fix(garden-inbox): preserve declined candidates on reruns (#10586)

## 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 <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-07-31 18:53:16 -07:00 committed by GitHub
parent 627728bdde
commit 70175d5b6f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 60 additions and 6 deletions

View File

@ -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 <issueId>` (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

View File

@ -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,

View File

@ -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" });
});