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:
parent
627728bdde
commit
70175d5b6f
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue