fix(skills): ship the completion helper with the installed skill (#15554)

## Thinking Path

> - Paperclip manages work for AI agents.
> - Legacy agents use the Paperclip skill to save task status and
comments.
> - The skill names a script relative to the task workspace.
> - That script exists only in the Paperclip source repository.
> - Agents in other workspaces can hit a missing command or search for
it.
> - This PR ships the helper inside the skill and uses the installed
skill path.
> - The repository command remains available through a forwarding
wrapper.

## Linked Issues or Issue Description

Fixes #9527. Refs #15548 for the preceding runtime checkout guidance.
Related: #6052 addresses LF line endings for the repository helper; this
change addresses helper delivery and path resolution.

## What Changed

- Bundle the existing issue update helper with the Paperclip skill.
Preserve its HTTP checks, echoed-status check and two-attempt limit.
- Resolve the command from the installed skill directory. Use a verified
PATCH when that path is unavailable, without searching the filesystem.
- Keep the repository command as a wrapper that works from any
directory.
- Test shell execution and exact status/comment payloads through both
provider skill-home layouts, including paths with spaces.
- Add helper sources and existing verification tests to stock-harness
admission. Record an absent historical helper explicitly. Add the
missing declaration for the admission fingerprint export.

## Verification

- Complete directly affected source suites: 30 tests pass. They cover
skill delivery, preserved multiline comments and links, authentication
headers, empty responses, mismatched status, transient retries and
definitive rejections.
- Product E2E typecheck passes. Support suites: 1,835 Vitest tests pass,
one is skipped; 128 Node tests pass.
- Full local build and workspace typecheck pass.
- Full local repository tests are not claimed as passed. Embedded
PostgreSQL was unavailable in this worktree during the preceding task;
Linux CI will run the repository gates.
- The authorized matched Codex/Claude comparison is pending. It uses the
existing assigned-skill case and original oracle, one initial attempt
per profile and variant.
- CI and a fresh Greptile review are pending. Keep this PR in draft
until readiness gates complete.

## Risks

- Correct path resolution depends on the harness supplying the installed
skill path. The instructions use verified PATCH when that path is
unavailable.
- The helper still requires Bash, curl and jq. Its existing retry and
response-verification behavior is unchanged.
- Tests use the shared skill-directory symlink mechanism and an HTTP
fixture. Real provider completion behavior still requires the bounded
live comparison.
- This fix does not redesign native completion, legacy recovery or
ambiguous transport handling.

## Model Used

OpenAI Codex, GPT-6 family. The exact model build and context window are
not exposed in this session. Used code editing, shell tools and test
execution.

## 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
- [ ] All Paperclip CI gates are green
- [ ] 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:
DottaandPaperclip authored and GitHub committed 2026-10-08 07:21:30 -05:00
1 parent 71cd0a2621
commit 2f0c485dec
9 files changed
+271 -176

No files matched your search

@@ -1,15 +1,19 @@
import { spawn } from "node:child_process";
import http from "node:http";
import fs from "node:fs/promises";
import os from "node:os";
import { ensurePaperclipSkillSymlink } from "@paperclipai/adapter-utils/server-utils";
import type { AddressInfo } from "node:net";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
// End-to-end coverage for scripts/paperclip-issue-update.sh: the helper must
// End-to-end coverage for the skill-bundled issue update helper: the helper must
// only exit 0 when the server confirms the write by echoing the update, must
// classify failures (retry connection-level faults and 5xx, never retry a
// definitive 4xx), and must stop at two attempts total to honor the shared
// bounded-write-retry rule.
const HELPER_PATH = path.resolve("scripts/paperclip-issue-update.sh");
const HELPER_PATH = path.resolve("skills/paperclip/scripts/paperclip-issue-update.sh");
const REPO_HELPER_PATH = path.resolve("scripts/paperclip-issue-update.sh");
interface HelperResult {
code: number | null;
@@ -21,6 +25,8 @@ interface RecordedRequest {
method: string;
url: string;
body: string;
authorization: string | undefined;
runId: string | string[] | undefined;
}
describe("paperclip issue update helper", () => {
@@ -48,6 +54,8 @@ describe("paperclip issue update helper", () => {
method: req.method ?? "",
url: req.url ?? "",
body,
authorization: req.headers.authorization,
runId: req.headers["x-paperclip-run-id"],
};
requests.push(recorded);
respond(recorded, requests.length, res);
@@ -66,17 +74,19 @@ describe("paperclip issue update helper", () => {
return { baseUrl: `http://127.0.0.1:${port}`, requests };
}
function runHelper(apiUrl: string, args: string[]): Promise<HelperResult> {
function runHelper(apiUrl: string, args: string[], options: { helper?: string; cwd?: string; input?: string } = {}): Promise<HelperResult> {
return new Promise((resolve, reject) => {
const child = spawn("bash", [HELPER_PATH, ...args], {
const child = spawn("bash", [options.helper ?? HELPER_PATH, ...args], {
cwd: options.cwd,
env: {
...process.env,
PAPERCLIP_API_URL: apiUrl,
PAPERCLIP_API_KEY: "test-key",
PAPERCLIP_RUN_ID: "test-run",
},
stdio: ["ignore", "pipe", "pipe"],
stdio: ["pipe", "pipe", "pipe"],
});
child.stdin.end(options.input);
let stdout = "";
let stderr = "";
child.stdout.on("data", (chunk) => {
@@ -109,6 +119,44 @@ describe("paperclip issue update helper", () => {
expect(JSON.parse(requests[0]?.body ?? "{}")).toEqual({ status: "done", comment: "closing note" });
});
it.each([".claude/skills", "codex-home/skills"])(
"delivers the bundled helper through %s from an unrelated workspace", async skillsHome => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip completion helper "));
cleanupFns.push(() => fs.rm(root, { recursive: true, force: true }));
const source = path.join(root, "bundle", "paperclip");
const installed = path.join(root, skillsHome, "paperclip");
const cwd = path.join(root, "unrelated project");
await fs.cp(path.dirname(path.dirname(HELPER_PATH)), source, { recursive: true });
await fs.mkdir(path.dirname(installed), { recursive: true });
await fs.mkdir(cwd);
await ensurePaperclipSkillSymlink(source, installed);
await expect(fs.access(path.join(cwd, "scripts/paperclip-issue-update.sh"))).rejects.toThrow();
const savedComments: string[] = [];
const { baseUrl, requests } = await startServer((request, _attempt, res) => {
const payload = JSON.parse(request.body);
savedComments.push(payload.comment);
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({ id: "issue-1", status: payload.status, comment: { body: payload.comment } }));
});
const comment = "Done\n\n- Saved [report](/TST/issues/TST-1#document-report)\n- Literal `code`, $HOME and $(not-a-command)";
const result = await runHelper(baseUrl, ["--issue-id", "issue-1", "--status", "done"], {
helper: path.join(installed, "scripts/paperclip-issue-update.sh"), cwd, input: comment,
});
expect(result.code).toBe(0);
expect(JSON.parse(result.stdout)).toMatchObject({ status: "done", comment: { body: comment } });
expect(savedComments).toEqual([comment]);
expect(requests).toHaveLength(1);
expect(requests[0]).toMatchObject({ method: "PATCH", url: "/api/issues/issue-1", authorization: "Bearer test-key", runId: "test-run" });
});
it("keeps the repository entrypoint working outside the repository", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-helper-wrapper-"));
cleanupFns.push(() => fs.rm(root, { recursive: true, force: true }));
const result = await runHelper("", [...doneArgs, "--dry-run"], { helper: REPO_HELPER_PATH, cwd: root });
expect(result.code).toBe(0);
expect(JSON.parse(result.stdout)).toEqual({ status: "done", comment: "closing note" });
});
it("fails an empty 2xx body instead of treating it as success", async () => {
const { baseUrl } = await startServer((_request, _attempt, res) => {
res.writeHead(200, { "content-length": "0" });
@@ -547,6 +547,10 @@ describe("paperclip skill utils", () => {
expect(skillBody).toContain("Verify writes — never infer them");
expect(skillBody).toContain("An empty response body means the write FAILED");
expect(skillBody).toContain("Never pipe a disposition write through `head`/`tail`");
expect(skillBody).toContain("resolved relative to this installed `SKILL.md`, not the task workspace");
expect(skillBody).toContain("do not search the filesystem for it");
expect(skillBody).toContain('bash "$paperclip_skill_dir/scripts/paperclip-issue-update.sh"');
expect(skillBody).not.toMatch(/^scripts\/paperclip-issue-update\.sh/m);
// The helper's verification behavior (HTTP status parsing, retry
// classification, attempt bound, exit codes) is exercised end-to-end in
// paperclip-issue-update-helper.test.ts against a live local server.