From 70175d5b6febf3bb5b8d5693416332c6a3dd4d6e Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Fri, 31 Jul 2026 18:53:16 -0700 Subject: [PATCH] fix(garden-inbox): preserve declined candidates on reruns (#10586) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip uses company skills to give agents repeatable operating workflows. > - The garden-inbox skill asks a user to confirm reversible archive candidates. > - A user can leave a candidate unchecked because they want to keep it visible. > - A later confirmation pass currently checks that candidate again by default. > - This pull request adds a repeatable `--unselect` option for candidates declined in an earlier pass. > - The benefit is that repeated confirmation cards preserve the user's prior choice and make that history visible. ## Linked Issues or Issue Description **What happened?** When an inbox gardening confirmation was created again, candidates declined in an earlier pass could start checked again. **Expected behavior** The caller can identify previously declined candidates. Those candidates start unchecked and explain why they are unchecked. **Steps to reproduce** 1. Create a garden-inbox scan with an archive candidate in bucket A or B. 2. Leave the candidate unchecked in a confirmation pass. 3. Create a later confirmation for the same candidate. 4. Observe that the default selection does not preserve the earlier decline. **Paperclip version or commit** `7301fae942c3d5826974335cb40d6f1e0d95d1e0` **Deployment mode** Built from source. ## What Changed - Added repeatable `--unselect ISSUE_ID` parsing to the garden-inbox confirmation command. - Removed those issue IDs from the default checked options. - Added a description note for candidates declined in an earlier pass. - Rejected `--unselect` values that are not offered by the current scan. - Documented the repeat-pass workflow and added regression coverage. ## Verification - `node --test .agents/skills/garden-inbox/scripts/garden-inbox.test.mjs` - `git diff --check origin/master...HEAD` ## Risks - Low risk. The new option is opt-in, and existing confirmation behavior is unchanged when it is omitted. - An invalid issue ID now fails before any confirmation card is posted. > 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 — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex, GPT-5 family. The runtime does not expose the exact deployment model ID or context-window size. Reasoning, repository tools, shell execution, and GitHub tools were enabled. ## 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 --- .agents/skills/garden-inbox/SKILL.md | 2 ++ .../garden-inbox/scripts/garden-inbox.mjs | 34 +++++++++++++++---- .../scripts/garden-inbox.test.mjs | 30 ++++++++++++++++ 3 files changed, 60 insertions(+), 6 deletions(-) diff --git a/.agents/skills/garden-inbox/SKILL.md b/.agents/skills/garden-inbox/SKILL.md index 10fd653c18..8317916bc7 100644 --- a/.agents/skills/garden-inbox/SKILL.md +++ b/.agents/skills/garden-inbox/SKILL.md @@ -57,6 +57,8 @@ node .agents/skills/garden-inbox/scripts/garden-inbox.mjs confirm \ 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. +When a candidate was declined by the user in an earlier pass, pass `--unselect ` (repeatable) so it starts unchecked and its description notes the earlier decline. Never re-offer previously declined items as default-checked. + For development or payload review, suppress the POST: ```bash diff --git a/.agents/skills/garden-inbox/scripts/garden-inbox.mjs b/.agents/skills/garden-inbox/scripts/garden-inbox.mjs index d196b71d11..17eedf954d 100755 --- a/.agents/skills/garden-inbox/scripts/garden-inbox.mjs +++ b/.agents/skills/garden-inbox/scripts/garden-inbox.mjs @@ -25,7 +25,7 @@ class ApiError extends Error { 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 confirm [--issue-id ID] [--candidates FILE] [--unselect ISSUE_ID]... [--dry-run] garden-inbox.mjs apply [--issue-id ID] (--interaction-id ID | --interaction-file FILE) [--candidates FILE] [--dry-run] Common environment: @@ -494,16 +494,31 @@ function chunk(values, size) { return chunks; } -function confirmationBody(scanData, candidates, index, count) { +function unselectedKeySuffix(candidates, unselectedIds) { + const idsInCard = candidates + .map((candidate) => candidate.issueId) + .filter((issueId) => unselectedIds.has(issueId)) + .sort(); + if (idsInCard.length === 0) return ""; + const fingerprint = createHash("sha256") + .update(idsInCard.join("\n")) + .digest("hex") + .slice(0, 16); + return `:${fingerprint}`; +} + +function confirmationBody(scanData, candidates, index, count, unselectedIds = new Set()) { 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), + description: truncate(`${candidate.reason.message} Last activity: ${candidate.lastActivityAt}.${ + unselectedIds.has(candidate.issueId) ? " Declined in a previous pass; starts unchecked." : "" + }`, 500), })); const part = count > 1 ? ` (${index + 1}/${count})` : ""; return { kind: "request_checkbox_confirmation", - idempotencyKey: `garden-inbox:${scanData.scanId}:${index + 1}:${count}`, + idempotencyKey: `garden-inbox:${scanData.scanId}:${index + 1}:${count}${unselectedKeySuffix(candidates, unselectedIds)}`, title: `Confirm inbox archive candidates${part}`, summary: `Choose which reversible inbox entries to archive${part}.`, continuationPolicy: "wake_assignee", @@ -512,7 +527,8 @@ function confirmationBody(scanData, candidates, index, count) { prompt: `Select the inbox entries to archive${part}. Unchecked entries will remain visible.`, options, defaultSelectedOptionIds: candidates - .filter((candidate) => candidate.bucket === "A" || candidate.bucket === "B") + .filter((candidate) => (candidate.bucket === "A" || candidate.bucket === "B") + && !unselectedIds.has(candidate.issueId)) .map((candidate) => candidate.issueId), minSelected: 0, acceptLabel: "Archive selected", @@ -526,12 +542,17 @@ 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 candidateIds = new Set(data.candidates.map((candidate) => candidate.issueId)); + const unselectedIds = new Set(optionValues(options.unselect).map((id) => required(id, "--unselect"))); + for (const id of unselectedIds) { + if (!candidateIds.has(id)) throw new Error(`--unselect ${id} is not an offered candidate in this scan`); + } 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)); + const bodies = groups.map((candidates, index) => confirmationBody(data, candidates, index, groups.length, unselectedIds)); if (options.dry_run) { process.stdout.write(`${JSON.stringify({ dryRun: true, issueId: drivingIssueId ?? null, interactions: bodies }, null, 2)}\n`); return bodies; @@ -674,6 +695,7 @@ export { archiveTargetBody, classify, confirm, + confirmationBody, decodeJwtPayload, fetchMineInboxRows, normalizeApiBase, diff --git a/.agents/skills/garden-inbox/scripts/garden-inbox.test.mjs b/.agents/skills/garden-inbox/scripts/garden-inbox.test.mjs index 6ceec11f6d..3e9a892f2c 100644 --- a/.agents/skills/garden-inbox/scripts/garden-inbox.test.mjs +++ b/.agents/skills/garden-inbox/scripts/garden-inbox.test.mjs @@ -5,6 +5,7 @@ import { acceptedCandidates, archiveTargetBody, classify, + confirmationBody, decodeJwtPayload, fetchMineInboxRows, normalizeApiBase, @@ -76,6 +77,35 @@ test("returns only accepted options from the originating scan", () => { assert.deepEqual(acceptedCandidates(interaction, scan, new Map([[candidate.issueId, candidate]])), [candidate]); }); +test("unselected candidates start unchecked and are labelled as previously declined", () => { + const scan = { scanId: "scan-1", staleDays: 60 }; + const candidates = [ + { issueId: "issue-1", identifier: "PAP-1", title: "Kept before", bucket: "B", lastActivityAt: "2026-05-01T00:00:00.000Z", reason: { message: "Stale." } }, + { issueId: "issue-2", identifier: "PAP-2", title: "New candidate", bucket: "B", lastActivityAt: "2026-05-01T00:00:00.000Z", reason: { message: "Stale." } }, + ]; + const body = confirmationBody(scan, candidates, 0, 1, new Set(["issue-1"])); + assert.deepEqual(body.payload.defaultSelectedOptionIds, ["issue-2"]); + assert.match(body.payload.options[0].description, /Declined in a previous pass; starts unchecked\./); + assert.doesNotMatch(body.payload.options[1].description, /Declined in a previous pass/); + assert.notEqual(body.idempotencyKey, "garden-inbox:scan-1:1:1"); + assert.equal( + body.idempotencyKey, + confirmationBody(scan, candidates, 0, 1, new Set(["issue-1"])).idempotencyKey, + ); + assert.notEqual( + body.idempotencyKey, + confirmationBody(scan, candidates, 0, 1, new Set(["issue-2"])).idempotencyKey, + ); + assert.equal( + confirmationBody(scan, candidates, 0, 1).idempotencyKey, + "garden-inbox:scan-1:1:1", + ); + assert.equal( + confirmationBody(scan, [candidates[1]], 1, 2, new Set(["issue-1"])).idempotencyKey, + "garden-inbox:scan-1:2:2", + ); +}); + test("preserves an overridden scan user for archive and undo requests", () => { assert.deepEqual(archiveTargetBody({ userId: "target-user" }), { userId: "target-user" }); });