diff --git a/.changes/source-control/fix-pr-unblock-eval-case-scope-ed5fddbc.md b/.changes/source-control/fix-pr-unblock-eval-case-scope-ed5fddbc.md new file mode 100644 index 00000000000..a696b9a58cf --- /dev/null +++ b/.changes/source-control/fix-pr-unblock-eval-case-scope-ed5fddbc.md @@ -0,0 +1,7 @@ +--- +bump: patch +--- + +### Fixed + +- The `comments --ci` review-thread tool's `check-paths` and `record` now also allow a file in the `plugins/

/` directory a kept thread's path sits in, except one under `hooks/`, `.claude-plugin/` or a `.claude/` directory, a `CLAUDE.md` or `AGENTS.md`, or a `.sh`, `.py`, `.mjs`, `.js` or `.ts` file, and a test file (`test_*.py`, `*_test.py`, `*.test.*`, `*.spec.*`) in the same directory as a kept thread's path. `.github/` stays refused. diff --git a/.github/scripts/pr-unblock/address-feedback.mjs b/.github/scripts/pr-unblock/address-feedback.mjs index b3aed64fa29..b8f3fc7da3e 100644 --- a/.github/scripts/pr-unblock/address-feedback.mjs +++ b/.github/scripts/pr-unblock/address-feedback.mjs @@ -22,6 +22,8 @@ import { appendFileSync, readFileSync, writeFileSync } from "node:fs"; import { pathToFileURL } from "node:url"; +import { isProtectedPath } from "./thread-scope.mjs"; + // The round cap is the config's `loop.review-rounds`, passed in as // REVIEW_ROUNDS; the resolver's default is the fallback. export const DEFAULT_MAX_ROUNDS = 2; @@ -39,10 +41,6 @@ const FILES_CAP = 3000; const firstLine = (body) => String(body ?? "").split("\n")[0].replace(/\r$/, ""); -export function isProtectedPath(path) { - return /^\.github(\/|$)/i.test(String(path)); -} - // Round markers the lanes App posted; any other author's copy is ignored. export const isRoundMarker = (comment, appId) => comment.user?.id === appId && firstLine(comment.body) === ROUND_MARKER; diff --git a/.github/scripts/pr-unblock/promote-scope.test.mjs b/.github/scripts/pr-unblock/promote-scope.test.mjs new file mode 100644 index 00000000000..ab6a1ad957d --- /dev/null +++ b/.github/scripts/pr-unblock/promote-scope.test.mjs @@ -0,0 +1,112 @@ +// Runs the review-threads path check of pr-run-activity-write.yml's promote +// step (the `script:` of the step with `id: promote`) against fake GitHub +// clients. A changed path in scope reaches the next gate, the hold-label read, +// which fails here because the case passes no hold labels. +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { after, test } from "node:test"; + +const WORKFLOW = new URL("../../workflows/pr-run-activity-write.yml", import.meta.url); +const SCOPE = new URL("./thread-scope.mjs", import.meta.url); +const HEAD = "a".repeat(40); +const LANE = "b".repeat(40); + +function promoteScript() { + const lines = readFileSync(WORKFLOW, "utf8").split("\n"); + const id = lines.findIndex((line) => /^\s+id: promote$/.test(line)); + assert.notEqual(id, -1, "the workflow has the promote step"); + const start = lines.findIndex((line, i) => i > id && /^\s+script: \|$/.test(line)); + const keyIndent = lines[start].search(/\S/); + const body = []; + for (const line of lines.slice(start + 1)) { + if (line.trim() !== "" && line.search(/\S/) <= keyIndent) break; + body.push(line); + } + const indent = Math.min(...body.filter((l) => l.trim()).map((l) => l.search(/\S/))); + return body.map((l) => l.slice(indent)).join("\n"); +} + +const dir = mkdtempSync(join(tmpdir(), "promote-scope-")); +after(() => rmSync(dir, { recursive: true, force: true })); + +async function promote(files, { scope = readFileSync(SCOPE), sha256 } = {}) { + const threadFile = join(dir, "threads.json"); + writeFileSync(threadFile, JSON.stringify({ threads: [{ path: "plugins/p/evals/c/case.yaml" }, { path: "src/round.py" }] })); + const scopeScript = join(dir, "thread-scope.mjs"); + writeFileSync(scopeScript, scope); + const env = { + HEAD_SHA: HEAD, + HEAD_REF: "feature", + LANE_BRANCH: "lane-commits/x", + MERGE_BASE: "false", + THREAD_FILE: threadFile, + SCOPE_SCRIPT: scopeScript, + SCOPE_SHA256: sha256 ?? createHash("sha256").update(readFileSync(SCOPE)).digest("hex"), + DEPENDENCY_REVIEW: "false", + HOLD_LABELS: "", + }; + const saved = { ...process.env }; + Object.assign(process.env, env); + const failed = []; + const core = { setOutput() {}, info() {}, warning() {}, setFailed: (message) => failed.push(message) }; + const github = { + request: async () => ({ data: { object: { sha: LANE } } }), + rest: { + repos: { compareCommitsWithBasehead: async () => ({ data: { files } }) }, + git: { deleteRef: async () => {} }, + }, + }; + const context = { repo: { owner: "o", repo: "r" } }; + try { + const AsyncFunction = (async () => {}).constructor; + await new AsyncFunction("github", "context", "core", "require", promoteScript())( + github, + context, + core, + createRequire(import.meta.url), + ); + } finally { + process.env = saved; + } + return failed; +} + +const HOLD = /^No hold labels were read/; +const OUT = /^The lane branch changes a path outside the kept threads' scope or under \.github\//; + +test("in-scope changes pass the path check: the thread's path, its plugin's files, a sibling test", async () => { + const failed = await promote([ + { filename: "plugins/p/evals/c/case.yaml" }, + { filename: "plugins/p/evals/c/samples/deep/new.json" }, + { filename: "plugins/p/evals/other/samples/new.json" }, + { filename: "plugins/p/README.md" }, + { filename: "src/test_round.py" }, + ]); + assert.equal(failed.length, 1); + assert.match(failed[0], HOLD); +}); + +for (const file of [ + { filename: "plugins/q/evals/c/samples/new.json" }, + { filename: "src/lib/test_round.py" }, + { filename: ".github/workflows/x.yml" }, + { filename: "plugins/p/evals/c/x.json", previous_filename: ".github/x.yml" }, +]) { + test(`an out-of-scope change stops the move: ${file.previous_filename ?? file.filename}`, async () => { + const failed = await promote([{ filename: "plugins/p/evals/c/case.yaml" }, file]); + assert.equal(failed.length, 1); + assert.match(failed[0], OUT); + }); +} + +test("a rewritten scope copy, or no recorded hash, stops the move", async () => { + const permissive = "export const inThreadScope = () => true;\n"; + for (const options of [{ scope: permissive }, { sha256: "" }]) { + const failed = await promote([{ filename: ".github/workflows/x.yml" }], options); + assert.deepEqual(failed, ["thread-scope.mjs does not match the base's copy; the PR branch is not moved"]); + } +}); diff --git a/.github/scripts/pr-unblock/publish-feedback.mjs b/.github/scripts/pr-unblock/publish-feedback.mjs index a2eed539121..9825930de76 100755 --- a/.github/scripts/pr-unblock/publish-feedback.mjs +++ b/.github/scripts/pr-unblock/publish-feedback.mjs @@ -10,7 +10,8 @@ // fields, within the limits below. Any failure posts no reply. // 2. For VALID replies, confirms every fix commit reached the PR branch // after the model job's gated head and that the branch changed no path -// but the kept threads' and none under .github/. +// outside the kept threads' scope (thread-scope.mjs) and none under +// .github/. // 3. Re-reads each thread and skips one that is resolved, moved, or has a // comment the model job did not see; posts the templated reply on its // root comment and resolves it only when it is VALID. The PR head is @@ -46,7 +47,6 @@ import { countRounds, createGitHubApi, currentThreads, - isProtectedPath, isRoundMarker, DEFAULT_MAX_ROUNDS, maxRoundsFrom, @@ -57,6 +57,7 @@ import { SHA, trustedHumansFrom, } from "./address-feedback.mjs"; +import { inThreadScope } from "./thread-scope.mjs"; export const LIMITS = { bytes: 262144, note: 2000, evidence: 10, files: 300 }; const THREAD_ID = /^PRRT_[A-Za-z0-9_-]{1,100}$/; @@ -168,9 +169,10 @@ export function renderReply({ disposition, evidence, fixSha, note }) { } // Whenever the PR branch moved since the model job's gated head, or a reply is -// VALID, that range may change only the kept threads' paths (run.json, whose -// SHA-256 the model job's runner recorded), and nothing under .github/. Every VALID -// fix must be a commit in that range that changes its own thread's path. +// VALID, that range may change only what inThreadScope allows around the kept +// threads' paths (run.json, whose SHA-256 the model job's runner recorded), and +// nothing under .github/. Every VALID fix must be a commit in that range that +// changes its own thread's path. async function fixesLanded({ api, run, headSha, replies }) { const valid = replies.filter((reply) => reply.disposition === "VALID"); if (valid.length === 0 && run.head_sha === headSha) { @@ -187,7 +189,7 @@ async function fixesLanded({ api, run, headSha, replies }) { files.length < LIMITS.files && files.every((file) => [file.filename, file.previous_filename].every( - (path) => path === undefined || (paths.has(path) && !isProtectedPath(path)), + (path) => path === undefined || inThreadScope(path, paths), ), ); if (!inRange) { diff --git a/.github/scripts/pr-unblock/publish-feedback.test.mjs b/.github/scripts/pr-unblock/publish-feedback.test.mjs index d799f61770a..c8a0255e125 100644 --- a/.github/scripts/pr-unblock/publish-feedback.test.mjs +++ b/.github/scripts/pr-unblock/publish-feedback.test.mjs @@ -422,6 +422,23 @@ for (const [label, compare] of [ }); } +test("a VALID fix that also adds a file in its thread's plugin lands; another plugin's file does not", async () => { + const caseRun = JSON.stringify({ ...RUN, threads: [{ ...RUN.threads[0], path: "plugins/p/evals/c/case.yaml" }] }); + for (const [sample, landed] of [ + ["plugins/p/evals/c/samples/new.json", true], + ["plugins/p/evals/other/samples/new.json", true], + ["plugins/q/evals/c/samples/new.json", false], + ]) { + const api = fakeApi({ + compare: { status: "ahead", commits: [{ sha: FIX }], files: [{ filename: "plugins/p/evals/c/case.yaml" }, { filename: sample }] }, + commits: { [FIX]: { files: [{ filename: "plugins/p/evals/c/case.yaml" }, { filename: sample }] } }, + }); + const result = await run(api, lines(reply({ disposition: "VALID", fix_sha: FIX })), caseRun); + assert.equal(result.escalationReason, landed ? "" : "feedback-not-landed", sample); + assert.equal(api.posted().length, landed ? 1 : 0, sample); + } +}); + test("feedback-not-landed, nothing posted: a VALID fix commit that does not change its own thread's path", async () => { const api = fakeApi({ compare: { status: "ahead", commits: [{ sha: FIX }], files: [{ filename: "docs/b.md" }] }, diff --git a/.github/scripts/pr-unblock/thread-scope.mjs b/.github/scripts/pr-unblock/thread-scope.mjs new file mode 100644 index 00000000000..2aab10f4da2 --- /dev/null +++ b/.github/scripts/pr-unblock/thread-scope.mjs @@ -0,0 +1,41 @@ +// Which paths an address-feedback fix may change, shared by the promote step +// of .github/workflows/pr-run-activity-write.yml (which imports a SHA-256 +// pinned copy, so it imports nothing itself) and publish-feedback.mjs. + +export function isProtectedPath(path) { + return /^\.github(\/|$)/i.test(String(path)); +} + +const PLUGIN_DIR = /^plugins\/[^/]+\//; +// In the plugin directory but run or loaded as instructions by the harness or +// a shell, so out of the plugin-wide part: hooks, manifests, script or code +// files, and agent rules (CLAUDE.md, AGENTS.md, anything under .claude/). +const PLUGIN_CODE = + /^plugins\/[^/]+\/(hooks|\.claude-plugin)\/|\.(sh|py|mjs|js|ts)$|(^|\/)(claude|agents)\.md$|(^|\/)\.claude\//i; +const TEST_FILE = /^(test_[^/]*\.py|[^/]*_test\.py|[^/]*\.(test|spec)\.[^/]+)$/; +const dirOf = (path) => path.slice(0, path.lastIndexOf("/") + 1); + +// An empty, . or .. segment, so a leading or doubled / too. +const BAD_SEGMENT = /(^|\/)\.{0,2}(\/|$)/; + +// A kept thread's own path, a file in the plugins/

/ directory a kept +// thread's path sits in that is not under hooks/, .claude-plugin/ or a +// .claude/ directory, not a CLAUDE.md or AGENTS.md, and not a .sh, .py, .mjs, +// .js or .ts file, or a test file in the same directory as a +// kept thread's path; never anything under .github/, and never a path that is +// not in normal form. +export function inThreadScope(path, threadPaths) { + if (isProtectedPath(path) || BAD_SEGMENT.test(path)) { + return false; + } + if (threadPaths.has(path)) { + return true; + } + const kept = [...threadPaths]; + const plugin = PLUGIN_DIR.exec(path)?.[0]; + if (plugin !== undefined && !PLUGIN_CODE.test(path) && kept.some((p) => p.startsWith(plugin))) { + return true; + } + const dir = dirOf(path); + return TEST_FILE.test(path.slice(dir.length)) && kept.some((p) => dirOf(p) === dir); +} diff --git a/.github/scripts/pr-unblock/thread-scope.test.mjs b/.github/scripts/pr-unblock/thread-scope.test.mjs new file mode 100644 index 00000000000..87582f7cd3a --- /dev/null +++ b/.github/scripts/pr-unblock/thread-scope.test.mjs @@ -0,0 +1,131 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { inThreadScope, isProtectedPath } from "./thread-scope.mjs"; + +const kept = new Set(["plugins/p/evals/c/graders/x.md", "src/a.js"]); + +test("a kept thread's own path is in scope", () => { + assert.equal(inThreadScope("src/a.js", kept), true); + assert.equal(inThreadScope("plugins/p/evals/c/graders/x.md", kept), true); +}); + +// plugins/planning/README.md is the file #8178's run 39117146913 refused. +test("any file in the same plugins/

/ directory as a kept thread's path is in scope, at any depth", () => { + for (const path of [ + "plugins/p/evals/c/samples/y.json", + "plugins/p/evals/other/samples/deep/z.json", + "plugins/p/evals/evals.json", + "plugins/p/skills/s/SKILL.md", + "plugins/p/README.md", + ]) { + assert.equal(inThreadScope(path, kept), true, path); + } + assert.equal(inThreadScope("plugins/planning/README.md", new Set(["plugins/planning/surface/round.py"])), true); +}); + +// Each refused path and the kept thread paths of the four recorded trips. +test("the four recorded trips are in scope", () => { + const trips = [ + // #8043, run 39100052790: an eval sample beside the thread's case. + ["plugins/session-flow/evals/rr-skill-fit-names-owner/samples/names-skill-and-plugin.json", ["plugins/session-flow/evals/rr-skill-fit-names-owner/case.yaml"]], + // #8103, run 39107762307: the thread is on case.yaml:18. + ["plugins/discipline/evals/ror-second-error-way/samples/second-way-named.json", ["plugins/discipline/evals/ror-second-error-way/case.yaml"]], + // #8142, run 39113747960: a .py file, in scope only as a sibling test. + ["plugins/planning/surface/test_round.py", ["plugins/planning/surface/round.py"]], + // #8178, run 39117146913: the PR's kept thread paths. + [ + "plugins/planning/README.md", + ["plugins/session-flow/skills/retro/SKILL.md", "plugins/planning/skills/draft-goal-condition/SKILL.md", "plugins/planning/.claude-plugin/plugin.json"], + ], + ]; + for (const [path, threads] of trips) { + assert.equal(inThreadScope(path, new Set(threads)), true, path); + } +}); + +test("hooks, manifests, agent rules and non-test code in the same plugin are refused", () => { + const named = new Set(["plugins/p/skills/s/SKILL.md"]); + for (const path of [ + "plugins/p/hooks/hooks.json", + "plugins/p/hooks/guard.md", + "plugins/p/.claude-plugin/plugin.json", + "plugins/p/scripts/run.sh", + "plugins/p/skills/s/scripts/helper.py", + "plugins/p/lib/x.mjs", + "plugins/p/lib/x.js", + "plugins/p/lib/x.ts", + "plugins/p/lib/X.SH", + "plugins/p/AGENTS.md", + "plugins/p/skills/s/CLAUDE.md", + "plugins/p/.claude/rules/r.md", + "plugins/p/.claude/settings.json", + ]) { + assert.equal(inThreadScope(path, named), false, path); + } +}); + +test("a thread's own path stays in scope whatever its type, and a sibling test of code stays in scope", () => { + for (const path of ["plugins/p/hooks/guard.sh", "plugins/p/.claude-plugin/plugin.json", "plugins/p/AGENTS.md"]) { + assert.equal(inThreadScope(path, new Set([path])), true, path); + } + assert.equal(inThreadScope("plugins/p/hooks/guard.test.mjs", new Set(["plugins/p/hooks/guard.mjs"])), true); + assert.equal(inThreadScope("plugins/p/scripts/test_run.py", new Set(["plugins/p/README.md"])), false); +}); + +test("another plugin's file and an unnamed file outside plugins/ stay out of scope", () => { + assert.equal(inThreadScope("plugins/q/evals/c/samples/y.json", kept), false); + assert.equal(inThreadScope("plugins/pp/README.md", kept), false); + assert.equal(inThreadScope("src/b.js", kept), false); + assert.equal(inThreadScope("plugins/README.md", kept), false); +}); + +test("with no kept thread under the plugin, its files are refused", () => { + assert.equal(inThreadScope("plugins/p/evals/c/samples/y.json", new Set(["src/a.js"])), false); +}); + +// Names from the coordinator's addition; plugins/planning/surface/test_round.py +// is the file #8142's run 39113747960 refused. +test("a test file in the same directory as a kept thread's path is in scope", () => { + const surface = new Set(["plugins/planning/surface/round.py", "src/a.js", "README.md"]); + for (const path of [ + "plugins/planning/surface/test_round.py", + "plugins/planning/surface/round_test.py", + "src/a.test.js", + "src/a.spec.ts", + "test_root.py", + ]) { + assert.equal(inThreadScope(path, surface), true, path); + } +}); + +test("outside plugins/, a test file in another directory, or a same-directory file that is no test, stays out of scope", () => { + const surface = new Set(["src/a.js"]); + for (const path of ["src/lib/a.test.js", "src/helper.js", "src/testing.js", "src/test_a.sh", "test_root.py"]) { + assert.equal(inThreadScope(path, surface), false, path); + } +}); + +test("a test file under .github/ is refused even beside a kept thread's path there", () => { + assert.equal(inThreadScope(".github/scripts/x.test.mjs", new Set([".github/scripts/x.mjs"])), false); +}); + +test("a path with an empty, . or .. segment, or a leading /, is refused before any other rule", () => { + for (const path of [ + "plugins/p/evals/c/../../../../.github/workflows/x.yml", + "plugins/p/evals/c/./samples/y.json", + "plugins/p/evals/c//y.json", + "/src/a.js", + "./src/a.js", + ]) { + assert.equal(inThreadScope(path, new Set([...kept, path])), false, path); + } +}); + +test(".github/ is refused even when a kept thread names a file there", () => { + const github = new Set([".github/workflows/x.yml", ".GitHub/evals/c/a.md"]); + assert.equal(inThreadScope(".github/workflows/x.yml", github), false); + assert.equal(inThreadScope(".GitHub/evals/c/b.md", github), false); + assert.equal(isProtectedPath(".github"), true); + assert.equal(isProtectedPath(".githubx/a"), false); +}); diff --git a/.github/workflows/pr-run-activity-write.yml b/.github/workflows/pr-run-activity-write.yml index 0ac7bb7ab5a..8ca64b3e402 100644 --- a/.github/workflows/pr-run-activity-write.yml +++ b/.github/workflows/pr-run-activity-write.yml @@ -507,7 +507,9 @@ jobs: # on a head a review lane's marker confirms, left by it alone or answered # by a trusted person after the lane escalated them, then # skips (none), escalates (a .github/** diff, or the round cap) or - # writes the thread file the skill's review-thread tool reads. + # writes the thread file the skill's review-thread tool reads. It also + # copies out the base's thread-scope.mjs with its SHA-256, which the + # promote step checks before it imports the copy. - name: Select the review threads id: feedback-select if: steps.config.outputs.applies == 'true' && steps.config.outputs.prepare == 'review-threads' @@ -518,11 +520,15 @@ jobs: APP_SENDER_ID: ${{ vars.AUTOMATION_LANES_APP_SENDER_ID }} LANE_BRANCH: ${{ format('lane-commits/{0}/{1}/{2}-{3}', inputs.lane, inputs.activity, github.run_id, github.run_attempt) }} FEEDBACK_DIR: ${{ runner.temp }}/lane-feedback + SCOPE_BIN: ${{ runner.temp }}/lane-feedback-bin TRUSTED_ACTORS: .base/.github/standards/trusted-actors/trusted-actors.json TRACKER_CONFIG: .base/.work-item-tracker.json RESOLVED: ${{ runner.temp }}/resolved-config.json run: | - mkdir -p "$FEEDBACK_DIR" + mkdir -p "$FEEDBACK_DIR" "$SCOPE_BIN" + cp .base/.github/scripts/pr-unblock/thread-scope.mjs "$SCOPE_BIN/thread-scope.mjs" + sum=$(sha256sum <"$SCOPE_BIN/thread-scope.mjs") + echo "scope-sha256=${sum%% *}" >>"$GITHUB_OUTPUT" REVIEW_ROUNDS=$(jq -r '.loop["review-rounds"]' "$RESOLVED") export REVIEW_ROUNDS THREAD_FILE="$FEEDBACK_DIR/threads.json" node .base/.github/scripts/pr-unblock/address-feedback.mjs select @@ -1484,11 +1490,14 @@ jobs: MERGE_BASE: ${{ steps.config.outputs.prepare == 'merge-base' }} COMMIT_OUTCOME: ${{ steps.commit.outcome }} COMMIT_SHA: ${{ steps.commit.outputs.commit }} - # review-threads mode moves the PR branch only when the lane branch - # changes nothing but the kept threads' paths, none under .github/. + # review-threads mode moves the PR branch only when every path the + # lane branch changes is in the kept threads' scope, as the base's + # thread-scope.mjs decides, and none is under .github/. THREAD_FILE: >- ${{ steps.config.outputs.prepare == 'review-threads' && format('{0}/lane-feedback/threads.json', runner.temp) || '' }} + SCOPE_SCRIPT: ${{ runner.temp }}/lane-feedback-bin/thread-scope.mjs + SCOPE_SHA256: ${{ steps.feedback-select.outputs.scope-sha256 }} # dependency-review mode moves the PR branch only when the lane # branch changes nothing under .github/ and every commit's message # carries [dependabot skip], so Dependabot may still rebase the PR. @@ -1505,6 +1514,7 @@ jobs: script: | const { HEAD_SHA, HEAD_REF, LANE_BRANCH, MERGE_BASE, COMMIT_OUTCOME, COMMIT_SHA, THREAD_FILE } = process.env; const { DEPENDENCY_REVIEW, DEPENDENCY_OUTPUT, PR_NUMBER, HOLD_LABELS, READ_TOKEN } = process.env; + const { SCOPE_SCRIPT, SCOPE_SHA256 } = process.env; core.setOutput('skip-reason', ''); const tip = async (branch) => (await github.request('GET /repos/{owner}/{repo}/git/ref/{ref}', { ...context.repo, ref: `heads/${branch}` })) @@ -1520,13 +1530,22 @@ jobs: return; } if (THREAD_FILE) { - const paths = new Set(JSON.parse(require('fs').readFileSync(THREAD_FILE, 'utf8')).threads.map((t) => t.path)); + const fs = require('fs'); + const paths = new Set(JSON.parse(fs.readFileSync(THREAD_FILE, 'utf8')).threads.map((t) => t.path)); + // Imported from the bytes just hashed, so a rewrite after the + // check cannot reach the import. + const scope = fs.readFileSync(SCOPE_SCRIPT); + if (!SCOPE_SHA256 || require('crypto').createHash('sha256').update(scope).digest('hex') !== SCOPE_SHA256) { + core.setFailed("thread-scope.mjs does not match the base's copy; the PR branch is not moved"); + return; + } + const { inThreadScope } = await import(`data:text/javascript;base64,${scope.toString('base64')}`); const { data } = await github.rest.repos.compareCommitsWithBasehead({ ...context.repo, basehead: `${HEAD_SHA}...${lane}` }); const bad = data.files.length >= 300 ? ['(300 or more files)'] : data.files .flatMap((f) => [f.filename, f.previous_filename].filter(Boolean)) - .filter((p) => !paths.has(p) || /^\.github(\/|$)/i.test(p)); + .filter((p) => !inThreadScope(p, paths)); if (bad.length > 0) { - core.setFailed(`The lane branch changes a path no kept thread names or under .github/ (${bad.slice(0, 5).join(', ')}); the PR branch is not moved`); + core.setFailed(`The lane branch changes a path outside the kept threads' scope or under .github/ (${bad.slice(0, 5).join(', ')}); the PR branch is not moved`); return; } } diff --git a/docs/conventions/pr-pipeline/CHANGELOG.md b/docs/conventions/pr-pipeline/CHANGELOG.md index 0ca3e572d3b..41e0e463886 100644 --- a/docs/conventions/pr-pipeline/CHANGELOG.md +++ b/docs/conventions/pr-pipeline/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog for the PR pipeline convention +## 2.12.3 - 2026-10-18 + +`pr-unblock` `address-feedback` widens a kept thread's scope from its path alone to also a file in +the `plugins/

/` directory that path sits in, and a test file (`test_*.py`, `*_test.py`, +`*.test.*`, `*.spec.*`) in that path's directory +([#8109](https://github.com/melodic-software/claude-code-plugins/issues/8109)). The plugin-wide +part leaves out files the harness or a shell runs or loads as instructions: anything under +`hooks/`, `.claude-plugin/` or a `.claude/` directory, a `CLAUDE.md` or `AGENTS.md`, and any +`.sh`, `.py`, `.mjs`, `.js` or `.ts` file. The review-thread tool, the promote step and the reply +job apply the same rule; the promote step imports the base's `thread-scope.mjs` from a copy taken +before the head checkout, checked against its SHA-256. `.github/` stays refused. `version` stays 2. + ## 2.12.2 - 2026-10-18 On a pull request labeled `needs-human` (the human-gated role label), `pr-unblock` diff --git a/docs/conventions/pr-pipeline/pr-run-activity.md b/docs/conventions/pr-pipeline/pr-run-activity.md index 07debb3b560..11dde0b9dfb 100644 --- a/docs/conventions/pr-pipeline/pr-run-activity.md +++ b/docs/conventions/pr-pipeline/pr-run-activity.md @@ -612,9 +612,14 @@ the base's `.github/scripts/pr-unblock/address-feedback.mjs` and `publish-feedba and Write are denied on the directory and on the tool's replies directory. The tool lists the kept threads, checks paths, and records a reply: it refuses a disposition file outside the scratch directory or off the template, and a `VALID` reply whose fix is not on the lane branch - or whose lane branch changes a file no kept thread names or any `.github/` path. It posts - nothing. The promote step then moves the PR branch only when the lane branch changes nothing - but kept-thread paths, none under `.github/`, in fewer than 300 files. + or whose lane branch changes a file outside every kept thread's scope or any `.github/` path. + A thread's scope is its path; a file in the `plugins/

