replume-cli 0.2.0 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +48 -5
- package/dist/cli.js +11576 -6659
- package/package.json +10 -4
- package/templates/pr-check-publish.mjs +125 -0
- package/templates/pr-check.yml +94 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "replume-cli",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"replume": "dist/cli.js"
|
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
"test": "vitest run",
|
|
11
11
|
"typecheck": "tsc --noEmit",
|
|
12
12
|
"check-context": "node scripts/check-context.mjs",
|
|
13
|
-
"prepublishOnly": "npm run build && npm run typecheck && npm run check-context && npm test"
|
|
13
|
+
"prepublishOnly": "npm run build && npm run typecheck && npm run check-context && npm test",
|
|
14
|
+
"eval": "node eval/run.mjs"
|
|
14
15
|
},
|
|
15
16
|
"dependencies": {
|
|
16
17
|
"commander": "^12.1.0"
|
|
@@ -20,7 +21,7 @@
|
|
|
20
21
|
"tsup": "^8.3.0",
|
|
21
22
|
"tsx": "^4.23.12",
|
|
22
23
|
"typescript": "^5.6.0",
|
|
23
|
-
"vitest": "^
|
|
24
|
+
"vitest": "^4.1.12"
|
|
24
25
|
},
|
|
25
26
|
"description": "Verification and supervision for AI coding agents",
|
|
26
27
|
"keywords": [
|
|
@@ -34,9 +35,14 @@
|
|
|
34
35
|
},
|
|
35
36
|
"files": [
|
|
36
37
|
"dist/cli.js",
|
|
38
|
+
"templates/pr-check.yml",
|
|
39
|
+
"templates/pr-check-publish.mjs",
|
|
37
40
|
"README.md",
|
|
38
41
|
"LICENSE",
|
|
39
42
|
"THIRD-PARTY-NOTICES.md"
|
|
40
43
|
],
|
|
41
|
-
"license": "SEE LICENSE IN LICENSE"
|
|
44
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
45
|
+
"overrides": {
|
|
46
|
+
"esbuild": "^0.28.2"
|
|
47
|
+
}
|
|
42
48
|
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// Copy beside the workflow as .github/replume-pr-check-publish.mjs on the trusted base branch.
|
|
2
|
+
// The publisher reads the verify artifact; --check-policy reads committed policy from the PR base.
|
|
3
|
+
import { execFileSync } from "node:child_process";
|
|
4
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
|
|
7
|
+
const marker = "<!-- replume-pr-check -->";
|
|
8
|
+
const text = (value) => String(value ?? "").replace(/[\r\n\t]+/g, " ").replace(/[<>`]/g, "").slice(0, 350);
|
|
9
|
+
const items = (entries) => entries.length ? entries.map((entry) => `- ${entry}`).join("\n") : "- None recorded.";
|
|
10
|
+
const contractPath = ".replume/contract.yaml";
|
|
11
|
+
|
|
12
|
+
// Replume's contract writer emits a flow or indented list. Fail closed if the base has no non-empty protect list.
|
|
13
|
+
function hasProtectedTests(source) {
|
|
14
|
+
const lines = source.split(/\r?\n/);
|
|
15
|
+
const index = lines.findIndex((line) => /^protect:(?:\s|$)/.test(line));
|
|
16
|
+
if (index < 0) return false;
|
|
17
|
+
const value = lines[index].slice("protect:".length).replace(/\s+#.*$/, "").trim();
|
|
18
|
+
if (value) return /^\[.*\]$/.test(value) && value.slice(1, -1).trim().length > 0;
|
|
19
|
+
for (const line of lines.slice(index + 1)) {
|
|
20
|
+
const content = line.replace(/\s+#.*$/, "").trimEnd();
|
|
21
|
+
if (!content.trim()) continue;
|
|
22
|
+
if (!/^\s/.test(content)) break;
|
|
23
|
+
if (/^\s+-\s+\S/.test(content)) return true;
|
|
24
|
+
}
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function checkBasePolicy(baseSha, repo = process.cwd()) {
|
|
29
|
+
if (!/^[0-9a-f]{40}$/i.test(baseSha ?? "")) throw new Error("PR base SHA must be a full commit hash");
|
|
30
|
+
let source;
|
|
31
|
+
try {
|
|
32
|
+
source = execFileSync("git", ["show", `${baseSha}:${contractPath}`], { cwd: repo, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
33
|
+
} catch {
|
|
34
|
+
return `Commit ${contractPath} with a confirmed task and non-empty protect test globs to the PR base branch.`;
|
|
35
|
+
}
|
|
36
|
+
return hasProtectedTests(source) ? null : `Commit non-empty protect test globs in ${contractPath} on the PR base branch.`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function result(reportPath, exitPath, policyPath = join(dirname(reportPath), "policy.txt")) {
|
|
40
|
+
let policyNote = "";
|
|
41
|
+
try { if (existsSync(policyPath)) policyNote = readFileSync(policyPath, "utf8").trim().split(/\r?\n/)[0]; } catch { /* incomplete below */ }
|
|
42
|
+
if (policyNote) return { report: null, verdict: "INCOMPLETE", reason: policyNote };
|
|
43
|
+
let report = null;
|
|
44
|
+
try { if (existsSync(reportPath)) report = JSON.parse(readFileSync(reportPath, "utf8")); } catch { /* incomplete below */ }
|
|
45
|
+
let exit = "";
|
|
46
|
+
try { if (existsSync(exitPath)) exit = readFileSync(exitPath, "utf8").trim(); } catch { /* incomplete below */ }
|
|
47
|
+
const expected = report?.verdict === "pass" ? "0" : report?.verdict === "fail" ? "1" : report?.verdict === "incomplete" ? "2" : "";
|
|
48
|
+
const verdict = expected && exit === expected ? report.verdict.toUpperCase() : "INCOMPLETE";
|
|
49
|
+
return { report: report && typeof report === "object" ? report : null, verdict,
|
|
50
|
+
reason: verdict === "INCOMPLETE" && expected !== exit ? "Verification did not produce a usable, matching result." : "" };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function commentBody(reportPath, exitPath, policyPath) {
|
|
54
|
+
const { report, verdict, reason } = result(reportPath, exitPath, policyPath);
|
|
55
|
+
const done = Array.isArray(report?.done) ? report.done : [];
|
|
56
|
+
const failed = Array.isArray(report?.failed) ? report.failed : [];
|
|
57
|
+
const extra = Array.isArray(report?.extra) ? report.extra : [];
|
|
58
|
+
const tampering = Array.isArray(report?.tampering) ? report.tampering : [];
|
|
59
|
+
const notChecked = Array.isArray(report?.notChecked) ? report.notChecked : [];
|
|
60
|
+
const verified = done.map((entry) => `${text(entry.id)}: ${text(entry.evidence)}`);
|
|
61
|
+
const failures = failed.map((entry) => `${text(entry.id)}: ${text(entry.evidence)}`);
|
|
62
|
+
const beyond = [
|
|
63
|
+
...extra.map((entry) => `${text(entry.path)}: ${text(entry.reason)}${entry.informational ? " (information only)" : ""}`),
|
|
64
|
+
...tampering.map((entry) => `${text(entry.path)}: ${text(entry.detail)}`),
|
|
65
|
+
];
|
|
66
|
+
const unchecked = [
|
|
67
|
+
...notChecked.map((entry) => `${text(entry.path)}: ${text(entry.reason)}`),
|
|
68
|
+
"CI did not observe agent steps; this result covers the commit diff and checks run here.",
|
|
69
|
+
];
|
|
70
|
+
if (reason) unchecked.unshift(reason);
|
|
71
|
+
return [marker, `**Replume: ${verdict}${reason ? ` — ${text(reason)}` : ""}**`, "",
|
|
72
|
+
"## Verified", items(verified), "",
|
|
73
|
+
...(failures.length ? ["## Failed", items(failures), ""] : []),
|
|
74
|
+
"## Changed beyond the task", items(beyond), "",
|
|
75
|
+
"## Not checked", items(unchecked), "",
|
|
76
|
+
"_Tests ran in an isolated working copy. This comment was made from the saved CI result; it did not rerun verification._",
|
|
77
|
+
].join("\n");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function api(path, method, token, body) {
|
|
81
|
+
const base = (process.env.GITHUB_API_URL || "https://api.github.com").replace(/\/$/, "");
|
|
82
|
+
const response = await fetch(`${base}${path}`, {
|
|
83
|
+
method, signal: AbortSignal.timeout(15_000),
|
|
84
|
+
headers: { authorization: `Bearer ${token}`, accept: "application/vnd.github+json", "content-type": "application/json" },
|
|
85
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
86
|
+
});
|
|
87
|
+
if (!response.ok) throw new Error(`GitHub ${method} ${path} returned HTTP ${response.status}`);
|
|
88
|
+
return response.json();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function publish(env = process.env) {
|
|
92
|
+
const repo = env.GITHUB_REPOSITORY, pr = Number(env.PR_NUMBER), sha = env.HEAD_SHA, token = env.GITHUB_TOKEN;
|
|
93
|
+
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo ?? "") || !Number.isSafeInteger(pr) || pr < 1 || !/^[a-fA-F0-9]{40}$/.test(sha ?? "") || !token)
|
|
94
|
+
throw new Error("Missing or invalid GitHub repository, PR, head SHA, or token");
|
|
95
|
+
const reportPath = env.REPLUME_REPORT || "replume-ci/report.json";
|
|
96
|
+
const exitPath = env.REPLUME_VERIFY_EXIT_FILE || "replume-ci/exit.txt";
|
|
97
|
+
const policyPath = env.REPLUME_POLICY_NOTE || join(dirname(reportPath), "policy.txt");
|
|
98
|
+
const body = commentBody(reportPath, exitPath, policyPath);
|
|
99
|
+
const verdict = result(reportPath, exitPath, policyPath).verdict;
|
|
100
|
+
let existing = null;
|
|
101
|
+
for (let page = 1; page <= 20; page++) {
|
|
102
|
+
const comments = await api(`/repos/${repo}/issues/${pr}/comments?per_page=100&page=${page}`, "GET", token);
|
|
103
|
+
if (!Array.isArray(comments)) throw new Error("GitHub comments response was not a list");
|
|
104
|
+
existing = comments.find((entry) => entry?.body?.startsWith(marker) && entry?.user?.login === "github-actions[bot]") ?? null;
|
|
105
|
+
if (existing || comments.length < 100) break;
|
|
106
|
+
}
|
|
107
|
+
if (existing) await api(`/repos/${repo}/issues/comments/${existing.id}`, "PATCH", token, { body });
|
|
108
|
+
else await api(`/repos/${repo}/issues/${pr}/comments`, "POST", token, { body });
|
|
109
|
+
const conclusion = verdict === "PASS" ? "success" : verdict === "FAIL" ? "failure" : "neutral";
|
|
110
|
+
await api(`/repos/${repo}/check-runs`, "POST", token, {
|
|
111
|
+
name: "Replume / task", head_sha: sha, status: "completed", conclusion,
|
|
112
|
+
output: { title: `Replume: ${verdict}`, summary: body.slice(0, 60000) },
|
|
113
|
+
});
|
|
114
|
+
process.stdout.write(`Replume: ${verdict}; ${existing ? "updated" : "posted"} one PR comment; check ${conclusion}.\n`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (process.argv[1] && new URL(import.meta.url).pathname === process.argv[1]) {
|
|
118
|
+
if (process.argv[2] === "--check-policy") {
|
|
119
|
+
try {
|
|
120
|
+
const reason = checkBasePolicy(process.argv[3]);
|
|
121
|
+
writeFileSync(process.argv[4], reason ? `${reason}\n` : "");
|
|
122
|
+
if (reason) { process.stdout.write(`Replume: INCOMPLETE — ${reason}\n`); process.exitCode = 2; }
|
|
123
|
+
} catch (error) { process.stderr.write(`replume-pr-check: ${error.message}\n`); process.exitCode = 2; }
|
|
124
|
+
} else publish().catch((error) => { process.stderr.write(`replume-pr-check: ${error.message}\n`); process.exitCode = 1; });
|
|
125
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
name: Replume PR check
|
|
2
|
+
on: pull_request
|
|
3
|
+
|
|
4
|
+
concurrency:
|
|
5
|
+
group: replume-pr-${{ github.event.pull_request.number }}
|
|
6
|
+
cancel-in-progress: true
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
verify:
|
|
10
|
+
name: Replume / verify
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
timeout-minutes: 15
|
|
13
|
+
permissions:
|
|
14
|
+
contents: read
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v4
|
|
17
|
+
with:
|
|
18
|
+
fetch-depth: 0
|
|
19
|
+
persist-credentials: false
|
|
20
|
+
- uses: actions/setup-node@v4
|
|
21
|
+
with:
|
|
22
|
+
node-version: '20'
|
|
23
|
+
- run: mkdir -p "$RUNNER_TEMP/replume-ci"
|
|
24
|
+
- run: npm install -g replume-cli@0.2.1
|
|
25
|
+
- name: Install project dependencies
|
|
26
|
+
if: ${{ hashFiles('package-lock.json') != '' }}
|
|
27
|
+
run: npm ci
|
|
28
|
+
- name: Verify the PR merge commit against base policy
|
|
29
|
+
shell: bash
|
|
30
|
+
env:
|
|
31
|
+
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
|
32
|
+
run: |
|
|
33
|
+
set +e
|
|
34
|
+
if ! git show "$BASE_SHA:.github/replume-pr-check-publish.mjs" > "$RUNNER_TEMP/replume-base-publisher.mjs"; then
|
|
35
|
+
printf '%s\n' 'Commit .github/replume-pr-check-publish.mjs to the PR base branch.' > "$RUNNER_TEMP/replume-ci/policy.txt"
|
|
36
|
+
printf '2\n' > "$RUNNER_TEMP/replume-ci/exit.txt"
|
|
37
|
+
exit 0
|
|
38
|
+
fi
|
|
39
|
+
node "$RUNNER_TEMP/replume-base-publisher.mjs" --check-policy "$BASE_SHA" "$RUNNER_TEMP/replume-ci/policy.txt"
|
|
40
|
+
if [ "$?" -ne 0 ]; then
|
|
41
|
+
printf '2\n' > "$RUNNER_TEMP/replume-ci/exit.txt"
|
|
42
|
+
exit 0
|
|
43
|
+
fi
|
|
44
|
+
replume verify --commit HEAD --trusted-base "$BASE_SHA" --isolation worktree --json > "$RUNNER_TEMP/replume-ci/report.json" 2> "$RUNNER_TEMP/replume-ci/verify.log"
|
|
45
|
+
code=$?
|
|
46
|
+
printf '%s\n' "$code" > "$RUNNER_TEMP/replume-ci/exit.txt"
|
|
47
|
+
cat "$RUNNER_TEMP/replume-ci/verify.log"
|
|
48
|
+
exit 0
|
|
49
|
+
- uses: actions/upload-artifact@v4
|
|
50
|
+
if: always()
|
|
51
|
+
with:
|
|
52
|
+
name: replume-pr-result
|
|
53
|
+
path: ${{ runner.temp }}/replume-ci/
|
|
54
|
+
if-no-files-found: error
|
|
55
|
+
- name: Require Replume PASS
|
|
56
|
+
if: always()
|
|
57
|
+
shell: bash
|
|
58
|
+
run: |
|
|
59
|
+
if [ ! -f "$RUNNER_TEMP/replume-ci/exit.txt" ]; then exit 2; fi
|
|
60
|
+
exit "$(cat "$RUNNER_TEMP/replume-ci/exit.txt")"
|
|
61
|
+
|
|
62
|
+
publish:
|
|
63
|
+
name: Replume / PR result
|
|
64
|
+
if: ${{ always() && github.event.pull_request.head.repo.full_name == github.repository }}
|
|
65
|
+
needs: verify
|
|
66
|
+
runs-on: ubuntu-latest
|
|
67
|
+
timeout-minutes: 5
|
|
68
|
+
permissions:
|
|
69
|
+
contents: read
|
|
70
|
+
pull-requests: write
|
|
71
|
+
checks: write
|
|
72
|
+
steps:
|
|
73
|
+
- uses: actions/checkout@v4
|
|
74
|
+
with:
|
|
75
|
+
ref: ${{ github.event.pull_request.base.sha }}
|
|
76
|
+
persist-credentials: false
|
|
77
|
+
- uses: actions/setup-node@v4
|
|
78
|
+
with:
|
|
79
|
+
node-version: '20'
|
|
80
|
+
- uses: actions/download-artifact@v4
|
|
81
|
+
continue-on-error: true
|
|
82
|
+
with:
|
|
83
|
+
name: replume-pr-result
|
|
84
|
+
path: replume-ci/
|
|
85
|
+
- name: Update one PR comment and the Replume check
|
|
86
|
+
env:
|
|
87
|
+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
88
|
+
GITHUB_REPOSITORY: ${{ github.repository }}
|
|
89
|
+
PR_NUMBER: ${{ github.event.pull_request.number }}
|
|
90
|
+
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
|
91
|
+
REPLUME_REPORT: replume-ci/report.json
|
|
92
|
+
REPLUME_VERIFY_EXIT_FILE: replume-ci/exit.txt
|
|
93
|
+
REPLUME_POLICY_NOTE: replume-ci/policy.txt
|
|
94
|
+
run: node .github/replume-pr-check-publish.mjs
|