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

+4 -163
View File
@@ -1,166 +1,7 @@
#!/usr/bin/env bash
# Keep the repository command compatible; the deployable implementation belongs
# to the skill so agents do not need a checkout of this repository.
set -euo pipefail
usage() {
cat <<'EOF'
Usage:
scripts/paperclip-issue-update.sh [--issue-id ID] [--status STATUS] [--comment TEXT] [--dry-run]
Reads a multiline markdown comment from stdin when stdin is piped. This preserves
newlines when building the JSON payload for PATCH /api/issues/{issueId}.
Examples:
scripts/paperclip-issue-update.sh --issue-id "$PAPERCLIP_TASK_ID" --status in_progress <<'MD'
Investigating formatting
- Pulled the raw comment body
- Comparing it with the run transcript
MD
scripts/paperclip-issue-update.sh --issue-id "$PAPERCLIP_TASK_ID" --status done --dry-run <<'MD'
Done
- Fixed the issue update helper
MD
EOF
}
require_command() {
if ! command -v "$1" >/dev/null 2>&1; then
printf 'Missing required command: %s\n' "$1" >&2
exit 1
fi
}
issue_id="${PAPERCLIP_TASK_ID:-}"
status=""
comment_arg=""
dry_run=0
while [[ $# -gt 0 ]]; do
case "$1" in
--issue-id)
issue_id="${2:-}"
shift 2
;;
--status)
status="${2:-}"
shift 2
;;
--comment)
comment_arg="${2:-}"
shift 2
;;
--dry-run)
dry_run=1
shift
;;
--help|-h)
usage
exit 0
;;
*)
printf 'Unknown argument: %s\n' "$1" >&2
usage >&2
exit 1
;;
esac
done
if [[ -z "$issue_id" ]]; then
printf 'Missing issue id. Pass --issue-id or set PAPERCLIP_TASK_ID.\n' >&2
exit 1
fi
comment=""
if [[ -n "$comment_arg" ]]; then
comment="$comment_arg"
elif [[ ! -t 0 ]]; then
comment="$(cat)"
fi
require_command jq
payload="$(
jq -nc \
--arg status "$status" \
--arg comment "$comment" \
'
(if $status == "" then {} else {status: $status} end) +
(if $comment == "" then {} else {comment: $comment} end)
'
)"
if [[ "$dry_run" == "1" ]]; then
printf '%s\n' "$payload"
exit 0
fi
if [[ -z "${PAPERCLIP_API_URL:-}" || -z "${PAPERCLIP_API_KEY:-}" || -z "${PAPERCLIP_RUN_ID:-}" ]]; then
printf 'Missing PAPERCLIP_API_URL, PAPERCLIP_API_KEY, or PAPERCLIP_RUN_ID.\n' >&2
exit 1
fi
# A successful PATCH always returns the updated issue JSON. An empty body or a
# connection-level failure means the write did NOT land, even when a pipeline
# exit code says otherwise, so verify the response instead of inferring success.
# Two attempts total: the shared heartbeat policy stops a control-plane write
# after two consecutive failures, so the helper must not send a third.
max_attempts=2
attempt=1
while :; do
http_code=""
body=""
set +e
response="$(
curl -sS -m 30 -X PATCH \
"$PAPERCLIP_API_URL/api/issues/$issue_id" \
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
-H "X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID" \
-H 'Content-Type: application/json' \
--data-binary "$payload" \
-w '\n%{http_code}'
)"
curl_exit=$?
set -e
if [[ "$curl_exit" -eq 0 ]]; then
http_code="${response##*$'\n'}"
body="${response%$'\n'*}"
fi
if [[ "$curl_exit" -eq 0 && "$http_code" == 2* ]]; then
if [[ -z "$body" ]]; then
printf 'Issue update FAILED: HTTP %s with an empty response body. A real update echoes the issue JSON; treat this write as not saved.\n' "$http_code" >&2
exit 1
fi
if [[ -n "$status" ]]; then
returned_status="$(jq -r '.status // empty' <<<"$body" 2>/dev/null || true)"
if [[ "$returned_status" != "$status" ]]; then
printf 'Issue update FAILED: server echoed status %s instead of requested %s.\n' "${returned_status:-<none>}" "$status" >&2
printf '%s\n' "$body" >&2
exit 1
fi
fi
printf '%s\n' "$body"
exit 0
fi
# 4xx (other than 429) is a definitive rejection; retrying cannot change it.
if [[ "$curl_exit" -eq 0 && "$http_code" == 4* && "$http_code" != "429" ]]; then
printf 'Issue update rejected (HTTP %s).\n' "$http_code" >&2
[[ -n "$body" ]] && printf '%s\n' "$body" >&2
exit 1
fi
if (( attempt >= max_attempts )); then
printf 'Issue update FAILED after %d attempts (curl exit %s, HTTP %s). The status/comment was NOT saved — report this write as failed, do not assume it landed.\n' "$max_attempts" "$curl_exit" "${http_code:-000}" >&2
[[ -n "$body" ]] && printf '%s\n' "$body" >&2
exit 1
fi
printf 'Issue update attempt %d/%d failed (curl exit %s, HTTP %s); retrying...\n' "$attempt" "$max_attempts" "$curl_exit" "${http_code:-000}" >&2
sleep $((attempt * 2))
attempt=$((attempt + 1))
done
paperclip_repo_scripts="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
exec bash "$paperclip_repo_scripts/../skills/paperclip/scripts/paperclip-issue-update.sh" "$@"
@@ -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.
+3 -3
View File
@@ -210,7 +210,7 @@ the routine server-verified external-chat handoff described above.
**Bounded write retry.** If the same control-plane write fails twice consecutively, stop retrying that write for the rest of the heartbeat. Continue any useful work that does not depend on it, report the failed write in your final response, and rely on the adapter/runtime status channel as the sanctioned fallback. Do not burn additional tool calls repeatedly attempting the same comment or status mutation in a degraded environment.
**Verify writes — never infer them.** A successful `PATCH /api/issues/{id}` always returns the updated issue JSON. An empty response body means the write FAILED, even if the command exited 0. Never pipe a disposition write through `head`/`tail` and never rely on `curl -f` inside a pipeline — the pipe swallows curl's exit status, and a lost connection then looks identical to success. Use `scripts/paperclip-issue-update.sh` (it checks the HTTP status, retries connection-level failures, and confirms the echoed `status`); if you must hand-roll curl, capture `-w '%{http_code}'` and check the response echoes your update. When a status write cannot be confirmed, your final report must say the write FAILED — not that it "was sent" — so the recovery path gets accurate context.
**Verify writes — never infer them.** A successful `PATCH /api/issues/{id}` always returns the updated issue JSON. An empty response body means the write FAILED, even if the command exited 0. Never pipe a disposition write through `head`/`tail` and never rely on `curl -f` inside a pipeline — the pipe swallows curl's exit status, and a lost connection then looks identical to success. Use the bundled `scripts/paperclip-issue-update.sh`, resolved relative to this installed `SKILL.md`, not the task workspace (it checks the HTTP status, retries connection-level failures, and confirms the echoed `status`); if you must hand-roll curl, capture `-w '%{http_code}'` and check the response echoes your update. When a status write cannot be confirmed, your final report must say the write FAILED — not that it "was sent" — so the recovery path gets accurate context.
Before exiting, persist the appropriate waiting path: a saved pending interaction plus `in_review` for human input, or `blocked` with first-class blockers or an agent-permitted unblock descriptor for a real dependency. A comment naming someone does not create that path.
@@ -230,10 +230,10 @@ Headers: X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID
{ "status": "done", "comment": "What was done and why." }
```
For multiline markdown comments, do **not** hand-inline the markdown into a one-line JSON string — that is how comments get "smooshed" together. Use the helper below (or an equivalent `jq --arg` pattern reading from a heredoc/file) so literal newlines survive JSON encoding:
For multiline comments, use a heredoc/file with the helper (or `jq --arg`) to preserve newlines. Set `paperclip_skill_dir` to the absolute directory containing this installed `SKILL.md`, using the skill path/base directory supplied by your harness. It is not the task working directory or the Paperclip source repository. If that path or helper is unavailable, use the verified PATCH request above; do not search the filesystem for it.
```bash
scripts/paperclip-issue-update.sh --issue-id "$PAPERCLIP_TASK_ID" --status done <<'MD'
bash "$paperclip_skill_dir/scripts/paperclip-issue-update.sh" --issue-id "$PAPERCLIP_TASK_ID" --status done <<'MD'
Done
- Fixed the newline-preserving issue update path
+166
View File
@@ -0,0 +1,166 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage:
scripts/paperclip-issue-update.sh [--issue-id ID] [--status STATUS] [--comment TEXT] [--dry-run]
Reads a multiline markdown comment from stdin when stdin is piped. This preserves
newlines when building the JSON payload for PATCH /api/issues/{issueId}.
Examples:
scripts/paperclip-issue-update.sh --issue-id "$PAPERCLIP_TASK_ID" --status in_progress <<'MD'
Investigating formatting
- Pulled the raw comment body
- Comparing it with the run transcript
MD
scripts/paperclip-issue-update.sh --issue-id "$PAPERCLIP_TASK_ID" --status done --dry-run <<'MD'
Done
- Fixed the issue update helper
MD
EOF
}
require_command() {
if ! command -v "$1" >/dev/null 2>&1; then
printf 'Missing required command: %s\n' "$1" >&2
exit 1
fi
}
issue_id="${PAPERCLIP_TASK_ID:-}"
status=""
comment_arg=""
dry_run=0
while [[ $# -gt 0 ]]; do
case "$1" in
--issue-id)
issue_id="${2:-}"
shift 2
;;
--status)
status="${2:-}"
shift 2
;;
--comment)
comment_arg="${2:-}"
shift 2
;;
--dry-run)
dry_run=1
shift
;;
--help|-h)
usage
exit 0
;;
*)
printf 'Unknown argument: %s\n' "$1" >&2
usage >&2
exit 1
;;
esac
done
if [[ -z "$issue_id" ]]; then
printf 'Missing issue id. Pass --issue-id or set PAPERCLIP_TASK_ID.\n' >&2
exit 1
fi
comment=""
if [[ -n "$comment_arg" ]]; then
comment="$comment_arg"
elif [[ ! -t 0 ]]; then
comment="$(cat)"
fi
require_command jq
payload="$(
jq -nc \
--arg status "$status" \
--arg comment "$comment" \
'
(if $status == "" then {} else {status: $status} end) +
(if $comment == "" then {} else {comment: $comment} end)
'
)"
if [[ "$dry_run" == "1" ]]; then
printf '%s\n' "$payload"
exit 0
fi
if [[ -z "${PAPERCLIP_API_URL:-}" || -z "${PAPERCLIP_API_KEY:-}" || -z "${PAPERCLIP_RUN_ID:-}" ]]; then
printf 'Missing PAPERCLIP_API_URL, PAPERCLIP_API_KEY, or PAPERCLIP_RUN_ID.\n' >&2
exit 1
fi
# A successful PATCH always returns the updated issue JSON. An empty body or a
# connection-level failure means the write did NOT land, even when a pipeline
# exit code says otherwise, so verify the response instead of inferring success.
# Two attempts total: the shared heartbeat policy stops a control-plane write
# after two consecutive failures, so the helper must not send a third.
max_attempts=2
attempt=1
while :; do
http_code=""
body=""
set +e
response="$(
curl -sS -m 30 -X PATCH \
"$PAPERCLIP_API_URL/api/issues/$issue_id" \
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
-H "X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID" \
-H 'Content-Type: application/json' \
--data-binary "$payload" \
-w '\n%{http_code}'
)"
curl_exit=$?
set -e
if [[ "$curl_exit" -eq 0 ]]; then
http_code="${response##*$'\n'}"
body="${response%$'\n'*}"
fi
if [[ "$curl_exit" -eq 0 && "$http_code" == 2* ]]; then
if [[ -z "$body" ]]; then
printf 'Issue update FAILED: HTTP %s with an empty response body. A real update echoes the issue JSON; treat this write as not saved.\n' "$http_code" >&2
exit 1
fi
if [[ -n "$status" ]]; then
returned_status="$(jq -r '.status // empty' <<<"$body" 2>/dev/null || true)"
if [[ "$returned_status" != "$status" ]]; then
printf 'Issue update FAILED: server echoed status %s instead of requested %s.\n' "${returned_status:-<none>}" "$status" >&2
printf '%s\n' "$body" >&2
exit 1
fi
fi
printf '%s\n' "$body"
exit 0
fi
# 4xx (other than 429) is a definitive rejection; retrying cannot change it.
if [[ "$curl_exit" -eq 0 && "$http_code" == 4* && "$http_code" != "429" ]]; then
printf 'Issue update rejected (HTTP %s).\n' "$http_code" >&2
[[ -n "$body" ]] && printf '%s\n' "$body" >&2
exit 1
fi
if (( attempt >= max_attempts )); then
printf 'Issue update FAILED after %d attempts (curl exit %s, HTTP %s). The status/comment was NOT saved — report this write as failed, do not assume it landed.\n' "$max_attempts" "$curl_exit" "${http_code:-000}" >&2
[[ -n "$body" ]] && printf '%s\n' "$body" >&2
exit 1
fi
printf 'Issue update attempt %d/%d failed (curl exit %s, HTTP %s); retrying...\n' "$attempt" "$max_attempts" "$curl_exit" "${http_code:-000}" >&2
sleep $((attempt * 2))
attempt=$((attempt + 1))
done
@@ -0,0 +1,4 @@
export function sourceFingerprint(): {
fingerprint: string;
sourceErrors: string[];
};
+6 -2
View File
@@ -33,10 +33,12 @@ export const stockHarnessGates = [
"packages/adapters/opencode-local/src/server/execute.test.ts",
"packages/adapters/cursor-cloud/src/server/execute.test.ts",
"server/src/__tests__/codex-local-execute.test.ts",
"server/src/__tests__/paperclip-issue-update-helper.test.ts",
], required: ["keeps task and chat defaults to identity and connection guidance",
"does not restore generic procedures on resume or with the legacy opt-in",
"integrates the env-free store", "advertises folded routing metadata", "bounds routing descriptions",
"loads an old env-bearing file with rotated credentials", "drops a skill that fails to materialize"] },
"loads an old env-bearing file with rotated credentials", "drops a skill that fails to materialize", "exits 0 and prints the issue JSON when the server echoes the requested status",
"fails an empty 2xx body instead of treating it as success"] },
// Hermes is not in the root Vitest project list. Run its package config so
// the requested file cannot silently disappear from discovery.
{ id: "SH-3-hermes", name: "Hermes shared-prompt delivery", cwd: "packages/adapters/hermes",
@@ -113,11 +115,13 @@ export function sourceFingerprint() {
const sourceErrors = [];
sources.add("skills/paperclip/SKILL.md");
sources.add("skills/paperclip/references/issue-documents.md");
sources.add("scripts/paperclip-issue-update.sh");
sources.add("skills/paperclip/scripts/paperclip-issue-update.sh");
for (const source of [...sources].sort()) {
hash.update(source);
try { hash.update("present\0").update(readFileSync(join(root, source))); }
catch (error) {
if (source === "skills/paperclip/references/issue-documents.md" && error.code === "ENOENT") hash.update("absent\0");
if ((source === "skills/paperclip/references/issue-documents.md" || source === "skills/paperclip/scripts/paperclip-issue-update.sh") && error.code === "ENOENT") hash.update("absent\0");
else sourceErrors.push(source);
}
}
+27 -1
View File
@@ -1,5 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { sourceFingerprint } from "./stock-harness-checks.mjs";
import { stockHarnessSourceDigest, stockHarnessSkillSources } from "./stock-harness.js";
@@ -17,13 +18,38 @@ describe("stock harness instruction revision", () => {
expect(changed.fingerprint).not.toBe(original.fingerprint);
});
it.each(["tests/runner-e2e/checkout-activity.ts", "server/src/onboarding-assets/default/AGENTS.md", "packages/adapter-utils/src/server-utils.ts", "packages/shared/src/connection-intent-guidance.ts", "skills/paperclip/SKILL.md", "skills/paperclip/references/issue-documents.md", "packages/paperclip-runner/generated/capability/capabilities.yaml", "packages/paperclip-runner/spec/capability/capabilities.yaml", "tests/runner-e2e/stock-harness-manifest.ts", "packages/adapter-utils/src/acpx-engine/execute.ts", "packages/adapter-utils/src/acpx-engine/ephemeral-session-environment.ts", "tests/runner-e2e/stock-harness-instruction-variant.mjs", "tests/runner-e2e/automatic-retry.ts", "tests/runner-e2e/catalog.ts"])(
it.each(["scripts/paperclip-issue-update.sh", "skills/paperclip/scripts/paperclip-issue-update.sh", "tests/runner-e2e/checkout-activity.ts", "server/src/onboarding-assets/default/AGENTS.md", "packages/adapter-utils/src/server-utils.ts", "packages/shared/src/connection-intent-guidance.ts", "skills/paperclip/SKILL.md", "skills/paperclip/references/issue-documents.md", "packages/paperclip-runner/generated/capability/capabilities.yaml", "packages/paperclip-runner/spec/capability/capabilities.yaml", "tests/runner-e2e/stock-harness-manifest.ts", "packages/adapter-utils/src/acpx-engine/execute.ts", "packages/adapter-utils/src/acpx-engine/ephemeral-session-environment.ts", "tests/runner-e2e/stock-harness-instruction-variant.mjs", "tests/runner-e2e/automatic-retry.ts", "tests/runner-e2e/catalog.ts"])(
"changes when the evaluated %s changes", source => {
vi.mocked(readFileSync).mockImplementation(() => Buffer.from("unchanged"));
const original = stockHarnessSourceDigest();
vi.mocked(readFileSync).mockImplementation(file => Buffer.from(String(file).endsWith(source) ? "changed instructions" : "unchanged"));
expect(stockHarnessSourceDigest()).not.toBe(original);
});
it.each(["scripts/paperclip-issue-update.sh", "skills/paperclip/scripts/paperclip-issue-update.sh"])(
"invalidates the prerequisite fingerprint when %s changes", source => {
vi.mocked(readFileSync).mockImplementation(() => Buffer.from("unchanged"));
const original = sourceFingerprint();
vi.mocked(readFileSync).mockImplementation(file => Buffer.from(
String(file) === resolve(import.meta.dirname, "../..", source) ? "changed helper" : "unchanged"));
expect(sourceFingerprint().fingerprint).not.toBe(original.fingerprint);
});
it("records a missing historical bundled helper without accepting unreadable helper files", () => {
vi.mocked(readFileSync).mockImplementation(() => Buffer.from("unchanged"));
const original = sourceFingerprint();
const digest = stockHarnessSourceDigest();
vi.mocked(readFileSync).mockImplementation(file => {
if (String(file).endsWith("skills/paperclip/scripts/paperclip-issue-update.sh"))
throw Object.assign(new Error("absent"), { code: "ENOENT" });
return Buffer.from("unchanged");
});
expect(sourceFingerprint().sourceErrors).toEqual([]);
expect(sourceFingerprint().fingerprint).not.toBe(original.fingerprint);
expect(stockHarnessSourceDigest()).not.toBe(digest);
expect(stockHarnessSkillSources()[3]).toMatchObject({ present: false, sha256: null });
vi.mocked(readFileSync).mockImplementation(() => { throw Object.assign(new Error("unreadable"), { code: "EACCES" }); });
expect(stockHarnessSourceDigest).toThrow("unreadable");
expect(sourceFingerprint().sourceErrors).toContain("skills/paperclip/scripts/paperclip-issue-update.sh");
});
it("records an absent historical recipe without introducing its content or hiding other read errors", () => {
vi.mocked(readFileSync).mockImplementation(() => Buffer.from("unchanged"));
const present = stockHarnessSourceDigest();
+4 -2
View File
@@ -7,14 +7,16 @@ import { readStockInstructionVariant } from "./stock-harness-instruction-variant
// constants. Otherwise a larger shipped manual could silently update the oracle.
export const STOCK_HIRE_IDENTITY = "You are an agent in a Paperclip company.\n";
export const STOCK_TEMPLATE_IDENTITY = "You are agent";
const SKILL_SOURCES = ["../../skills/paperclip/SKILL.md", "../../skills/paperclip/references/issue-documents.md"];
const SKILL_SOURCES = ["../../skills/paperclip/SKILL.md", "../../skills/paperclip/references/issue-documents.md",
"../../scripts/paperclip-issue-update.sh", "../../skills/paperclip/scripts/paperclip-issue-update.sh"];
export function stockHarnessSkillSources() {
return SKILL_SOURCES.map(source => {
try {
return { path: source.replace(/^\.\.\/\.\.\//, ""), present: true,
sha256: createHash("sha256").update(readFileSync(new URL(source, import.meta.url))).digest("hex") };
} catch (error) {
if (source.endsWith("/references/issue-documents.md") && (error as NodeJS.ErrnoException).code === "ENOENT")
if ((source.endsWith("/references/issue-documents.md") || source === "../../skills/paperclip/scripts/paperclip-issue-update.sh")
&& (error as NodeJS.ErrnoException).code === "ENOENT")
return { path: source.replace(/^\.\.\/\.\.\//, ""), present: false, sha256: null };
throw error;
}