/` directory its path sits in, except + one under `hooks/`, `.claude-plugin/` or a `.claude/` directory, a `CLAUDE.md` or `AGENTS.md`, + or a `.sh`, `.py`, `.mjs`, `.js` or `.ts` file; and a test file (`test_*.py`, `*_test.py`, `*.test.*`, `*.spec.*`) in its path's + directory. It posts nothing. The promote step then moves the PR branch only when every path the + lane branch changes is in a kept thread's scope, none under `.github/`, in fewer than 300 + files, judged by a copy of the base's `thread-scope.mjs` taken before the head checkout and + imported only when its SHA-256 matches. 3. After the promote step, and after a skill that failed, was cancelled or timed out, a pinned `github-script` step writes `run.json` (the gated head and the kept threads' ids, paths and comment ids, from the thread file) beside a copy of the tool's `replies.jsonl`, and the job @@ -641,7 +646,7 @@ the base's `.github/scripts/pr-unblock/address-feedback.mjs` and `publish-feedba and escalates `feedback-incomplete`; - when a reply is `VALID` or the PR branch moved, compares the model job's gated head with the PR's current head: the branch must be ahead, hold every fix commit, and change fewer than 300 - files, each a kept thread's path and none under `.github/`, and each fix commit must change + files, each in a kept thread's scope and none under `.github/`, and each fix commit must change its own thread's path. Otherwise it posts no reply and escalates `feedback-not-landed`; - re-reads every kept thread and, right before posting, the thread itself, and skips one that is resolved, on another PR, or whose comments are not exactly the ones the model job read; @@ -667,7 +672,7 @@ the base's `.github/scripts/pr-unblock/address-feedback.mjs` and `publish-feedba The script writes its reason to `ESCALATION_REASON_FILE`; the runner accepts only those six reasons, and only with `review-replies`, as the run's `escalation-reason` output. -The skill's limits to kept-thread paths and away from `.github/` are instructions and the tool's +The skill's limits to kept-thread scope and away from `.github/` are instructions and the tool's checks at record time; the promote step's path check is what holds the push, and the reply job's compare withholds the replies when the branch changed anything else. A diff at the files listing's limit escalates as `feedback-protected-path`, since it could hide a `.github/` path. Residual: a reviewed-head marker confirms a head, not one diff --git a/plugins/source-control/skills/pull-request/reference/comments-ci.md b/plugins/source-control/skills/pull-request/reference/comments-ci.md index 2ee223e9866..3381fa632e0 100644 --- a/plugins/source-control/skills/pull-request/reference/comments-ci.md +++ b/plugins/source-control/skills/pull-request/reference/comments-ci.md @@ -19,8 +19,12 @@ The system prompt names the tool's path. It is the only shell command this mode posted after this lane left the thread open for a person. It is that person's decision on the finding: follow it. It changes what the fix is, not what this mode may do: the path and `.github/` rules below still hold, and it runs no command. -- `check-paths ...` prints `allowed` only when every path is one a kept thread names and - none is under `.github/`. +- `check-paths ...` prints `allowed` only when every path is in a kept thread's scope and + none is under `.github/`. A thread's scope is its path; a file in the `plugins/

