feat(skills): add confirmed inbox gardening workflow (#10462)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Operators use the Mine inbox to track work that needs their
attention.
> - Old terminal work can make that inbox difficult to review.
> - Inbox cleanup must not change issues, branches, or workspaces.
> - Operators also need to approve each cleanup set before Paperclip
hides any item.
> - This pull request adds a garden-inbox skill with scan, confirmation,
and apply stages.
> - The benefit is a safe and repeatable inbox cleanup workflow with an
explicit undo path.

## Linked Issues or Issue Description

### Subsystem affected

Cross-cutting. This change adds an agent skill that calls existing
inbox, workspace, interaction, and archive APIs.

### Problem or motivation

Old terminal inbox entries can hide current work. Manual cleanup is slow
and can select the wrong entries. A single global result limit can also
hide rows from a large inbox.

### Proposed solution

Add a zero-dependency skill that classifies inbox rows, requests
checkbox confirmation, and archives only accepted rows for the current
user. Scan each relevant status separately, remove duplicate rows, and
report partial coverage if a status reaches its result limit.

### Alternatives considered

Automatic archive was rejected because it removes operator control. A
report-only tool was rejected because it does not complete the cleanup
workflow.

### Roadmap alignment

This is an agent skill. It does not add a new core product capability or
duplicate a planned roadmap item.

### Additional context

The apply stage changes only per-user inbox visibility. It provides the
archive API undo path for every successful item. A GitHub search found
no separate duplicate implementation; this pull request is the existing
result for the search terms.

## What Changed

- Added the `garden-inbox` skill workflow and command reference.
- Added a zero-dependency Node script with `scan`, `confirm`, and
`apply` commands.
- Added four exclusive classification buckets with machine-readable
reasons.
- Added per-status Mine scans, cross-status deduplication, and
partial-coverage reports for capped status results.
- Added chunked checkbox confirmations with deterministic idempotency
keys.
- Added strict accepted-option validation, per-item archive results, and
undo instructions.
- Added Node tests for classification, fail-safe inspection, accepted
selections, tampering, JWT decoding, URL normalization, and capped
scans.

## Verification

- Passed `node --check
.agents/skills/garden-inbox/scripts/garden-inbox.mjs`.
- Passed `node --test
.agents/skills/garden-inbox/scripts/garden-inbox.test.mjs`.
- Passed `pnpm -r typecheck` with inherited Paperclip runtime identity
variables removed.
- Passed `pnpm build` with inherited Paperclip runtime identity
variables removed.
- Ran `pnpm test:run`: 3,241 tests passed and 1 test skipped. One
unrelated current-base server integration assertion failed because it
received `heartbeat.scheduling_suppressed` instead of `issue_commented`
in `plugin-orchestration-apis.test.ts`. The exact test failed the same
way on one focused rerun. This pull request changes only
`.agents/skills/garden-inbox/**`.

## Risks

- Low risk. The skill is opt-in and does not change server or UI
behavior.
- `scan` and `confirm` do not mutate issues. `apply` only updates
per-user inbox archive visibility for accepted option IDs.
- API or workspace inspection failures keep items out of the archive
candidate set.
- A status result at the configured limit produces a visible
partial-coverage warning instead of claiming a complete scan.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected. See `CONTRIBUTING.md`.

## Model Used

- OpenAI `gpt-5.6-sol` through the Codex local adapter. The runtime did
not expose the context window size. The model used high-reasoning
coding, shell, API, and code-execution tools.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-07-30 16:36:46 -07:00 committed by GitHub
parent 39666aa906
commit a5d252d058
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 954 additions and 0 deletions

View File

@ -0,0 +1,98 @@
---
name: garden-inbox
description: Scan a Paperclip user's Mine inbox, classify reversible archive candidates, request checkbox confirmation, and archive only accepted selections. Use when asked to garden, clean up, prune, or tidy a Paperclip inbox without changing issues, branches, or workspaces.
---
# Garden Inbox
Use the bundled script for every stage. Keep the workflow strictly ordered: `scan` → `confirm` → `apply`.
## Safety contract
- Treat `scan` as read-only. It may read inbox, workspace, close-readiness, and local Git metadata only.
- Treat `confirm` as a confirmation-card write only. It must not archive inbox entries or mutate issue fields, branches, or workspaces.
- Run `apply` only after a resolved `request_checkbox_confirmation` interaction. It archives only accepted option IDs that also occur in the originating `candidates.json`.
- Remember that inbox archive state is per-user presentation state. It is reversible and does not change the underlying issue.
- Never substitute issue status changes, branch deletion, workspace cleanup, or issue deletion for inbox archiving.
## Inputs
Require `PAPERCLIP_API_URL` and `PAPERCLIP_API_KEY`. The script strips a trailing `/api` from the URL. It resolves the target user from the run JWT payload's `responsible_user_id`; use `--user-id <uuid>` only for an explicit target override.
Use a run-owned output directory when available:
```bash
RUN_DIR="${PAPERCLIP_RUN_SCRATCH_DIR:-${PAPERCLIP_TASK_SCRATCH_DIR:-.}}/garden-inbox"
mkdir -p "$RUN_DIR"
```
## 1. Scan
```bash
node .agents/skills/garden-inbox/scripts/garden-inbox.mjs scan \
--output-dir "$RUN_DIR" \
--stale-days 60
```
Inspect `garden-inbox-report.md` and `candidates.json`. The report groups every inbox row into exactly one bucket:
The scan reads Mine separately for each included issue status so the endpoint's global 500-row cap does not silently omit older rows. If any single-status query reaches that cap, both outputs mark coverage as possibly truncated; do not treat that scan as complete.
- A: merged/archived workspace and all linked work terminal; selected by default.
- B: terminal or workspace-gone work idle beyond the threshold; selected by default.
- C: stale work with commits ahead of base; never selected by default.
- D: keep; never offered for archiving.
Do not manually promote bucket D entries into the candidate file.
## 2. Confirm
Post checkbox interactions on the driving issue:
```bash
node .agents/skills/garden-inbox/scripts/garden-inbox.mjs confirm \
--issue-id "$PAPERCLIP_TASK_ID" \
--candidates "$RUN_DIR/candidates.json"
```
The script posts sequential cards when a scan has more than 200 candidates. Re-running `confirm` with the same scan file is idempotent. Leave the driving issue in the waiting posture required by the surrounding Paperclip heartbeat workflow.
For development or payload review, suppress the POST:
```bash
node .agents/skills/garden-inbox/scripts/garden-inbox.mjs confirm \
--issue-id "$PAPERCLIP_TASK_ID" \
--candidates "$RUN_DIR/candidates.json" \
--dry-run
```
## 3. Apply accepted selections
After an interaction-resolution wake, take the resolved interaction ID from the wake payload and run:
```bash
node .agents/skills/garden-inbox/scripts/garden-inbox.mjs apply \
--issue-id "$PAPERCLIP_TASK_ID" \
--interaction-id "$INTERACTION_ID" \
--candidates "$RUN_DIR/candidates.json"
```
On rejection, expiry, or an accepted empty selection, the script archives nothing. Apply preserves the scan file's target user, including an explicit `--user-id` override. Its summary includes the API undo path and target-user body for every archived row.
Test `apply` without API writes by supplying a saved interaction response:
```bash
node .agents/skills/garden-inbox/scripts/garden-inbox.mjs apply \
--issue-id "$PAPERCLIP_TASK_ID" \
--interaction-file resolved-interaction.json \
--candidates "$RUN_DIR/candidates.json" \
--dry-run
```
## Verify the bundled logic
Run the zero-dependency Node tests after changing classification or selection safety:
```bash
node --test .agents/skills/garden-inbox/scripts/garden-inbox.test.mjs
```

View File

@ -0,0 +1,699 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { execFileSync } from "node:child_process";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { pathToFileURL } from "node:url";
const TERMINAL_STATUSES = new Set(["done", "cancelled"]);
const OFFERED_BUCKETS = new Set(["A", "B", "C"]);
const DEFAULT_STALE_DAYS = 60;
const INTERACTION_LIMIT = 200;
const INBOX_MINE_STATUSES = ["backlog", "todo", "in_progress", "in_review", "blocked", "done"];
const INBOX_QUERY_CAP = 500;
class ApiError extends Error {
constructor(status, message, body = null) {
super(`${status}: ${message}`);
this.name = "ApiError";
this.status = status;
this.body = body;
}
}
function usage() {
return `Usage:
garden-inbox.mjs [scan] [--user-id UUID] [--stale-days 60] [--output-dir DIR]
garden-inbox.mjs confirm [--issue-id ID] [--candidates FILE] [--dry-run]
garden-inbox.mjs apply [--issue-id ID] (--interaction-id ID | --interaction-file FILE) [--candidates FILE] [--dry-run]
Common environment:
PAPERCLIP_API_URL, PAPERCLIP_API_KEY, PAPERCLIP_RUN_ID, PAPERCLIP_TASK_ID`;
}
function parseArgs(argv) {
const args = [...argv];
let command = "scan";
if (args[0] && !args[0].startsWith("-")) command = args.shift();
if (!["scan", "confirm", "apply"].includes(command)) throw new Error(`Unknown command: ${command}`);
const options = {};
while (args.length > 0) {
const token = args.shift();
if (token === "--help" || token === "-h") {
options.help = true;
continue;
}
if (!token.startsWith("--")) throw new Error(`Unexpected argument: ${token}`);
const key = token.slice(2).replaceAll("-", "_");
if (key === "dry_run") {
options[key] = true;
continue;
}
const value = args.shift();
if (value === undefined || value.startsWith("--")) throw new Error(`${token} requires a value`);
if (options[key] === undefined) options[key] = value;
else options[key] = Array.isArray(options[key]) ? [...options[key], value] : [options[key], value];
}
return { command, options };
}
function required(value, name) {
if (typeof value !== "string" || value.trim() === "") throw new Error(`${name} is required`);
return value.trim();
}
function normalizeApiBase(value) {
let base = required(value, "PAPERCLIP_API_URL").replace(/\/+$/, "");
if (base.endsWith("/api")) base = base.slice(0, -4);
return base;
}
function runtime(options, { requireApi = true } = {}) {
const apiUrl = options.api_url ?? process.env.PAPERCLIP_API_URL;
const apiKey = options.api_key ?? process.env.PAPERCLIP_API_KEY;
return {
apiBase: requireApi ? normalizeApiBase(apiUrl) : (apiUrl ? normalizeApiBase(apiUrl) : null),
apiKey: requireApi ? required(apiKey, "PAPERCLIP_API_KEY") : apiKey ?? null,
runId: options.run_id ?? process.env.PAPERCLIP_RUN_ID ?? null,
};
}
async function apiRequest(context, path, { method = "GET", body } = {}) {
const headers = { Accept: "application/json", Authorization: `Bearer ${context.apiKey}` };
if (body !== undefined) headers["Content-Type"] = "application/json";
if (method !== "GET" && context.runId) headers["X-Paperclip-Run-Id"] = context.runId;
const response = await fetch(`${context.apiBase}/api${path}`, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
});
const text = await response.text();
let parsed = null;
if (text) {
try { parsed = JSON.parse(text); } catch { parsed = text; }
}
if (!response.ok) {
const message = parsed && typeof parsed === "object"
? parsed.error ?? parsed.message ?? response.statusText
: parsed ?? response.statusText;
throw new ApiError(response.status, String(message), parsed);
}
return parsed;
}
function decodeJwtPayload(token) {
const parts = required(token, "PAPERCLIP_API_KEY").split(".");
if (parts.length < 2) throw new Error("PAPERCLIP_API_KEY is not a JWT; pass --user-id explicitly");
try {
return JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"));
} catch {
throw new Error("Could not decode the PAPERCLIP_API_KEY JWT payload; pass --user-id explicitly");
}
}
function resolveUserId(options, apiKey) {
if (options.user_id) return required(options.user_id, "--user-id");
const userId = decodeJwtPayload(apiKey).responsible_user_id;
if (typeof userId !== "string" || userId.trim() === "") {
throw new Error("Run JWT has no responsible_user_id; pass --user-id explicitly");
}
return userId.trim();
}
function parsePositiveInteger(value, fallback, name) {
if (value === undefined) return fallback;
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 3650) {
throw new Error(`${name} must be an integer from 1 to 3650`);
}
return parsed;
}
function validDate(value) {
if (!value) return null;
const date = new Date(value);
return Number.isNaN(date.getTime()) ? null : date;
}
function latestDate(...values) {
return values.flat().map(validDate).filter(Boolean).sort((left, right) => right.getTime() - left.getTime())[0] ?? null;
}
function runGit(args, cwd) {
try {
return execFileSync("git", ["-C", cwd, ...args], {
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
timeout: 5_000,
}).trim();
} catch {
return "";
}
}
function gitLastCommitAt(branchName, roots) {
if (!branchName) return null;
for (const root of [...new Set(roots.filter(Boolean))]) {
const parsed = validDate(runGit(["log", "-1", "--format=%cI", branchName], root));
if (parsed) return parsed;
}
return null;
}
function findLocalBranch(identifier, roots) {
if (!identifier) return null;
const needle = identifier.toLowerCase();
for (const root of [...new Set(roots.filter(Boolean))]) {
const branches = runGit(["for-each-ref", "--format=%(refname:short)", "refs/heads"], root)
.split("\n").map((branch) => branch.trim()).filter(Boolean)
.filter((branch) => branch.toLowerCase().includes(needle));
if (branches.length === 1) return branches[0];
}
return null;
}
async function mapLimit(values, limit, mapper) {
const results = new Array(values.length);
let cursor = 0;
async function worker() {
while (cursor < values.length) {
const index = cursor++;
results[index] = await mapper(values[index], index);
}
}
await Promise.all(Array.from({ length: Math.min(limit, values.length) }, worker));
return results;
}
async function fetchMineInboxRows(context, userId) {
const statusResults = await Promise.all(INBOX_MINE_STATUSES.map(async (status) => {
const query = new URLSearchParams({ userId, status });
const rows = await apiRequest(context, `/agents/me/inbox/mine?${query}`);
if (!Array.isArray(rows)) throw new Error(`Inbox API returned a non-array response for status ${status}`);
return { status, rows };
}));
const rowsById = new Map();
const statusCounts = {};
const cappedStatuses = [];
let duplicateCount = 0;
for (const { status, rows } of statusResults) {
statusCounts[status] = rows.length;
if (rows.length >= INBOX_QUERY_CAP) cappedStatuses.push(status);
for (const issue of rows) {
if (rowsById.has(issue.id)) duplicateCount += 1;
rowsById.set(issue.id, issue);
}
}
return {
rows: [...rowsById.values()],
coverage: {
strategy: "per_status",
queryCap: INBOX_QUERY_CAP,
statusCounts,
cappedStatuses,
duplicateCount,
complete: cappedStatuses.length === 0,
},
};
}
async function inspectWorkspace(context, workspaceId, issueIdentifiers, gitRoot) {
let workspace = null;
let readiness = null;
let gone = false;
let error = null;
try {
workspace = await apiRequest(context, `/execution-workspaces/${encodeURIComponent(workspaceId)}`);
} catch (caught) {
if (caught instanceof ApiError && caught.status === 404) gone = true;
else error = caught.message;
}
if (workspace) {
try {
readiness = await apiRequest(context, `/execution-workspaces/${encodeURIComponent(workspaceId)}/close-readiness`);
} catch (caught) {
error = error ?? caught.message;
}
}
const roots = [workspace?.cwd, workspace?.providerRef, readiness?.git?.repoRoot, readiness?.git?.workspacePath, gitRoot];
let branchName = workspace?.branchName ?? readiness?.git?.branchName ?? null;
if (!branchName && gone) {
const matches = issueIdentifiers.map((identifier) => findLocalBranch(identifier, roots)).filter(Boolean);
if (new Set(matches).size === 1) branchName = matches[0];
}
const branchCommitAt = gitLastCommitAt(branchName, roots);
return {
id: workspaceId,
gone,
error,
status: workspace?.status ?? null,
branchName,
lastUsedAt: workspace?.lastUsedAt ?? null,
branchCommitAt: branchCommitAt?.toISOString() ?? null,
readiness,
};
}
function reason(code, message, facts = {}) {
return { code, message, facts };
}
function classify(issue, workspaceInfo, staleDays, now) {
const terminal = TERMINAL_STATUSES.has(issue.status);
const workspaceArchived = workspaceInfo?.status === "archived";
const workspaceGone = Boolean(workspaceInfo?.gone);
const readiness = workspaceInfo?.readiness ?? null;
const linkedIssues = Array.isArray(readiness?.linkedIssues) ? readiness.linkedIssues : [];
const allLinkedIssuesTerminal = Boolean(readiness) && linkedIssues.every((linked) => linked.isTerminal === true);
const mergedIntoBase = readiness?.git?.isMergedIntoBase === true;
const aheadCount = Number.isInteger(readiness?.git?.aheadCount) ? readiness.git.aheadCount : null;
const blockerAttention = issue.blockerAttention?.state;
const hasOpenBlockers = issue.status === "blocked"
|| (Array.isArray(issue.blockedBy) && issue.blockedBy.some((blocker) => !TERMINAL_STATUSES.has(blocker.status)))
|| (typeof blockerAttention === "string" && blockerAttention !== "none");
const awaitingUser = issue.blockedInboxAttention?.state === "awaiting_decision"
|| (issue.status === "in_review" && Boolean(issue.assigneeUserId));
const issueActivity = latestDate(issue.lastActivityAt, issue.updatedAt, issue.createdAt);
const branchActivity = latestDate(workspaceInfo?.branchCommitAt, workspaceInfo?.lastUsedAt);
const lastActivity = latestDate(issueActivity, branchActivity) ?? new Date(0);
const cutoff = new Date(now.getTime() - staleDays * 86_400_000);
const stale = lastActivity.getTime() <= cutoff.getTime();
const facts = {
issueStatus: issue.status,
workspaceStatus: workspaceInfo?.status ?? null,
workspaceGone,
workspaceInspectionError: workspaceInfo?.error ?? null,
mergedIntoBase,
allLinkedIssuesTerminal,
aheadCount,
staleDays,
};
if (workspaceInfo?.error) {
return {
bucket: "D",
reason: reason("workspace_inspection_failed", "Keep: workspace archive safety could not be verified.", facts),
lastActivity,
};
}
if (hasOpenBlockers) return { bucket: "D", reason: reason("open_blockers", "Keep: the issue has unresolved blocker or liveness attention.", facts), lastActivity };
if (awaitingUser) return { bucket: "D", reason: reason("pending_user_action", "Keep: the issue is awaiting a user decision or review.", facts), lastActivity };
if (!terminal) {
const code = issue.status === "in_review" ? "pending_interaction_or_review" : "non_terminal_status";
return { bucket: "D", reason: reason(code, `Keep: issue status ${issue.status} is not terminal.`, facts), lastActivity };
}
if ((mergedIntoBase || workspaceArchived) && allLinkedIssuesTerminal) {
const code = workspaceArchived ? "workspace_archived_finished" : "merged_finished";
return { bucket: "A", reason: reason(code, "Archive candidate: work is merged or archived and every linked issue is terminal.", facts), lastActivity };
}
if (!stale) return { bucket: "D", reason: reason("recent_activity", `Keep: activity is newer than ${staleDays} days.`, facts), lastActivity };
if (aheadCount !== null && aheadCount > 0) {
return { bucket: "C", reason: reason("stale_unmerged_commits", `Review manually: stale branch is ${aheadCount} commit(s) ahead of base.`, facts), lastActivity };
}
if (terminal || workspaceGone) {
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 };
}
return { bucket: "D", reason: reason("insufficient_archive_evidence", "Keep: archive safety conditions were not met.", facts), lastActivity };
}
function candidateFrom(issue, workspace, classification) {
return {
issueId: issue.id,
identifier: issue.identifier ?? null,
title: issue.title,
bucket: classification.bucket,
reason: classification.reason,
executionWorkspaceId: issue.executionWorkspaceId ?? null,
workspaceStatus: workspace?.status ?? null,
workspaceGone: Boolean(workspace?.gone),
branchName: workspace?.branchName ?? null,
lastActivityAt: classification.lastActivity.toISOString(),
};
}
function markdownEscape(value) {
return String(value ?? "").replaceAll("|", "\\|").replaceAll("\n", " ");
}
function renderReport(scanResult) {
const labels = {
A: "A — merged and finished (default checked)",
B: "B — stale (default checked)",
C: "C — stale but unmerged (default unchecked)",
D: "D — keep (not offered)",
};
const lines = [
"# Garden inbox scan", "",
`- Generated: ${scanResult.generatedAt}`,
`- Target user: \`${scanResult.userId}\``,
`- Stale threshold: ${scanResult.staleDays} days`,
`- Inbox rows classified: ${scanResult.items.length}`,
`- Archive candidates: ${scanResult.candidates.length}`,
`- Scan coverage: ${scanResult.coverage.complete ? "complete across per-status queries" : "possibly truncated"}`,
];
if (!scanResult.coverage.complete) {
lines.push(
"",
"> [!WARNING]",
`> Coverage may be incomplete: ${scanResult.coverage.cappedStatuses.map((status) => `\`${status}\``).join(", ")} returned at least ${scanResult.coverage.queryCap} rows, the Mine endpoint cap.`,
);
}
for (const bucket of ["A", "B", "C", "D"]) {
const items = scanResult.items.filter((item) => item.bucket === bucket);
lines.push("", `## ${labels[bucket]} (${items.length})`, "");
if (items.length === 0) {
lines.push("_None._");
continue;
}
lines.push("| Issue | Title | Reason | Workspace / branch | Last activity |", "|---|---|---|---|---|");
for (const item of items) {
const issue = item.identifier ? `\`${markdownEscape(item.identifier)}\`` : `\`${item.issueId}\``;
const workspace = [item.workspaceStatus, item.branchName].filter(Boolean).join(" / ") || (item.workspaceGone ? "gone" : "none");
lines.push(`| ${issue} | ${markdownEscape(item.title)} | \`${item.reason.code}\` — ${markdownEscape(item.reason.message)} | ${markdownEscape(workspace)} | ${item.lastActivityAt} |`);
}
}
lines.push("");
return lines.join("\n");
}
function writeText(path, contents) {
const target = resolve(path);
mkdirSync(dirname(target), { recursive: true });
writeFileSync(target, contents);
return target;
}
function readJson(path) {
return JSON.parse(readFileSync(resolve(path), "utf8"));
}
function stableScanId(scanResult) {
const fingerprint = {
userId: scanResult.userId,
staleDays: scanResult.staleDays,
generatedAt: scanResult.generatedAt,
candidates: scanResult.candidates.map((item) => ({
issueId: item.issueId,
bucket: item.bucket,
reason: item.reason.code,
lastActivityAt: item.lastActivityAt,
})),
};
return createHash("sha256").update(JSON.stringify(fingerprint)).digest("hex").slice(0, 24);
}
async function scan(options) {
const context = runtime(options);
const userId = resolveUserId(options, context.apiKey);
const staleDays = parsePositiveInteger(options.stale_days, DEFAULT_STALE_DAYS, "--stale-days");
const now = options.now ? validDate(options.now) : new Date();
if (!now) throw new Error("--now must be an ISO date");
const outputDir = options.output_dir ?? ".";
const candidatesPath = options.candidates ?? resolve(outputDir, "candidates.json");
const reportPath = options.report ?? resolve(outputDir, "garden-inbox-report.md");
const { rows, coverage } = await fetchMineInboxRows(context, userId);
const issuesByWorkspace = new Map();
for (const issue of rows) {
if (!issue.executionWorkspaceId) continue;
const identifiers = issuesByWorkspace.get(issue.executionWorkspaceId) ?? [];
if (issue.identifier) identifiers.push(issue.identifier);
issuesByWorkspace.set(issue.executionWorkspaceId, identifiers);
}
const workspaceIds = [...issuesByWorkspace.keys()];
const inspected = await mapLimit(workspaceIds, 8, (workspaceId) => inspectWorkspace(
context, workspaceId, issuesByWorkspace.get(workspaceId), options.git_root ?? process.cwd(),
));
const workspaceById = new Map(inspected.map((entry) => [entry.id, entry]));
const items = rows.map((issue) => {
const workspace = issue.executionWorkspaceId ? workspaceById.get(issue.executionWorkspaceId) ?? null : null;
return candidateFrom(issue, workspace, classify(issue, workspace, staleDays, now));
});
const scanResult = {
schemaVersion: 1,
generatedAt: now.toISOString(),
userId,
staleDays,
sourceCount: rows.length,
coverage,
items,
candidates: items.filter((item) => OFFERED_BUCKETS.has(item.bucket)),
};
scanResult.scanId = stableScanId(scanResult);
const output = {
schemaVersion: 1,
scanId: scanResult.scanId,
generatedAt: scanResult.generatedAt,
userId,
staleDays,
sourceCount: rows.length,
coverage,
candidates: scanResult.candidates,
kept: items.filter((item) => item.bucket === "D"),
};
const candidatesFile = writeText(candidatesPath, `${JSON.stringify(output, null, 2)}\n`);
const report = renderReport(scanResult);
const reportFile = writeText(reportPath, report);
process.stdout.write(`${report}\nCandidates JSON: ${candidatesFile}\nReport: ${reportFile}\n`);
return output;
}
function truncate(value, limit) {
const text = String(value ?? "").trim();
if (text.length <= limit) return text;
return `${text.slice(0, Math.max(0, limit - 1)).trimEnd()}…`;
}
function candidateFile(options) {
const data = readJson(options.candidates ?? "candidates.json");
if (data?.schemaVersion !== 1
|| typeof data.scanId !== "string"
|| typeof data.userId !== "string"
|| data.userId.trim() === ""
|| !Array.isArray(data.candidates)) {
throw new Error("Candidates file is not a garden-inbox schemaVersion 1 scan");
}
for (const item of data.candidates) {
if (!item?.issueId || !OFFERED_BUCKETS.has(item.bucket)) {
throw new Error("Candidates file contains an invalid or non-offered item");
}
}
return data;
}
function archiveTargetBody(scanData) {
return { userId: scanData.userId };
}
function chunk(values, size) {
const chunks = [];
for (let index = 0; index < values.length; index += size) chunks.push(values.slice(index, index + size));
return chunks;
}
function confirmationBody(scanData, candidates, index, count) {
const options = candidates.map((candidate) => ({
id: candidate.issueId,
label: truncate(`${candidate.identifier ?? candidate.issueId} — ${candidate.title}`, 120),
description: truncate(`${candidate.reason.message} Last activity: ${candidate.lastActivityAt}.`, 500),
}));
const part = count > 1 ? ` (${index + 1}/${count})` : "";
return {
kind: "request_checkbox_confirmation",
idempotencyKey: `garden-inbox:${scanData.scanId}:${index + 1}:${count}`,
title: `Confirm inbox archive candidates${part}`,
summary: `Choose which reversible inbox entries to archive${part}.`,
continuationPolicy: "wake_assignee",
payload: {
version: 1,
prompt: `Select the inbox entries to archive${part}. Unchecked entries will remain visible.`,
options,
defaultSelectedOptionIds: candidates
.filter((candidate) => candidate.bucket === "A" || candidate.bucket === "B")
.map((candidate) => candidate.issueId),
minSelected: 0,
acceptLabel: "Archive selected",
rejectLabel: "Keep everything",
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.`,
},
};
}
async function confirm(options) {
const data = candidateFile(options);
const drivingIssueId = options.issue_id ?? process.env.PAPERCLIP_TASK_ID;
if (!options.dry_run) required(drivingIssueId, "--issue-id or PAPERCLIP_TASK_ID");
const groups = chunk(data.candidates, INTERACTION_LIMIT);
if (groups.length === 0) {
process.stdout.write("No archive candidates; no confirmation interaction created.\n");
return [];
}
const bodies = groups.map((candidates, index) => confirmationBody(data, candidates, index, groups.length));
if (options.dry_run) {
process.stdout.write(`${JSON.stringify({ dryRun: true, issueId: drivingIssueId ?? null, interactions: bodies }, null, 2)}\n`);
return bodies;
}
const context = runtime(options);
const created = [];
for (const body of bodies) {
created.push(await apiRequest(context, `/issues/${encodeURIComponent(drivingIssueId)}/interactions`, {
method: "POST",
body,
}));
}
process.stdout.write(`${JSON.stringify({
scanId: data.scanId,
createdInteractions: created.map((item) => ({
id: item.id,
status: item.status,
idempotencyKey: item.idempotencyKey,
})),
}, null, 2)}\n`);
return created;
}
function flattenInteractionFile(value) {
if (Array.isArray(value)) return value;
if (Array.isArray(value?.interactions)) return value.interactions;
if (value?.interaction && typeof value.interaction === "object") return [value.interaction];
return value && typeof value === "object" ? [value] : [];
}
function optionValues(value) {
if (value === undefined) return [];
return Array.isArray(value) ? value : [value];
}
async function resolveInteractions(options, context, drivingIssueId) {
const files = optionValues(options.interaction_file);
const ids = optionValues(options.interaction_id);
const fromFiles = files.flatMap((file) => flattenInteractionFile(readJson(file)));
if (ids.length === 0) return fromFiles;
required(drivingIssueId, "--issue-id or PAPERCLIP_TASK_ID");
const live = await apiRequest(context, `/issues/${encodeURIComponent(drivingIssueId)}/interactions`);
if (!Array.isArray(live)) throw new Error("Interactions API returned a non-array response");
const byId = new Map(live.map((interaction) => [interaction.id, interaction]));
for (const id of ids) {
if (!byId.has(id)) throw new Error(`Interaction ${id} was not found on the driving issue`);
fromFiles.push(byId.get(id));
}
return fromFiles;
}
function acceptedCandidates(interaction, scanData, candidateById) {
if (interaction?.kind !== "request_checkbox_confirmation") {
throw new Error(`Interaction ${interaction?.id ?? "(unknown)"} is not request_checkbox_confirmation`);
}
if (typeof interaction.idempotencyKey !== "string"
|| !interaction.idempotencyKey.startsWith(`garden-inbox:${scanData.scanId}:`)) {
throw new Error(`Interaction ${interaction.id ?? "(unknown)"} does not belong to scan ${scanData.scanId}`);
}
if (interaction.status !== "accepted" || interaction.result?.outcome !== "accepted") return [];
const selected = interaction.result?.selectedOptionIds ?? [];
if (!Array.isArray(selected)) throw new Error("Accepted interaction has invalid selectedOptionIds");
const optionIds = new Set((interaction.payload?.options ?? []).map((option) => option.id));
const seen = new Set();
return selected.map((issueId) => {
if (seen.has(issueId)) throw new Error(`Interaction selected duplicate option id ${issueId}`);
seen.add(issueId);
if (!optionIds.has(issueId)) throw new Error(`Selected id ${issueId} was not an option in the interaction`);
const candidate = candidateById.get(issueId);
if (!candidate || !OFFERED_BUCKETS.has(candidate.bucket)) {
throw new Error(`Selected id ${issueId} is not an offered candidate in the originating scan`);
}
return candidate;
});
}
function renderApplySummary(results, interactions, dryRun, userId) {
const lines = [
`# Garden inbox ${dryRun ? "dry-run " : ""}apply summary`, "",
`- Interactions inspected: ${interactions.length}`,
`- Entries ${dryRun ? "that would be archived" : "archived"}: ${results.filter((result) => result.ok).length}`,
`- Failures: ${results.filter((result) => !result.ok).length}`,
];
if (results.length === 0) {
lines.push("", "Nothing was archived: the interaction was rejected, unresolved, expired, or accepted with no selections.");
}
for (const result of results) {
lines.push("", `- ${result.ok ? "OK" : "FAILED"}: ${result.identifier ?? result.issueId} — ${result.message}`);
if (result.ok) {
lines.push(` Undo: \`DELETE /api/issues/${result.issueId}/inbox-archive\` with body \`${JSON.stringify({ userId })}\`, or use **Unarchive** in the issue properties pane.`);
}
}
return `${lines.join("\n")}\n`;
}
async function applyAccepted(options) {
const data = candidateFile(options);
const drivingIssueId = options.issue_id ?? process.env.PAPERCLIP_TASK_ID ?? null;
const needsLiveApi = optionValues(options.interaction_id).length > 0 || !options.dry_run;
const context = runtime(options, { requireApi: needsLiveApi });
const interactions = await resolveInteractions(options, context, drivingIssueId);
if (interactions.length === 0) throw new Error("Provide --interaction-id or --interaction-file");
const candidateById = new Map(data.candidates.map((candidate) => [candidate.issueId, candidate]));
const accepted = [];
const seen = new Set();
for (const interaction of interactions) {
for (const candidate of acceptedCandidates(interaction, data, candidateById)) {
if (!seen.has(candidate.issueId)) accepted.push(candidate);
seen.add(candidate.issueId);
}
}
const results = [];
for (const candidate of accepted) {
if (options.dry_run) {
results.push({ ...candidate, ok: true, message: "would archive this accepted inbox entry" });
continue;
}
try {
await apiRequest(context, `/issues/${encodeURIComponent(candidate.issueId)}/inbox-archive`, {
method: "POST",
body: archiveTargetBody(data),
});
results.push({ ...candidate, ok: true, message: "archived accepted inbox entry" });
} catch (error) {
results.push({ ...candidate, ok: false, message: error.message });
}
}
const summary = renderApplySummary(results, interactions, Boolean(options.dry_run), data.userId);
process.stdout.write(summary);
if (options.summary) writeText(options.summary, summary);
if (results.some((result) => !result.ok)) process.exitCode = 1;
return results;
}
export {
acceptedCandidates,
archiveTargetBody,
classify,
confirm,
decodeJwtPayload,
fetchMineInboxRows,
normalizeApiBase,
scan,
};
async function main() {
const { command, options } = parseArgs(process.argv.slice(2));
if (options.help) {
process.stdout.write(`${usage()}\n`);
return;
}
if (command === "scan") await scan(options);
else if (command === "confirm") await confirm(options);
else await applyAccepted(options);
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
main().catch((error) => {
process.stderr.write(`garden-inbox: ${error.message}\n`);
process.exitCode = 1;
});
}

View File

@ -0,0 +1,157 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
acceptedCandidates,
archiveTargetBody,
classify,
decodeJwtPayload,
fetchMineInboxRows,
normalizeApiBase,
} from "./garden-inbox.mjs";
const now = new Date("2026-07-29T00:00:00.000Z");
function issue(overrides = {}) {
return {
id: "issue-1",
identifier: "PAP-1",
title: "Example",
status: "done",
updatedAt: "2025-01-01T00:00:00.000Z",
blockedBy: [],
...overrides,
};
}
function workspace(overrides = {}) {
return {
gone: false,
error: null,
status: "active",
branchCommitAt: "2025-01-01T00:00:00.000Z",
readiness: {
git: { isMergedIntoBase: false, aheadCount: 0 },
linkedIssues: [{ isTerminal: true }],
},
...overrides,
};
}
test("classifies each archive and keep condition into one bucket", () => {
const merged = workspace({
readiness: {
git: { isMergedIntoBase: true, aheadCount: 0 },
linkedIssues: [{ isTerminal: true }],
},
});
assert.equal(classify(issue(), merged, 60, now).bucket, "A");
assert.equal(classify(issue(), workspace(), 60, now).bucket, "B");
assert.equal(classify(issue(), workspace({
readiness: {
git: { isMergedIntoBase: false, aheadCount: 2 },
linkedIssues: [{ isTerminal: true }],
},
}), 60, now).bucket, "C");
assert.equal(classify(issue({ status: "in_progress" }), workspace(), 60, now).bucket, "D");
});
test("keeps candidates when workspace safety inspection fails", () => {
const result = classify(issue(), workspace({ error: "503 Service Unavailable", readiness: null }), 60, now);
assert.equal(result.bucket, "D");
assert.equal(result.reason.code, "workspace_inspection_failed");
});
test("returns only accepted options from the originating scan", () => {
const candidate = { issueId: "issue-1", bucket: "A" };
const scan = { scanId: "scan-1" };
const interaction = {
id: "interaction-1",
kind: "request_checkbox_confirmation",
idempotencyKey: "garden-inbox:scan-1:1:1",
status: "accepted",
payload: { options: [{ id: "issue-1" }] },
result: { outcome: "accepted", selectedOptionIds: ["issue-1"] },
};
assert.deepEqual(acceptedCandidates(interaction, scan, new Map([[candidate.issueId, candidate]])), [candidate]);
});
test("preserves an overridden scan user for archive and undo requests", () => {
assert.deepEqual(archiveTargetBody({ userId: "target-user" }), { userId: "target-user" });
});
test("rejects selected ids that were not offered", () => {
const scan = { scanId: "scan-1" };
const interaction = {
id: "interaction-1",
kind: "request_checkbox_confirmation",
idempotencyKey: "garden-inbox:scan-1:1:1",
status: "accepted",
payload: { options: [{ id: "issue-1" }] },
result: { outcome: "accepted", selectedOptionIds: ["issue-2"] },
};
assert.throws(
() => acceptedCandidates(interaction, scan, new Map()),
/was not an option in the interaction/,
);
});
test("decodes the responsible user and normalizes API URLs locally", () => {
const payload = Buffer.from(JSON.stringify({ responsible_user_id: "user-1" })).toString("base64url");
assert.equal(decodeJwtPayload(`header.${payload}.signature`).responsible_user_id, "user-1");
assert.equal(normalizeApiBase("https://paperclip.example/api/"), "https://paperclip.example");
});
test("scans Mine once per status and merges rows by issue id", async (t) => {
const requestedStatuses = [];
t.mock.method(globalThis, "fetch", async (url) => {
const parsed = new URL(url);
const status = parsed.searchParams.get("status");
requestedStatuses.push(status);
const rows = status === "todo"
? [issue({ id: "shared", status: "todo" })]
: status === "done"
? [issue({ id: "shared", status: "done" }), issue({ id: "done-only" })]
: [];
return new Response(JSON.stringify(rows), { status: 200 });
});
const result = await fetchMineInboxRows({
apiBase: "https://paperclip.example",
apiKey: "secret",
}, "user-1");
assert.deepEqual(requestedStatuses, ["backlog", "todo", "in_progress", "in_review", "blocked", "done"]);
assert.equal(result.rows.length, 2);
assert.equal(result.rows.find((row) => row.id === "shared").status, "done");
assert.equal(result.coverage.duplicateCount, 1);
assert.equal(result.coverage.complete, true);
assert.deepEqual(result.coverage.statusCounts, {
backlog: 0,
todo: 1,
in_progress: 0,
in_review: 0,
blocked: 0,
done: 2,
});
});
test("warns when an individual status query reaches the Mine endpoint cap", async (t) => {
t.mock.method(globalThis, "fetch", async (url) => {
const status = new URL(url).searchParams.get("status");
const rows = status === "done"
? Array.from({ length: 500 }, (_, index) => issue({ id: `done-${index}` }))
: [];
return new Response(JSON.stringify(rows), { status: 200 });
});
const result = await fetchMineInboxRows({
apiBase: "https://paperclip.example",
apiKey: "secret",
}, "user-1");
assert.equal(result.rows.length, 500);
assert.equal(result.coverage.complete, false);
assert.deepEqual(result.coverage.cappedStatuses, ["done"]);
assert.equal(result.coverage.queryCap, 500);
});