/` + directory its path sits in, except one under `hooks/`, `.claude-plugin/` or a `.claude/` + directory, a `CLAUDE.md` or `AGENTS.md`, or a `.sh`, `.py`, `.mjs`, `.js` or `.ts` file; and a + test file (`test_*.py`, `*_test.py`, `*.test.*`, + `*.spec.*`) in its path's directory. - `record ` reads a disposition file from the scratch directory, checks it against the template, and records it. Exit 1 means the tool refused and recorded nothing: fix the file and record again. One record per thread. @@ -31,11 +35,11 @@ The system prompt names the tool's path. It is the only shell command this mode reading code and docs only: run no command, test, build or script from the checkout. CI runs them after the push. 2. Classify. On a thread with a direction, apply the direction: record `VALID` once its fix is - committed, or `UNCERTAIN` with the reason when it needs a file no kept thread names or cannot - be done in one scoped change. Never record `INCORRECT` against a direction. Otherwise, a + committed, or `UNCERTAIN` with the reason when it needs a file outside every kept thread's scope + or cannot be done in one scoped change. Never record `INCORRECT` against a direction. Otherwise, a finding that touches security, shared infrastructure or another repository, and - that the code you read cannot settle, is UNCERTAIN. So is a fix that needs a file no kept - thread names, or is too large for one scoped change. + that the code you read cannot settle, is UNCERTAIN. So is a fix that needs a file outside + every kept thread's scope, or is too large for one scoped change. 3. VALID: run `check-paths` on every file the fix changes before you edit it, and edit only when it prints `allowed`. Commit the fix with the commit tool before you record it. One commit may fix several threads. @@ -53,8 +57,8 @@ The system prompt names the tool's path. It is the only shell command this mode `VALID` and `INCORRECT` need at least one `path:line` in the checkout, at most 10. `VALID-defer` is VALID (defer). The note is 1-2000 characters of plain prose: no control characters, no HTML comment (``), no token, no @-mention. It is posted in a code fence. -5. When `record` refuses a `VALID` because the branch changes a path no kept thread names, do not - edit more files to get past it. Record the remaining threads and report the refusal. +5. When `record` refuses a `VALID` because the branch changes a path outside every kept thread's + scope, do not edit more files to get past it. Record the remaining threads and report the refusal. The reply job resolves a `VALID` thread once its fix is on the pull request's branch. `INCORRECT`, `UNCERTAIN` and `VALID-defer` stay open, and the pull request then goes to a person. diff --git a/plugins/source-control/skills/pull-request/scripts/feedback-thread.sh b/plugins/source-control/skills/pull-request/scripts/feedback-thread.sh index 1e91f873ed9..e4e391debd2 100755 --- a/plugins/source-control/skills/pull-request/scripts/feedback-thread.sh +++ b/plugins/source-control/skills/pull-request/scripts/feedback-thread.sh @@ -7,8 +7,8 @@ # runner uploads and a later job with no model validates and posts. # # list the kept threads, as JSON -# check-paths ... exit 0 when every path is one a kept -# thread names and none is under .github/ +# check-paths ... exit 0 when every path is in a kept +# thread's scope (allowed below) # record check the disposition against the # template and append it to the replies # @@ -44,10 +44,23 @@ scratch_dir=${FEEDBACK_SCRATCH:-} workspace=${FEEDBACK_WORKSPACE:-} repo=$(jq -er '.repository | select(test("^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$"))' "$threads_file") || die 2 "the thread file names no repository" -# A path a kept thread names, not under .github/ in any letter case. +# Not under .github/ in any letter case, with no empty, . or .. segment, and +# in a kept thread's scope: its path, a file in the plugins/

/ directory +# it sits in (not under hooks/, .claude-plugin/ or a .claude/ directory, not +# a CLAUDE.md or AGENTS.md, and not a .sh, .py, .mjs, .js or .ts file), or a +# test file in its directory. The same rule as +# .github/scripts/pr-unblock/thread-scope.mjs, which the promote step and +# the reply job apply. allowed() { - [[ ! ${1,,} =~ ^\.github(/|$) ]] && - jq -e --arg p "$1" 'any(.threads[]; .path == $p)' "$threads_file" >/dev/null + [[ ! ${1,,} =~ ^\.github(/|$) && ! $1 =~ (^|/)\.{0,2}(/|$) ]] && + jq -e --arg p "$1" ' + def dir: sub("[^/]*$"; ""); + (if $p | test("^plugins/[^/]+/(hooks|\\.claude-plugin)/|\\.(sh|py|mjs|js|ts)$|(^|/)(claude|agents)\\.md$|(^|/)\\.claude/"; "i") + then null else first($p | capture("^(?plugins/[^/]+/)").c) // null end) as $plugin + | ($p | sub("^.*/"; "") | test("^(test_[^/]*\\.py|[^/]*_test\\.py|[^/]*\\.(test|spec)\\.[^/]+)$")) as $test + | any(.threads[].path; . == $p + or ($plugin != null and startswith($plugin)) + or ($test and dir == ($p | dir)))' "$threads_file" >/dev/null } cmd_list() { @@ -59,7 +72,7 @@ cmd_check_paths() { local refused=0 path for path in "$@"; do if ! allowed "$path"; then - printf 'refused: %s is not a path a kept thread names, or is under .github/\n' "$path" >&2 + printf "refused: %s is outside every kept thread's scope, or is under .github/\n" "$path" >&2 refused=1 fi done @@ -113,7 +126,7 @@ cmd_record() { done < <(jq -r '.evidence[]' <<<"$d") # A VALID reply names a fix commit on the lane branch, and every file the - # lane branch changed since the gated head is one a kept thread names. + # lane branch changed since the gated head is in a kept thread's scope. if [[ $disposition == VALID ]]; then local head lane cmp path head=$(jq -er '.head_sha | select(test("^[0-9a-f]{40}$"))' "$threads_file") || die 2 "the thread file names no head" @@ -123,7 +136,7 @@ cmd_record() { die 1 "the fix $fix_sha is not a commit on the lane branch" (($(jq '.files | length' <<<"$cmp") < 300)) || die 1 "the lane branch changes too many files to check" while IFS= read -r path; do - allowed "$path" || die 1 "the lane branch changes $path, which no kept thread names or which is under .github/; nothing is recorded" + allowed "$path" || die 1 "the lane branch changes $path, which is outside every kept thread's scope or under .github/; nothing is recorded" done < <(jq -r '.files[] | .filename, (.previous_filename // empty)' <<<"$cmp") fi diff --git a/plugins/source-control/skills/pull-request/scripts/feedback-thread.test.sh b/plugins/source-control/skills/pull-request/scripts/feedback-thread.test.sh index 9731e04a4f7..a674bb01b04 100755 --- a/plugins/source-control/skills/pull-request/scripts/feedback-thread.test.sh +++ b/plugins/source-control/skills/pull-request/scripts/feedback-thread.test.sh @@ -84,6 +84,31 @@ run check-paths .github/workflows/ci.yml >/dev/null 2>&1 assert_exit "check-paths refuses .github/ even when a thread names it" 1 "$?" run check-paths .GITHUB/x.yml >/dev/null 2>&1 assert_exit "check-paths refuses .github/ in any letter case" 1 "$?" +run check-paths .github/workflows/ci.test.yml >/dev/null 2>&1 +assert_exit "check-paths refuses a test file beside a .github/ thread path" 1 "$?" + +# check-paths: a file in a kept thread's plugins/

/ directory other than hooks, manifests, +# agent rules and non-test code, and test files beside a thread path +setup +jq '.threads += [{"id":"PRRT_e","path":"plugins/p/evals/c/case.yaml","root_comment_id":400,"comments":[]}, + {"id":"PRRT_s","path":"plugins/planning/surface/round.py","root_comment_id":401,"comments":[]}]' \ + "$STUB/threads.json" >"$STUB/t2" && mv "$STUB/t2" "$STUB/threads.json" +for path in plugins/p/evals/c/samples/y.json plugins/p/evals/c/samples/deep/z.json \ + plugins/p/evals/other/samples/y.json plugins/p/skills/s/SKILL.md plugins/p/evals/x.json \ + plugins/planning/README.md plugins/planning/surface/test_round.py plugins/planning/surface/round_test.py src/a.test.js src/a.spec.ts; do + run check-paths "$path" >/dev/null 2>&1 + assert_exit "check-paths allows $path" 0 "$?" +done +for path in plugins/q/evals/c/samples/y.json plugins/pp/README.md plugins/README.md \ + plugins/p/hooks/hooks.json plugins/p/.claude-plugin/plugin.json plugins/p/scripts/run.sh \ + plugins/p/AGENTS.md plugins/p/skills/s/CLAUDE.md plugins/p/.claude/rules/r.md plugins/p/lib/X.TS \ + plugins/planning/surface/helper.py plugins/planning/test_round.py \ + src/lib/a.test.js src/helper.js src/test_a.sh test_root.py \ + plugins/p/evals/c/../../../../.github/workflows/x.yml plugins/p/evals/c/./y.json plugins/p/evals/c//y.json \ + /src/a.js ./src/a.js; do + run check-paths "$path" >/dev/null 2>&1 + assert_exit "check-paths refuses $path" 1 "$?" +done # record VALID: checked against the lane branch and appended, nothing posted setup