@makerx/yoke 0.7.0 → 0.8.1
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/dist/cli.js +680 -474
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -7,7 +7,7 @@ import { Command } from "commander";
|
|
|
7
7
|
// package.json
|
|
8
8
|
var package_default = {
|
|
9
9
|
name: "@makerx/yoke",
|
|
10
|
-
version: "0.
|
|
10
|
+
version: "0.8.1",
|
|
11
11
|
description: "Lean, user-invoked Claude Code skills for MakerX AI-assisted delivery, plus the CLI they call.",
|
|
12
12
|
license: "MIT",
|
|
13
13
|
author: "MakerX",
|
|
@@ -136,14 +136,14 @@ function wrap(text2, width) {
|
|
|
136
136
|
return line ? [...lines, line] : lines;
|
|
137
137
|
}
|
|
138
138
|
function listSettings(settings, columns = Infinity) {
|
|
139
|
-
const
|
|
139
|
+
const rows2 = settingKeys.map((key) => ({
|
|
140
140
|
key,
|
|
141
141
|
value: displayValue(key, settings[key]),
|
|
142
142
|
description: settingDescription(key)
|
|
143
143
|
}));
|
|
144
|
-
const keyWidth = Math.max(...
|
|
144
|
+
const keyWidth = Math.max(...rows2.map(({ key }) => key.length));
|
|
145
145
|
const indent = " ".repeat(keyWidth + gap.length);
|
|
146
|
-
const blocks =
|
|
146
|
+
const blocks = rows2.map(
|
|
147
147
|
({ key, value, description }) => [`${key.padEnd(keyWidth)}${gap}${value}`, ...wrap(description, columns - indent.length).map((line) => indent + line)].join("\n")
|
|
148
148
|
);
|
|
149
149
|
return blocks.join("\n\n");
|
|
@@ -670,7 +670,8 @@ function renderCallerWorkflow({ inputs }) {
|
|
|
670
670
|
` uses: ${reviewWorkflowRef}`,
|
|
671
671
|
" with:",
|
|
672
672
|
...inputs.map(([name, value]) => ` ${name}: ${yamlValue(value)}`),
|
|
673
|
-
" secrets:
|
|
673
|
+
" secrets:",
|
|
674
|
+
...ciSecrets.map((secret2) => ` ${secret2}: \${{ secrets.${secret2} }}`),
|
|
674
675
|
""
|
|
675
676
|
].join("\n");
|
|
676
677
|
}
|
|
@@ -823,6 +824,37 @@ import { existsSync as existsSync7, mkdirSync as mkdirSync4, mkdtempSync, readFi
|
|
|
823
824
|
import { homedir as homedir2, tmpdir } from "os";
|
|
824
825
|
import { join as join8 } from "path";
|
|
825
826
|
|
|
827
|
+
// src/features/review/comments.ts
|
|
828
|
+
var markerPrefix = "<!-- yoke-review:";
|
|
829
|
+
function slug(text2) {
|
|
830
|
+
return text2.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
831
|
+
}
|
|
832
|
+
function findingMarker({ file, title }) {
|
|
833
|
+
return `${markerPrefix} ${file ?? "n/a"}#${slug(title)} -->`;
|
|
834
|
+
}
|
|
835
|
+
function hasMarker(prior, marker) {
|
|
836
|
+
return prior.some((comment) => comment.body.includes(marker));
|
|
837
|
+
}
|
|
838
|
+
function withoutMarkers(body) {
|
|
839
|
+
return body.split("\n").filter((line) => !line.trimStart().startsWith(markerPrefix)).join("\n").trim();
|
|
840
|
+
}
|
|
841
|
+
function heading({ path, line }) {
|
|
842
|
+
if (!path) return "### PR review";
|
|
843
|
+
return line === null ? `### ${path}` : `### ${path}:${line}`;
|
|
844
|
+
}
|
|
845
|
+
function formatPriorComments(prior) {
|
|
846
|
+
if (!prior.length) return "";
|
|
847
|
+
const blocks = prior.map((comment) => `${heading(comment)}
|
|
848
|
+
|
|
849
|
+
**${comment.author}**: ${withoutMarkers(comment.body)}`);
|
|
850
|
+
return `## Prior review comments
|
|
851
|
+
|
|
852
|
+
The PR already has the review comments below, including resolved and outdated threads. Do not re-raise a finding a prior comment substantively covers.
|
|
853
|
+
|
|
854
|
+
${blocks.join("\n\n")}
|
|
855
|
+
`;
|
|
856
|
+
}
|
|
857
|
+
|
|
826
858
|
// src/features/review/findings.ts
|
|
827
859
|
var severities = ["blocker", "major", "minor", "nit"];
|
|
828
860
|
var sources = ["confirmed", "disputed", "reviewer1-only", "reviewer2-only", "already-raised"];
|
|
@@ -881,463 +913,554 @@ function formatSynthesis({ summary, findings }) {
|
|
|
881
913
|
return lines.join("\n").trimEnd();
|
|
882
914
|
}
|
|
883
915
|
|
|
884
|
-
// src/features/review/
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
return
|
|
916
|
+
// src/features/review/diff.ts
|
|
917
|
+
var fileHeader = /^\+\+\+ (.+)$/;
|
|
918
|
+
var hunkHeader = /^@@ -\d+(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/;
|
|
919
|
+
var escapes = { a: 7, b: 8, t: 9, n: 10, v: 11, f: 12, r: 13 };
|
|
920
|
+
function parseDiff(diff) {
|
|
921
|
+
let file = null;
|
|
922
|
+
const hunk = { next: 0, oldLeft: 0, newLeft: 0 };
|
|
923
|
+
return diff.split("\n").map((text2) => {
|
|
924
|
+
if (hunk.oldLeft > 0 || hunk.newLeft > 0) return { text: text2, file, line: hunkLine(text2, hunk) };
|
|
925
|
+
const header = text2.match(fileHeader)?.[1];
|
|
926
|
+
if (header) file = header === "/dev/null" ? null : diffPath(header);
|
|
927
|
+
const range = text2.match(hunkHeader);
|
|
928
|
+
if (range) Object.assign(hunk, { oldLeft: Number(range[1] ?? 1), next: Number(range[2]), newLeft: Number(range[3] ?? 1) });
|
|
929
|
+
return { text: text2, file, line: null };
|
|
930
|
+
});
|
|
892
931
|
}
|
|
893
|
-
function
|
|
894
|
-
|
|
895
|
-
|
|
932
|
+
function hunkLine(text2, hunk) {
|
|
933
|
+
if (text2.startsWith("-")) hunk.oldLeft--;
|
|
934
|
+
else if (text2.startsWith("+")) hunk.newLeft--;
|
|
935
|
+
else if (text2.startsWith(" ")) {
|
|
936
|
+
hunk.oldLeft--;
|
|
937
|
+
hunk.newLeft--;
|
|
938
|
+
} else return null;
|
|
939
|
+
return text2.startsWith("-") ? null : hunk.next++;
|
|
896
940
|
}
|
|
897
|
-
function
|
|
898
|
-
const
|
|
899
|
-
|
|
941
|
+
function diffPath(raw) {
|
|
942
|
+
const quoted = raw.match(/^"(.*)"$/)?.[1];
|
|
943
|
+
const path = quoted === void 0 ? raw : unquote(quoted);
|
|
944
|
+
return path.startsWith("b/") ? path.slice(2) : path;
|
|
900
945
|
}
|
|
901
|
-
function
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
if (pattern) {
|
|
905
|
-
const scope = text(input, "path");
|
|
906
|
-
return scope ? `${pattern} in ${scope}` : pattern;
|
|
907
|
-
}
|
|
908
|
-
const found = text(input, "file_path") ?? text(input, "path") ?? text(input, "url");
|
|
909
|
-
if (found) return found;
|
|
910
|
-
const described = text(input, "command") ?? text(input, "query") ?? text(input, "description");
|
|
911
|
-
return described ? truncate(described) : "";
|
|
946
|
+
function unquote(quoted) {
|
|
947
|
+
const parts = quoted.split(/(\\[0-7]{3}|\\.)/);
|
|
948
|
+
return Buffer.concat(parts.map((part, i) => i % 2 ? escapedBytes(part) : Buffer.from(part))).toString("utf8");
|
|
912
949
|
}
|
|
913
|
-
function
|
|
914
|
-
|
|
950
|
+
function escapedBytes(escape) {
|
|
951
|
+
const code = escape.slice(1);
|
|
952
|
+
if (/^[0-7]{3}$/.test(code)) return Buffer.from([parseInt(code, 8)]);
|
|
953
|
+
return Buffer.from([escapes[code] ?? code.charCodeAt(0)]);
|
|
915
954
|
}
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
const uses = content.filter((block) => isJson(block) && block.type === "tool_use");
|
|
922
|
-
if (!uses.length) return { kind: "ignore" };
|
|
923
|
-
return { kind: "activity", text: uses.map((use) => describeTool(text(use, "name") ?? "tool", toolSummary(use.input))).join(", ") };
|
|
955
|
+
|
|
956
|
+
// src/features/review/selfReviewNote.ts
|
|
957
|
+
var yokeCaller = /\buses:\s*['"]?MakerXStudio\/yoke\/\.github\/workflows\/review\.yml@/i;
|
|
958
|
+
function editsYokeCaller(diff) {
|
|
959
|
+
return parseDiff(diff).some(({ text: text2, file, line }) => line !== null && file?.startsWith(".github/workflows/") && yokeCaller.test(text2));
|
|
924
960
|
}
|
|
925
|
-
function
|
|
926
|
-
|
|
927
|
-
return
|
|
961
|
+
function selfReviewNote(diff) {
|
|
962
|
+
if (!editsYokeCaller(diff)) return "";
|
|
963
|
+
return `## Yoke's own review workflow
|
|
964
|
+
|
|
965
|
+
This change adds or edits a workflow that calls \`MakerXStudio/yoke/.github/workflows/review.yml\`, the CI setup for this review. Review it like any other file, but these are yoke's deliberate design: do not raise them as findings, keep them in a consolidated review, or mention them in a summary.
|
|
966
|
+
|
|
967
|
+
- Calling the workflow at \`@main\` rather than a tag or SHA.
|
|
968
|
+
- The trigger types, including leaving out \`synchronize\`: a review is re-run by marking the PR as draft and then ready for review.
|
|
969
|
+
- Draft PRs being reviewed twice: the reusable workflow already skips drafts.
|
|
970
|
+
|
|
971
|
+
Real defects in it are still findings, such as passing every secret with \`secrets: inherit\` instead of only \`REVIEW_CI_BASE_URL\` and \`REVIEW_CI_API_KEY\`, or an input the reusable workflow does not declare.
|
|
972
|
+
`;
|
|
928
973
|
}
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
974
|
+
|
|
975
|
+
// src/features/review/request.ts
|
|
976
|
+
var gutterWidth = 6;
|
|
977
|
+
function gutter(line) {
|
|
978
|
+
return `${(line === null ? "" : String(line)).padStart(gutterWidth)} `;
|
|
933
979
|
}
|
|
934
|
-
function
|
|
935
|
-
|
|
936
|
-
if (message) return { kind: "error", message };
|
|
937
|
-
const item = event.item;
|
|
938
|
-
if (event.type !== "item.started" || !isJson(item)) return { kind: "ignore" };
|
|
939
|
-
const command = text(item, "command");
|
|
940
|
-
if (item.type === "command_execution" && command) return { kind: "activity", text: describeTool("shell", unwrapShell(command)) };
|
|
941
|
-
return { kind: "ignore" };
|
|
980
|
+
function withNewFileLineNumbers(diff) {
|
|
981
|
+
return parseDiff(diff).map(({ text: text2, line }) => gutter(line) + text2).join("\n");
|
|
942
982
|
}
|
|
943
|
-
function
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
983
|
+
function commentableLines(diff) {
|
|
984
|
+
const lines = /* @__PURE__ */ new Map();
|
|
985
|
+
for (const { file, line } of parseDiff(diff)) {
|
|
986
|
+
if (file === null || line === null) continue;
|
|
987
|
+
const numbers = lines.get(file) ?? /* @__PURE__ */ new Set();
|
|
988
|
+
lines.set(file, numbers.add(line));
|
|
949
989
|
}
|
|
950
|
-
|
|
951
|
-
return harness === "claude" ? claudeEvent(event) : codexEvent(event);
|
|
990
|
+
return lines;
|
|
952
991
|
}
|
|
992
|
+
function buildRequest(pr2, diff, prior = []) {
|
|
993
|
+
const files = pr2.files.map((f) => `- ${f.path}`).join("\n") || "(none)";
|
|
994
|
+
const priorSection = formatPriorComments(prior);
|
|
995
|
+
const note = selfReviewNote(diff);
|
|
996
|
+
return `# Code review request
|
|
953
997
|
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
998
|
+
- PR: #${pr2.number} ${pr2.title}
|
|
999
|
+
- Head: \`${pr2.headRefName}\` (\`${pr2.headRefOid}\`)
|
|
1000
|
+
- Base: \`${pr2.baseRefName}\` (\`${pr2.baseRefOid}\`)
|
|
1001
|
+
|
|
1002
|
+
## Changed files
|
|
1003
|
+
|
|
1004
|
+
${files}
|
|
1005
|
+
|
|
1006
|
+
## Diff
|
|
1007
|
+
|
|
1008
|
+
Each added or context line is prefixed with a left gutter showing its line number in the new file. When citing a finding as \`file:line\`, use that gutter number \u2014 do **not** count lines in this document. Removed lines (\`-\`) and headers have a blank gutter and cannot be commented on.
|
|
1009
|
+
|
|
1010
|
+
\`\`\`diff
|
|
1011
|
+
${withNewFileLineNumbers(diff.trimEnd())}
|
|
1012
|
+
\`\`\`
|
|
1013
|
+
${note && `
|
|
1014
|
+
${note}`}${priorSection && `
|
|
1015
|
+
${priorSection}`}`;
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
// src/features/review/post.ts
|
|
1019
|
+
function isAlreadyRaised(finding, prior) {
|
|
1020
|
+
return finding.source === "already-raised" || hasMarker(prior, findingMarker(finding));
|
|
1021
|
+
}
|
|
1022
|
+
function details(finding) {
|
|
959
1023
|
return [
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
reviewDir,
|
|
963
|
-
"--disallowedTools",
|
|
964
|
-
"Edit,Write,NotebookEdit",
|
|
965
|
-
"--output-format",
|
|
966
|
-
"stream-json",
|
|
967
|
-
"--verbose",
|
|
968
|
-
...extra
|
|
1024
|
+
...finding.impact ? [`Impact: ${finding.impact}`] : [],
|
|
1025
|
+
...finding.recommendation ? [`Recommendation: ${finding.recommendation}`] : []
|
|
969
1026
|
];
|
|
970
1027
|
}
|
|
971
|
-
function
|
|
972
|
-
return
|
|
1028
|
+
function commentBody(finding) {
|
|
1029
|
+
return [`**${finding.severity ?? "finding"}: ${finding.title}**`, ...details(finding), findingMarker(finding)].join("\n\n");
|
|
973
1030
|
}
|
|
974
|
-
function
|
|
975
|
-
|
|
1031
|
+
function planPost({ findings }, diff, prior) {
|
|
1032
|
+
const lines = commentableLines(diff);
|
|
1033
|
+
const plan = { comments: [], outside: [], skipped: [] };
|
|
1034
|
+
for (const finding of findings) {
|
|
1035
|
+
const { file, line } = finding;
|
|
1036
|
+
if (isAlreadyRaised(finding, prior)) plan.skipped.push(finding);
|
|
1037
|
+
else if (file && line !== null && lines.get(file)?.has(line))
|
|
1038
|
+
plan.comments.push({ path: file, line, body: commentBody(finding), finding });
|
|
1039
|
+
else plan.outside.push(finding);
|
|
1040
|
+
}
|
|
1041
|
+
return plan;
|
|
976
1042
|
}
|
|
977
|
-
function
|
|
978
|
-
|
|
979
|
-
|
|
1043
|
+
function outsideEntry(finding) {
|
|
1044
|
+
const location = finding.file ? `\`${formatLocation(finding)}\`` : "no location";
|
|
1045
|
+
const entry = [`- **${finding.severity ?? "finding"}** ${location} \u2014 ${finding.title}`, ...details(finding).map((d) => ` ${d}`)];
|
|
1046
|
+
return [...entry, ` ${findingMarker(finding)}`].join("\n");
|
|
980
1047
|
}
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
1048
|
+
var noFindingsNote = "No new findings were raised.";
|
|
1049
|
+
var rerunNote = "To re-run this review, mark the PR as draft and then ready for review again.";
|
|
1050
|
+
function reviewBody(summary, outside, footer = "", note = "") {
|
|
1051
|
+
const sections = ["## Adversarial review", ...summary ? [summary] : [], ...note ? [note] : []];
|
|
1052
|
+
if (outside.length) sections.push(`### Findings outside the diff
|
|
1053
|
+
|
|
1054
|
+
${outside.map(outsideEntry).join("\n")}`);
|
|
1055
|
+
if (footer) sections.push(footer);
|
|
1056
|
+
return sections.join("\n\n");
|
|
984
1057
|
}
|
|
985
|
-
function
|
|
986
|
-
return
|
|
1058
|
+
function postFooter(footer, ci) {
|
|
1059
|
+
return [...footer ? [footer] : [], ...ci ? [`_${rerunNote}_`] : []].join("\n\n");
|
|
987
1060
|
}
|
|
988
|
-
|
|
989
|
-
const
|
|
990
|
-
const
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
let lastError = "";
|
|
1001
|
-
let stderr = "";
|
|
1002
|
-
const onLine = (line) => {
|
|
1003
|
-
const event = parseHarnessEvent(run2.harness, line);
|
|
1004
|
-
if (event.kind === "activity") run2.onProgress?.(event.text);
|
|
1005
|
-
if (event.kind === "final") finalText = event.text;
|
|
1006
|
-
if (event.kind === "model") model = event.model;
|
|
1007
|
-
if (event.kind === "error") lastError = event.message;
|
|
1008
|
-
};
|
|
1009
|
-
child.stdout.setEncoding("utf8");
|
|
1010
|
-
child.stderr.setEncoding("utf8");
|
|
1011
|
-
child.stdout.on("data", (chunk) => {
|
|
1012
|
-
const lines = (pending + chunk).split("\n");
|
|
1013
|
-
pending = lines.pop() ?? "";
|
|
1014
|
-
lines.forEach(onLine);
|
|
1015
|
-
});
|
|
1016
|
-
child.stderr.on("data", (chunk) => stderr += chunk);
|
|
1017
|
-
child.stdin.on("error", () => {
|
|
1018
|
-
});
|
|
1019
|
-
child.stdin.end(run2.prompt);
|
|
1020
|
-
child.on("error", (error) => resolve({ ok: false, error: spawnError(run2.harness, error) }));
|
|
1021
|
-
child.on("close", (code) => {
|
|
1022
|
-
onLine(pending);
|
|
1023
|
-
if (code !== 0)
|
|
1024
|
-
return resolve({ ok: false, error: exitError(run2.harness, code, [stderr || finalText, lastError].filter(Boolean).join("\n")) });
|
|
1025
|
-
if (run2.harness === "claude") writeFileSync3(run2.outputPath, finalText);
|
|
1026
|
-
if (!hasReview(run2.outputPath)) return resolve({ ok: false, error: `${run2.harness} returned an empty review` });
|
|
1027
|
-
resolve(model ? { ok: true, model } : { ok: true });
|
|
1028
|
-
});
|
|
1029
|
-
});
|
|
1030
|
-
|
|
1031
|
-
// src/features/review/comments.ts
|
|
1032
|
-
var markerPrefix = "<!-- yoke-review:";
|
|
1033
|
-
function slug(text2) {
|
|
1034
|
-
return text2.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
1035
|
-
}
|
|
1036
|
-
function findingMarker({ file, title }) {
|
|
1037
|
-
return `${markerPrefix} ${file ?? "n/a"}#${slug(title)} -->`;
|
|
1061
|
+
async function addComments(review, comments, { api, log }) {
|
|
1062
|
+
const rejected = [];
|
|
1063
|
+
for (const { finding, ...comment } of comments) {
|
|
1064
|
+
try {
|
|
1065
|
+
await api.add(review, comment);
|
|
1066
|
+
} catch (error) {
|
|
1067
|
+
if (!(error instanceof ReviewError)) throw error;
|
|
1068
|
+
rejected.push(finding);
|
|
1069
|
+
log(`GitHub rejected the comment on ${comment.path}:${comment.line}, so it goes in the review body: ${error.message}`);
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
return rejected;
|
|
1038
1073
|
}
|
|
1039
|
-
function
|
|
1040
|
-
|
|
1074
|
+
function failedReviewBody(reason, runUrl, footer = "") {
|
|
1075
|
+
const sections = ["## Code review failed", `The review did not complete: ${reason}. No findings were posted.`];
|
|
1076
|
+
if (runUrl) sections.push(`See the [workflow run](${runUrl}) for details.`);
|
|
1077
|
+
if (footer) sections.push(footer);
|
|
1078
|
+
return sections.join("\n\n");
|
|
1041
1079
|
}
|
|
1042
|
-
function
|
|
1043
|
-
|
|
1080
|
+
async function postFailure(pr2, reason, runUrl, deps) {
|
|
1081
|
+
const review = await deps.api.open(pr2);
|
|
1082
|
+
try {
|
|
1083
|
+
const url = await deps.api.submit(pr2, review, failedReviewBody(reason, runUrl, postFooter(deps.footer, true)));
|
|
1084
|
+
deps.log(`posted the failed review: ${url}`);
|
|
1085
|
+
} catch (error) {
|
|
1086
|
+
await discard(pr2, review, deps);
|
|
1087
|
+
throw error;
|
|
1088
|
+
}
|
|
1044
1089
|
}
|
|
1045
|
-
function
|
|
1046
|
-
|
|
1047
|
-
|
|
1090
|
+
async function discard(pr2, review, { api, log }) {
|
|
1091
|
+
try {
|
|
1092
|
+
await api.discard(pr2, review);
|
|
1093
|
+
} catch (discardError) {
|
|
1094
|
+
log(`could not discard the pending review: ${discardError instanceof Error ? discardError.message : String(discardError)}`);
|
|
1095
|
+
}
|
|
1048
1096
|
}
|
|
1049
|
-
function
|
|
1050
|
-
|
|
1051
|
-
const
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
return
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1097
|
+
async function postFindings(pr2, synthesis, diff, prior, deps) {
|
|
1098
|
+
const { api, log, ci = false } = deps;
|
|
1099
|
+
const { comments, outside, skipped } = planPost(synthesis, diff, prior);
|
|
1100
|
+
if (skipped.length) log(`skipped ${skipped.length} finding(s) already raised on the PR`);
|
|
1101
|
+
const nothingNew = !comments.length && !outside.length;
|
|
1102
|
+
if (nothingNew && !ci) return log("nothing new to post");
|
|
1103
|
+
const footer = postFooter(deps.footer, ci);
|
|
1104
|
+
const review = await api.open(pr2);
|
|
1105
|
+
try {
|
|
1106
|
+
if (nothingNew) {
|
|
1107
|
+
const url2 = await api.submit(pr2, review, reviewBody(synthesis.summary, [], footer, noFindingsNote));
|
|
1108
|
+
return log(`nothing new to post, so posted a review without findings: ${url2}`);
|
|
1109
|
+
}
|
|
1110
|
+
const rejected = await addComments(review, comments, deps);
|
|
1111
|
+
const url = await api.submit(pr2, review, reviewBody(synthesis.summary, [...outside, ...rejected], footer));
|
|
1112
|
+
log(`posted ${comments.length - rejected.length} inline comment(s) and ${outside.length + rejected.length} in the review body: ${url}`);
|
|
1113
|
+
} catch (error) {
|
|
1114
|
+
await discard(pr2, review, deps);
|
|
1115
|
+
throw error;
|
|
1116
|
+
}
|
|
1060
1117
|
}
|
|
1061
1118
|
|
|
1062
|
-
// src/features/review/
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
}
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
const code = escape.slice(1);
|
|
1078
|
-
if (/^[0-7]{3}$/.test(code)) return Buffer.from([parseInt(code, 8)]);
|
|
1079
|
-
return Buffer.from([escapes[code] ?? code.charCodeAt(0)]);
|
|
1080
|
-
}
|
|
1081
|
-
function unquote(quoted) {
|
|
1082
|
-
const parts = quoted.split(/(\\[0-7]{3}|\\.)/);
|
|
1083
|
-
return Buffer.concat(parts.map((part, i) => i % 2 ? escapedBytes(part) : Buffer.from(part))).toString("utf8");
|
|
1119
|
+
// src/features/review/status.ts
|
|
1120
|
+
import { styleText } from "util";
|
|
1121
|
+
import { createLogUpdate } from "log-update";
|
|
1122
|
+
async function timed(ui, label, work) {
|
|
1123
|
+
const started = Date.now();
|
|
1124
|
+
const status = ui.track(label);
|
|
1125
|
+
const elapsed = () => `${Math.round((Date.now() - started) / 1e3)}s`;
|
|
1126
|
+
try {
|
|
1127
|
+
const result = await work();
|
|
1128
|
+
status.succeed(`${label} (${elapsed()})`);
|
|
1129
|
+
return result;
|
|
1130
|
+
} catch (error) {
|
|
1131
|
+
status.fail(`${label} failed after ${elapsed()}`);
|
|
1132
|
+
throw error;
|
|
1133
|
+
}
|
|
1084
1134
|
}
|
|
1085
|
-
function
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1135
|
+
function lineUi(log, { activity }) {
|
|
1136
|
+
return {
|
|
1137
|
+
log,
|
|
1138
|
+
track: (label) => {
|
|
1139
|
+
log(`${label}\u2026`);
|
|
1140
|
+
const update = (text2) => activity ? log(`${label}: ${text2}`) : void 0;
|
|
1141
|
+
return { update, succeed: log, fail: log };
|
|
1142
|
+
},
|
|
1143
|
+
stop: () => {
|
|
1144
|
+
}
|
|
1145
|
+
};
|
|
1089
1146
|
}
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1147
|
+
var frames = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
|
|
1148
|
+
var tickMs = 80;
|
|
1149
|
+
function spinnerUi(stream) {
|
|
1150
|
+
const update = createLogUpdate(stream);
|
|
1151
|
+
const running = [];
|
|
1152
|
+
let frame = 0;
|
|
1153
|
+
let timer;
|
|
1154
|
+
const paint = (colour, text2) => styleText(colour, text2, { stream });
|
|
1155
|
+
const render = () => update(running.map((entry) => `${paint("cyan", frames[frame] ?? "")} ${entry.text}`).join("\n"));
|
|
1156
|
+
const persist = (line) => {
|
|
1157
|
+
update.clear();
|
|
1158
|
+
update.persist(line);
|
|
1159
|
+
render();
|
|
1160
|
+
};
|
|
1161
|
+
const settle = (entry, line) => {
|
|
1162
|
+
running.splice(running.indexOf(entry), 1);
|
|
1163
|
+
persist(line);
|
|
1164
|
+
if (running.length || !timer) return;
|
|
1165
|
+
clearInterval(timer);
|
|
1166
|
+
timer = void 0;
|
|
1167
|
+
};
|
|
1168
|
+
return {
|
|
1169
|
+
log: persist,
|
|
1170
|
+
track: (label) => {
|
|
1171
|
+
const entry = { label, text: label };
|
|
1172
|
+
running.push(entry);
|
|
1173
|
+
timer ??= setInterval(() => {
|
|
1174
|
+
frame = (frame + 1) % frames.length;
|
|
1175
|
+
render();
|
|
1176
|
+
}, tickMs);
|
|
1177
|
+
render();
|
|
1178
|
+
return {
|
|
1179
|
+
update: (activity) => entry.text = `${label} \u2014 ${activity}`,
|
|
1180
|
+
succeed: (text2) => settle(entry, `${paint("green", "\u2713")} ${text2}`),
|
|
1181
|
+
fail: (text2) => settle(entry, `${paint("red", "\u2717")} ${text2}`)
|
|
1182
|
+
};
|
|
1183
|
+
},
|
|
1184
|
+
stop: () => {
|
|
1185
|
+
if (timer) clearInterval(timer);
|
|
1186
|
+
update.done();
|
|
1187
|
+
}
|
|
1188
|
+
};
|
|
1101
1189
|
}
|
|
1102
|
-
|
|
1103
|
-
|
|
1190
|
+
|
|
1191
|
+
// src/features/review/ciRun.ts
|
|
1192
|
+
function isCi(env = process.env) {
|
|
1193
|
+
return env.GITHUB_ACTIONS === "true";
|
|
1104
1194
|
}
|
|
1105
|
-
function
|
|
1106
|
-
|
|
1195
|
+
function ciRunUrl(env = process.env) {
|
|
1196
|
+
const { GITHUB_SERVER_URL, GITHUB_REPOSITORY, GITHUB_RUN_ID } = env;
|
|
1197
|
+
if (!GITHUB_SERVER_URL || !GITHUB_REPOSITORY || !GITHUB_RUN_ID) return void 0;
|
|
1198
|
+
return `${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}`;
|
|
1107
1199
|
}
|
|
1108
|
-
function
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
lines.set(file, numbers.add(line));
|
|
1200
|
+
async function reportFailure(pr2, reason, ui, deps) {
|
|
1201
|
+
try {
|
|
1202
|
+
await timed(ui, `posting the failed review to #${pr2.number}`, () => postFailure(pr2, reason, ciRunUrl(), deps));
|
|
1203
|
+
} catch (error) {
|
|
1204
|
+
ui.log(`could not post the failed review: ${error instanceof Error ? error.message : String(error)}`);
|
|
1114
1205
|
}
|
|
1115
|
-
return lines;
|
|
1116
1206
|
}
|
|
1117
|
-
function buildRequest(pr2, diff, prior = []) {
|
|
1118
|
-
const files = pr2.files.map((f) => `- ${f.path}`).join("\n") || "(none)";
|
|
1119
|
-
const priorSection = formatPriorComments(prior);
|
|
1120
|
-
return `# Code review request
|
|
1121
1207
|
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
## Changed files
|
|
1127
|
-
|
|
1128
|
-
${files}
|
|
1129
|
-
|
|
1130
|
-
## Diff
|
|
1131
|
-
|
|
1132
|
-
Each added or context line is prefixed with a left gutter showing its line number in the new file. When citing a finding as \`file:line\`, use that gutter number \u2014 do **not** count lines in this document. Removed lines (\`-\`) and headers have a blank gutter and cannot be commented on.
|
|
1208
|
+
// src/features/review/harness.ts
|
|
1209
|
+
import { spawn } from "child_process";
|
|
1210
|
+
import { existsSync as existsSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "fs";
|
|
1133
1211
|
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1212
|
+
// src/features/review/usage.ts
|
|
1213
|
+
import { appendFileSync } from "fs";
|
|
1214
|
+
var fields2 = ["input", "output", "cacheRead", "cacheWrite"];
|
|
1215
|
+
var rows = [
|
|
1216
|
+
["reviewer1", "Reviewer 1"],
|
|
1217
|
+
["reviewer2", "Reviewer 2"],
|
|
1218
|
+
["synthesis", "Synthesis"]
|
|
1219
|
+
];
|
|
1220
|
+
var dash = "\u2014";
|
|
1221
|
+
function count(source, key) {
|
|
1222
|
+
const value = key ? source[key] : void 0;
|
|
1223
|
+
return typeof value === "number" && Number.isFinite(value) ? value : void 0;
|
|
1139
1224
|
}
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
function isAlreadyRaised(finding, prior) {
|
|
1143
|
-
return finding.source === "already-raised" || hasMarker(prior, findingMarker(finding));
|
|
1225
|
+
function isRecord(value) {
|
|
1226
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1144
1227
|
}
|
|
1145
|
-
function
|
|
1146
|
-
return
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1228
|
+
function parseUsage(raw, keys) {
|
|
1229
|
+
if (!isRecord(raw)) return void 0;
|
|
1230
|
+
const usage = {};
|
|
1231
|
+
for (const field2 of fields2) {
|
|
1232
|
+
const value = count(raw, keys[field2]);
|
|
1233
|
+
if (value !== void 0) usage[field2] = value;
|
|
1234
|
+
}
|
|
1235
|
+
return Object.keys(usage).length ? usage : void 0;
|
|
1150
1236
|
}
|
|
1151
|
-
function
|
|
1152
|
-
|
|
1237
|
+
function sum(values) {
|
|
1238
|
+
const reported = values.filter((value) => value !== void 0);
|
|
1239
|
+
return reported.length ? reported.reduce((a, b) => a + b, 0) : void 0;
|
|
1153
1240
|
}
|
|
1154
|
-
function
|
|
1155
|
-
const
|
|
1156
|
-
const
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
if (isAlreadyRaised(finding, prior)) plan.skipped.push(finding);
|
|
1160
|
-
else if (file && line !== null && lines.get(file)?.has(line))
|
|
1161
|
-
plan.comments.push({ path: file, line, body: commentBody(finding), finding });
|
|
1162
|
-
else plan.outside.push(finding);
|
|
1241
|
+
function sumUsage(usages) {
|
|
1242
|
+
const total = {};
|
|
1243
|
+
for (const field2 of fields2) {
|
|
1244
|
+
const value = sum(usages.map((usage) => usage?.[field2]));
|
|
1245
|
+
if (value !== void 0) total[field2] = value;
|
|
1163
1246
|
}
|
|
1164
|
-
return
|
|
1247
|
+
return total;
|
|
1165
1248
|
}
|
|
1166
|
-
function
|
|
1167
|
-
|
|
1168
|
-
const entry = [`- **${finding.severity ?? "finding"}** ${location} \u2014 ${finding.title}`, ...details(finding).map((d) => ` ${d}`)];
|
|
1169
|
-
return [...entry, ` ${findingMarker(finding)}`].join("\n");
|
|
1249
|
+
function addUsage(a, b) {
|
|
1250
|
+
return sumUsage([a, b]);
|
|
1170
1251
|
}
|
|
1171
|
-
function
|
|
1172
|
-
|
|
1173
|
-
if (outside.length) sections.push(`### Findings outside the diff
|
|
1174
|
-
|
|
1175
|
-
${outside.map(outsideEntry).join("\n")}`);
|
|
1176
|
-
if (footer) sections.push(footer);
|
|
1177
|
-
return sections.join("\n\n");
|
|
1252
|
+
function cell(value) {
|
|
1253
|
+
return value === void 0 ? dash : value.toLocaleString("en-US");
|
|
1178
1254
|
}
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
for (const { finding, ...comment } of comments) {
|
|
1182
|
-
try {
|
|
1183
|
-
await api.add(review, comment);
|
|
1184
|
-
} catch (error) {
|
|
1185
|
-
if (!(error instanceof ReviewError)) throw error;
|
|
1186
|
-
rejected.push(finding);
|
|
1187
|
-
log(`GitHub rejected the comment on ${comment.path}:${comment.line}, so it goes in the review body: ${error.message}`);
|
|
1188
|
-
}
|
|
1189
|
-
}
|
|
1190
|
-
return rejected;
|
|
1255
|
+
function row(label, usage) {
|
|
1256
|
+
return `| ${label} | ${fields2.map((field2) => cell(usage?.[field2])).join(" | ")} |`;
|
|
1191
1257
|
}
|
|
1192
|
-
|
|
1193
|
-
const
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1258
|
+
function usageTable(usage) {
|
|
1259
|
+
const total = sumUsage(rows.map(([step]) => usage[step]));
|
|
1260
|
+
return [
|
|
1261
|
+
"## Token usage",
|
|
1262
|
+
"",
|
|
1263
|
+
"| Slot | Input | Output | Cache read | Cache write |",
|
|
1264
|
+
"| --- | ---: | ---: | ---: | ---: |",
|
|
1265
|
+
...rows.map(([step, label]) => row(label, usage[step])),
|
|
1266
|
+
row("**Total**", total)
|
|
1267
|
+
].join("\n");
|
|
1268
|
+
}
|
|
1269
|
+
function jobSummary(summary, usage) {
|
|
1270
|
+
return ["## Adversarial review", summary || "No summary was given.", usageTable(usage)].join("\n\n");
|
|
1271
|
+
}
|
|
1272
|
+
function writeJobSummary(summary, usage, log, env = process.env) {
|
|
1273
|
+
const path = env.GITHUB_STEP_SUMMARY;
|
|
1274
|
+
if (!path) return;
|
|
1198
1275
|
try {
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
log(
|
|
1276
|
+
appendFileSync(path, `${jobSummary(summary, usage)}
|
|
1277
|
+
`);
|
|
1278
|
+
log("wrote the review summary and token usage to the job summary");
|
|
1202
1279
|
} catch (error) {
|
|
1203
|
-
|
|
1204
|
-
await api.discard(pr2, review);
|
|
1205
|
-
} catch (discardError) {
|
|
1206
|
-
log(`could not discard the pending review: ${discardError instanceof Error ? discardError.message : String(discardError)}`);
|
|
1207
|
-
}
|
|
1208
|
-
throw error;
|
|
1280
|
+
log(`could not write the job summary: ${error instanceof Error ? error.message : String(error)}`);
|
|
1209
1281
|
}
|
|
1210
1282
|
}
|
|
1211
1283
|
|
|
1212
|
-
// src/features/review/
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
function
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1284
|
+
// src/features/review/progress.ts
|
|
1285
|
+
var maxSummary = 80;
|
|
1286
|
+
function isJson(value) {
|
|
1287
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1288
|
+
}
|
|
1289
|
+
function truncate(value) {
|
|
1290
|
+
const collapsed = value.replace(/\s+/g, " ").trim();
|
|
1291
|
+
return collapsed.length <= maxSummary ? collapsed : `${collapsed.slice(0, maxSummary - 1)}\u2026`;
|
|
1292
|
+
}
|
|
1293
|
+
function text(input, key) {
|
|
1294
|
+
const value = input[key];
|
|
1295
|
+
return typeof value === "string" ? value : void 0;
|
|
1296
|
+
}
|
|
1297
|
+
function toolSummary(input) {
|
|
1298
|
+
if (!isJson(input)) return "";
|
|
1299
|
+
const pattern = text(input, "pattern");
|
|
1300
|
+
if (pattern) {
|
|
1301
|
+
const scope = text(input, "path");
|
|
1302
|
+
return scope ? `${pattern} in ${scope}` : pattern;
|
|
1303
|
+
}
|
|
1304
|
+
const found = text(input, "file_path") ?? text(input, "path") ?? text(input, "url");
|
|
1305
|
+
if (found) return found;
|
|
1306
|
+
const described = text(input, "command") ?? text(input, "query") ?? text(input, "description");
|
|
1307
|
+
return described ? truncate(described) : "";
|
|
1308
|
+
}
|
|
1309
|
+
function describeTool(tool, summary) {
|
|
1310
|
+
return summary ? `${tool} ${summary}` : tool;
|
|
1311
|
+
}
|
|
1312
|
+
function claudeUsage(raw) {
|
|
1313
|
+
return parseUsage(raw, {
|
|
1314
|
+
input: "input_tokens",
|
|
1315
|
+
output: "output_tokens",
|
|
1316
|
+
cacheRead: "cache_read_input_tokens",
|
|
1317
|
+
cacheWrite: "cache_creation_input_tokens"
|
|
1221
1318
|
});
|
|
1222
1319
|
}
|
|
1223
|
-
|
|
1224
|
-
const
|
|
1225
|
-
if (
|
|
1320
|
+
function codexUsage(raw) {
|
|
1321
|
+
const usage = parseUsage(raw, { input: "input_tokens", output: "output_tokens", cacheRead: "cached_input_tokens" });
|
|
1322
|
+
if (usage?.input !== void 0 && usage.cacheRead !== void 0) usage.input = Math.max(0, usage.input - usage.cacheRead);
|
|
1323
|
+
return usage;
|
|
1324
|
+
}
|
|
1325
|
+
function claudeResult(event) {
|
|
1326
|
+
const usage = claudeUsage(event.usage);
|
|
1327
|
+
if (typeof event.result === "string") return usage ? { kind: "final", text: event.result, usage } : { kind: "final", text: event.result };
|
|
1328
|
+
return usage ? { kind: "usage", usage } : { kind: "ignore" };
|
|
1329
|
+
}
|
|
1330
|
+
function claudeEvent(event) {
|
|
1331
|
+
if (event.type === "result") return claudeResult(event);
|
|
1332
|
+
if (event.type === "system" && event.subtype === "init" && typeof event.model === "string") return { kind: "model", model: event.model };
|
|
1333
|
+
const content = isJson(event.message) ? event.message.content : void 0;
|
|
1334
|
+
if (event.type !== "assistant" || !Array.isArray(content)) return { kind: "ignore" };
|
|
1335
|
+
const uses = content.filter((block) => isJson(block) && block.type === "tool_use");
|
|
1336
|
+
if (!uses.length) return { kind: "ignore" };
|
|
1337
|
+
return { kind: "activity", text: uses.map((use) => describeTool(text(use, "name") ?? "tool", toolSummary(use.input))).join(", ") };
|
|
1338
|
+
}
|
|
1339
|
+
function unwrapShell(command) {
|
|
1340
|
+
const wrapped = command.match(/^\/bin\/(?:ba|z)?sh\s+-lc\s+["'](.*)["']\s*$/s);
|
|
1341
|
+
return truncate(wrapped?.[1]?.replace(/\\"/g, '"') ?? command);
|
|
1342
|
+
}
|
|
1343
|
+
function codexError(event) {
|
|
1344
|
+
if (event.type === "error") return text(event, "message");
|
|
1345
|
+
if (event.type === "turn.failed" && isJson(event.error)) return text(event.error, "message");
|
|
1346
|
+
return void 0;
|
|
1347
|
+
}
|
|
1348
|
+
function codexEvent(event) {
|
|
1349
|
+
const message = codexError(event);
|
|
1350
|
+
if (message) return { kind: "error", message };
|
|
1351
|
+
const usage = event.type === "turn.completed" ? codexUsage(event.usage) : void 0;
|
|
1352
|
+
if (usage) return { kind: "usage", usage };
|
|
1353
|
+
const item = event.item;
|
|
1354
|
+
if (event.type !== "item.started" || !isJson(item)) return { kind: "ignore" };
|
|
1355
|
+
const command = text(item, "command");
|
|
1356
|
+
if (item.type === "command_execution" && command) return { kind: "activity", text: describeTool("shell", unwrapShell(command)) };
|
|
1357
|
+
return { kind: "ignore" };
|
|
1358
|
+
}
|
|
1359
|
+
function parseHarnessEvent(harness, line) {
|
|
1360
|
+
let event;
|
|
1226
1361
|
try {
|
|
1227
|
-
|
|
1228
|
-
return typeof parsed === "object" && parsed !== null && "loggedIn" in parsed && parsed.loggedIn === true;
|
|
1362
|
+
event = JSON.parse(line);
|
|
1229
1363
|
} catch {
|
|
1230
|
-
return
|
|
1364
|
+
return { kind: "ignore" };
|
|
1231
1365
|
}
|
|
1366
|
+
if (!isJson(event)) return { kind: "ignore" };
|
|
1367
|
+
return harness === "claude" ? claudeEvent(event) : codexEvent(event);
|
|
1232
1368
|
}
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1369
|
+
|
|
1370
|
+
// src/features/review/harness.ts
|
|
1371
|
+
var tailLines = 20;
|
|
1372
|
+
function harnessArgs({ harness, reviewDir, outputPath, override }) {
|
|
1373
|
+
const extra = override?.args ?? [];
|
|
1374
|
+
if (harness === "codex") return ["exec", "--sandbox", "read-only", "--json", "--output-last-message", outputPath, ...extra, "-"];
|
|
1375
|
+
return [
|
|
1376
|
+
"-p",
|
|
1377
|
+
"--add-dir",
|
|
1378
|
+
reviewDir,
|
|
1379
|
+
"--disallowedTools",
|
|
1380
|
+
"Edit,Write,NotebookEdit",
|
|
1381
|
+
"--output-format",
|
|
1382
|
+
"stream-json",
|
|
1383
|
+
"--verbose",
|
|
1384
|
+
...extra
|
|
1385
|
+
];
|
|
1249
1386
|
}
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
signedIn: async (harness) => harness === "claude" ? claudeSignedIn() : (await exec("codex", ["login", "status"])).ok,
|
|
1253
|
-
reach
|
|
1254
|
-
};
|
|
1255
|
-
var loginCommand = { claude: "claude auth login", codex: "codex login" };
|
|
1256
|
-
function signedOut({ slot: { harness }, keys }) {
|
|
1257
|
-
const settings = keys.map((key) => `${key}.provider and ${key}.model`).join(", and ");
|
|
1258
|
-
return `${harness} is not signed in (${keys.join(", ")}); run \`${loginCommand[harness]}\`, or run it through LiteLLM or Foundry by setting ${settings} (see \`yoke models\`)`;
|
|
1387
|
+
function windowsQuote(arg) {
|
|
1388
|
+
return /[\s"]/.test(arg) ? `"${arg.replace(/"/g, '\\"')}"` : arg;
|
|
1259
1389
|
}
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
if (!await check.onPath(harness)) return `${harness} CLI not found on PATH; install it`;
|
|
1263
|
-
if (!probe) return await check.signedIn(harness) ? void 0 : signedOut(use);
|
|
1264
|
-
const error = await check.reach(probe);
|
|
1265
|
-
return error && `${harness} model "${model}" at ${probe.url}: ${error}`;
|
|
1390
|
+
function tail(text2) {
|
|
1391
|
+
return text2.trim().split("\n").slice(-tailLines).join("\n");
|
|
1266
1392
|
}
|
|
1267
|
-
function
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
["review.reviewer2", slots2.reviewer2],
|
|
1271
|
-
["review.synthesis", slots2.synthesis ?? { harness: slots2.reviewer1.harness }]
|
|
1272
|
-
];
|
|
1273
|
-
const distinct = /* @__PURE__ */ new Map();
|
|
1274
|
-
for (const [key, slot] of uses) {
|
|
1275
|
-
const id = `${slot.harness}:${slot.provider ?? "own"}:${slot.model ?? ""}`;
|
|
1276
|
-
const use = distinct.get(id);
|
|
1277
|
-
if (use) use.keys.push(key);
|
|
1278
|
-
else distinct.set(id, { slot, keys: [key] });
|
|
1279
|
-
}
|
|
1280
|
-
return [...distinct.values()];
|
|
1393
|
+
function spawnError(harness, error) {
|
|
1394
|
+
if (error.code === "ENOENT") return `${harness} CLI not found on PATH; install it or sign in, then retry`;
|
|
1395
|
+
return `${harness} failed to start: ${error.message}`;
|
|
1281
1396
|
}
|
|
1282
|
-
|
|
1283
|
-
const
|
|
1284
|
-
|
|
1285
|
-
if (errors.length) throw new ReviewError(["Cannot review:", ...errors.map((error) => `- ${error}`)].join("\n"));
|
|
1397
|
+
function exitError(harness, code, output) {
|
|
1398
|
+
const detail = tail(output);
|
|
1399
|
+
return [`${harness} exited with code ${code ?? "unknown"}`, ...detail ? [detail] : []].join("\n");
|
|
1286
1400
|
}
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
import { appendFileSync, mkdirSync as mkdirSync3 } from "fs";
|
|
1290
|
-
import { join as join4 } from "path";
|
|
1291
|
-
function reviewLog(command = process.argv.slice(2), now = () => /* @__PURE__ */ new Date()) {
|
|
1292
|
-
let path;
|
|
1293
|
-
let buffered = [];
|
|
1294
|
-
return {
|
|
1295
|
-
append: (line) => {
|
|
1296
|
-
if (path) appendFileSync(path, `${line}
|
|
1297
|
-
`);
|
|
1298
|
-
else buffered.push(line);
|
|
1299
|
-
},
|
|
1300
|
-
attach: (reviewDir) => {
|
|
1301
|
-
mkdirSync3(reviewDir, { recursive: true });
|
|
1302
|
-
path = join4(reviewDir, "review.log");
|
|
1303
|
-
appendFileSync(path, `${["", `=== ${now().toISOString()} ===`, `$ yoke ${command.join(" ")}`, ...buffered].join("\n")}
|
|
1304
|
-
`);
|
|
1305
|
-
buffered = [];
|
|
1306
|
-
}
|
|
1307
|
-
};
|
|
1401
|
+
function hasReview(path) {
|
|
1402
|
+
return existsSync3(path) && readFileSync3(path, "utf8").trim().length > 0;
|
|
1308
1403
|
}
|
|
1309
|
-
|
|
1310
|
-
const
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1404
|
+
var runHarness = (run2) => new Promise((resolve) => {
|
|
1405
|
+
const shell = process.platform === "win32";
|
|
1406
|
+
const args = harnessArgs(run2);
|
|
1407
|
+
const child = spawn(run2.harness, shell ? args.map(windowsQuote) : args, {
|
|
1408
|
+
cwd: run2.cwd,
|
|
1409
|
+
env: { ...process.env, ...run2.override?.env },
|
|
1410
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
1411
|
+
shell
|
|
1412
|
+
});
|
|
1413
|
+
let pending = "";
|
|
1414
|
+
let finalText = "";
|
|
1415
|
+
let model;
|
|
1416
|
+
let usage;
|
|
1417
|
+
let lastError = "";
|
|
1418
|
+
let stderr = "";
|
|
1419
|
+
const onLine = (line) => {
|
|
1420
|
+
const event = parseHarnessEvent(run2.harness, line);
|
|
1421
|
+
if (event.kind === "activity") run2.onProgress?.(event.text);
|
|
1422
|
+
if (event.kind === "final") {
|
|
1423
|
+
finalText = event.text;
|
|
1424
|
+
if (event.usage) usage = event.usage;
|
|
1425
|
+
}
|
|
1426
|
+
if (event.kind === "usage") usage = addUsage(usage, event.usage);
|
|
1427
|
+
if (event.kind === "model") model = event.model;
|
|
1428
|
+
if (event.kind === "error") lastError = event.message;
|
|
1329
1429
|
};
|
|
1330
|
-
|
|
1430
|
+
child.stdout.setEncoding("utf8");
|
|
1431
|
+
child.stderr.setEncoding("utf8");
|
|
1432
|
+
child.stdout.on("data", (chunk) => {
|
|
1433
|
+
const lines = (pending + chunk).split("\n");
|
|
1434
|
+
pending = lines.pop() ?? "";
|
|
1435
|
+
lines.forEach(onLine);
|
|
1436
|
+
});
|
|
1437
|
+
child.stderr.on("data", (chunk) => stderr += chunk);
|
|
1438
|
+
child.stdin.on("error", () => {
|
|
1439
|
+
});
|
|
1440
|
+
child.stdin.end(run2.prompt);
|
|
1441
|
+
child.on("error", (error) => resolve({ ok: false, error: spawnError(run2.harness, error) }));
|
|
1442
|
+
child.on("close", (code) => {
|
|
1443
|
+
onLine(pending);
|
|
1444
|
+
if (code !== 0)
|
|
1445
|
+
return resolve({ ok: false, error: exitError(run2.harness, code, [stderr || finalText, lastError].filter(Boolean).join("\n")) });
|
|
1446
|
+
if (run2.harness === "claude") writeFileSync3(run2.outputPath, finalText);
|
|
1447
|
+
if (!hasReview(run2.outputPath)) return resolve({ ok: false, error: `${run2.harness} returned an empty review` });
|
|
1448
|
+
resolve({ ok: true, ...model ? { model } : {}, ...usage ? { usage } : {} });
|
|
1449
|
+
});
|
|
1450
|
+
});
|
|
1451
|
+
|
|
1452
|
+
// src/features/review/preflight.ts
|
|
1453
|
+
import { execFile as execFile2 } from "child_process";
|
|
1331
1454
|
|
|
1332
1455
|
// src/features/review/run.ts
|
|
1333
1456
|
import { existsSync as existsSync6, readFileSync as readFileSync6, rmSync, writeFileSync as writeFileSync6 } from "fs";
|
|
1334
|
-
import { join as
|
|
1457
|
+
import { join as join6 } from "path";
|
|
1335
1458
|
|
|
1336
1459
|
// src/features/review/cachedSlots.ts
|
|
1337
1460
|
import { existsSync as existsSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync4 } from "fs";
|
|
1338
|
-
import { join as
|
|
1461
|
+
import { join as join4 } from "path";
|
|
1339
1462
|
function slotsPath(reviewDir) {
|
|
1340
|
-
return
|
|
1463
|
+
return join4(reviewDir, "slots.json");
|
|
1341
1464
|
}
|
|
1342
1465
|
function isCachedSlots(value) {
|
|
1343
1466
|
return typeof value === "object" && value !== null && Object.values(value).every((slot) => typeof slot === "string");
|
|
@@ -1500,10 +1623,10 @@ ${files.join("\n")}
|
|
|
1500
1623
|
|
|
1501
1624
|
// src/features/review/usedModels.ts
|
|
1502
1625
|
import { existsSync as existsSync5, readFileSync as readFileSync5, writeFileSync as writeFileSync5 } from "fs";
|
|
1503
|
-
import { join as
|
|
1626
|
+
import { join as join5 } from "path";
|
|
1504
1627
|
var repoUrl = "https://github.com/MakerXStudio/yoke";
|
|
1505
1628
|
function modelsPath(reviewDir) {
|
|
1506
|
-
return
|
|
1629
|
+
return join5(reviewDir, "models.json");
|
|
1507
1630
|
}
|
|
1508
1631
|
function isUsedModels(value) {
|
|
1509
1632
|
return typeof value === "object" && value !== null && Object.values(value).every((model) => typeof model === "string");
|
|
@@ -1538,6 +1661,17 @@ _Adversarial review by [yoke](${repoUrl})${used.length ? `: ${used.join("; ")}`
|
|
|
1538
1661
|
}
|
|
1539
1662
|
|
|
1540
1663
|
// src/features/review/run.ts
|
|
1664
|
+
var ReviewFailure = class extends ReviewError {
|
|
1665
|
+
constructor(reason, message) {
|
|
1666
|
+
super(message);
|
|
1667
|
+
this.reason = reason;
|
|
1668
|
+
this.name = "ReviewFailure";
|
|
1669
|
+
}
|
|
1670
|
+
reason;
|
|
1671
|
+
};
|
|
1672
|
+
function stepNames(steps) {
|
|
1673
|
+
return steps.map((step) => step.replace(/(\d)$/, " $1")).join(" and ");
|
|
1674
|
+
}
|
|
1541
1675
|
var reviewers = ["reviewer1", "reviewer2"];
|
|
1542
1676
|
function seconds(since) {
|
|
1543
1677
|
return `${Math.round((Date.now() - since) / 1e3)}s`;
|
|
@@ -1561,6 +1695,7 @@ async function runStep({ name, label, slot, ...step }, context) {
|
|
|
1561
1695
|
status.succeed(`${label} done in ${seconds(started)}`);
|
|
1562
1696
|
recordUsedModel(context.reviewDir, name, harness, slot.model ?? result.model);
|
|
1563
1697
|
recordSlot(context.reviewDir, name, describeSlot(slot));
|
|
1698
|
+
if (result.usage) context.usage[name] = result.usage;
|
|
1564
1699
|
return true;
|
|
1565
1700
|
}
|
|
1566
1701
|
status.fail(`${label} failed after ${seconds(started)}: ${result.error}`);
|
|
@@ -1578,7 +1713,7 @@ async function runOrReuse(step, context) {
|
|
|
1578
1713
|
return { ok: true, ran: false };
|
|
1579
1714
|
}
|
|
1580
1715
|
function reviewPath(context, reviewer) {
|
|
1581
|
-
return
|
|
1716
|
+
return join6(context.reviewDir, `${reviewer}.md`);
|
|
1582
1717
|
}
|
|
1583
1718
|
function reviewerLabel(reviewer, context) {
|
|
1584
1719
|
return `${reviewer} (${describeSlot(context.slots[reviewer])})`;
|
|
@@ -1587,7 +1722,7 @@ function withReviewers(result, context) {
|
|
|
1587
1722
|
const line = reviewers.map((reviewer) => reviewerLabel(reviewer, context)).join(", ");
|
|
1588
1723
|
return { ...result, report: `Reviewers: ${line}
|
|
1589
1724
|
|
|
1590
|
-
${result.report}
|
|
1725
|
+
${result.report}`, usage: context.usage };
|
|
1591
1726
|
}
|
|
1592
1727
|
function synthesisResult(markdown) {
|
|
1593
1728
|
const synthesis = parseSynthesis(markdown);
|
|
@@ -1606,7 +1741,7 @@ function synthesisSlot(succeeded, context) {
|
|
|
1606
1741
|
}
|
|
1607
1742
|
async function synthesise(requestPath, succeeded, reviewersRan, context) {
|
|
1608
1743
|
const slot = synthesisSlot(succeeded, context);
|
|
1609
|
-
const outputPath =
|
|
1744
|
+
const outputPath = join6(context.reviewDir, "synthesis.md");
|
|
1610
1745
|
if (reviewersRan) rmSync(outputPath, { force: true });
|
|
1611
1746
|
const files = reviewers.map((reviewer) => ({
|
|
1612
1747
|
reviewer: reviewerLabel(reviewer, context),
|
|
@@ -1615,11 +1750,13 @@ async function synthesise(requestPath, succeeded, reviewersRan, context) {
|
|
|
1615
1750
|
const prompt = buildSynthesisPrompt(requestPath, files);
|
|
1616
1751
|
if ((await runOrReuse({ name: "synthesis", label: `synthesis (${describeSlot(slot)})`, slot, prompt, outputPath }, context)).ok)
|
|
1617
1752
|
return synthesisResult(readFileSync6(outputPath, "utf8"));
|
|
1753
|
+
if (context.strict) throw new ReviewFailure("synthesis failed", "The synthesis failed; nothing to post.");
|
|
1618
1754
|
context.log("showing the raw reviews instead");
|
|
1619
1755
|
return rawReviews(succeeded, context);
|
|
1620
1756
|
}
|
|
1621
|
-
async function runReview(request,
|
|
1622
|
-
const
|
|
1757
|
+
async function runReview(request, reviewContext) {
|
|
1758
|
+
const context = { ...reviewContext, usage: {} };
|
|
1759
|
+
const requestPath = join6(context.reviewDir, "request.md");
|
|
1623
1760
|
writeFileSync6(requestPath, request);
|
|
1624
1761
|
const prompt = buildReviewerPrompt(requestPath);
|
|
1625
1762
|
const results = await Promise.all(
|
|
@@ -1630,7 +1767,10 @@ async function runReview(request, context) {
|
|
|
1630
1767
|
})
|
|
1631
1768
|
);
|
|
1632
1769
|
const succeeded = reviewers.filter((_, i) => results[i]?.ok);
|
|
1633
|
-
|
|
1770
|
+
const failed = reviewers.filter((reviewer) => !succeeded.includes(reviewer));
|
|
1771
|
+
if (!succeeded.length) throw new ReviewFailure(`${stepNames(failed)} failed`, "Every reviewer failed; nothing to report.");
|
|
1772
|
+
if (context.strict && failed.length)
|
|
1773
|
+
throw new ReviewFailure(`${stepNames(failed)} failed`, `${stepNames(failed)} failed; not synthesising a partial review.`);
|
|
1634
1774
|
const synthesised = await synthesise(
|
|
1635
1775
|
requestPath,
|
|
1636
1776
|
succeeded,
|
|
@@ -1640,75 +1780,123 @@ async function runReview(request, context) {
|
|
|
1640
1780
|
return withReviewers(synthesised, context);
|
|
1641
1781
|
}
|
|
1642
1782
|
|
|
1643
|
-
// src/features/review/
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1783
|
+
// src/features/review/preflight.ts
|
|
1784
|
+
var commandTimeout = 3e4;
|
|
1785
|
+
var probeTimeout = 6e4;
|
|
1786
|
+
var maxBodyChars2 = 300;
|
|
1787
|
+
function exec(harness, args) {
|
|
1788
|
+
return new Promise((resolve) => {
|
|
1789
|
+
const options = { encoding: "utf8", timeout: commandTimeout, shell: process.platform === "win32" };
|
|
1790
|
+
execFile2(harness, args, options, (error, stdout) => resolve({ ok: !error, stdout }));
|
|
1791
|
+
});
|
|
1792
|
+
}
|
|
1793
|
+
async function claudeSignedIn() {
|
|
1794
|
+
const { ok, stdout } = await exec("claude", ["auth", "status", "--json"]);
|
|
1795
|
+
if (!ok) return false;
|
|
1650
1796
|
try {
|
|
1651
|
-
const
|
|
1652
|
-
|
|
1653
|
-
|
|
1797
|
+
const parsed = JSON.parse(stdout);
|
|
1798
|
+
return typeof parsed === "object" && parsed !== null && "loggedIn" in parsed && parsed.loggedIn === true;
|
|
1799
|
+
} catch {
|
|
1800
|
+
return false;
|
|
1801
|
+
}
|
|
1802
|
+
}
|
|
1803
|
+
async function reach({ url, headers, body }) {
|
|
1804
|
+
try {
|
|
1805
|
+
const response = await fetch(url, {
|
|
1806
|
+
method: "POST",
|
|
1807
|
+
headers: { "Content-Type": "application/json", ...headers },
|
|
1808
|
+
body: JSON.stringify(body),
|
|
1809
|
+
signal: AbortSignal.timeout(probeTimeout)
|
|
1810
|
+
});
|
|
1811
|
+
if (response.ok) return void 0;
|
|
1812
|
+
const text2 = (await response.text()).trim().slice(0, maxBodyChars2);
|
|
1813
|
+
return `HTTP ${response.status}${text2 ? `: ${text2}` : ""}`;
|
|
1654
1814
|
} catch (error) {
|
|
1655
|
-
|
|
1656
|
-
|
|
1815
|
+
if (!(error instanceof Error)) return String(error);
|
|
1816
|
+
const cause = error.cause instanceof Error ? error.cause.message : "";
|
|
1817
|
+
return cause ? `${error.message} (${cause})` : error.message;
|
|
1657
1818
|
}
|
|
1658
1819
|
}
|
|
1659
|
-
|
|
1820
|
+
var checks = {
|
|
1821
|
+
onPath: async (harness) => (await exec(harness, ["--version"])).ok,
|
|
1822
|
+
signedIn: async (harness) => harness === "claude" ? claudeSignedIn() : (await exec("codex", ["login", "status"])).ok,
|
|
1823
|
+
reach
|
|
1824
|
+
};
|
|
1825
|
+
var loginCommand = { claude: "claude auth login", codex: "codex login" };
|
|
1826
|
+
function signedOut({ slot: { harness }, keys }) {
|
|
1827
|
+
const settings = keys.map((key) => `${key}.provider and ${key}.model`).join(", and ");
|
|
1828
|
+
return `${harness} is not signed in (${keys.join(", ")}); run \`${loginCommand[harness]}\`, or run it through LiteLLM or Foundry by setting ${settings} (see \`yoke models\`)`;
|
|
1829
|
+
}
|
|
1830
|
+
async function slotProblem(use, check) {
|
|
1831
|
+
const { harness, model, probe } = use.slot;
|
|
1832
|
+
if (!await check.onPath(harness)) return `${harness} CLI not found on PATH; install it`;
|
|
1833
|
+
if (!probe) return await check.signedIn(harness) ? void 0 : signedOut(use);
|
|
1834
|
+
const error = await check.reach(probe);
|
|
1835
|
+
return error && `${harness} model "${model}" at ${probe.url}: ${error}`;
|
|
1836
|
+
}
|
|
1837
|
+
function slotsToRun(slots2) {
|
|
1838
|
+
const uses = [
|
|
1839
|
+
["review.reviewer1", slots2.reviewer1],
|
|
1840
|
+
["review.reviewer2", slots2.reviewer2],
|
|
1841
|
+
["review.synthesis", slots2.synthesis ?? { harness: slots2.reviewer1.harness }]
|
|
1842
|
+
];
|
|
1843
|
+
const distinct = /* @__PURE__ */ new Map();
|
|
1844
|
+
for (const [key, slot] of uses) {
|
|
1845
|
+
const id = `${slot.harness}:${slot.provider ?? "own"}:${slot.model ?? ""}`;
|
|
1846
|
+
const use = distinct.get(id);
|
|
1847
|
+
if (use) use.keys.push(key);
|
|
1848
|
+
else distinct.set(id, { slot, keys: [key] });
|
|
1849
|
+
}
|
|
1850
|
+
return [...distinct.values()];
|
|
1851
|
+
}
|
|
1852
|
+
async function assertViable(slots2, check = checks) {
|
|
1853
|
+
const problems = await Promise.all(slotsToRun(slots2).map((use) => slotProblem(use, check)));
|
|
1854
|
+
const errors = problems.filter((problem) => problem !== void 0);
|
|
1855
|
+
if (errors.length)
|
|
1856
|
+
throw new ReviewFailure("a reviewer could not run", ["Cannot review:", ...errors.map((error) => `- ${error}`)].join("\n"));
|
|
1857
|
+
}
|
|
1858
|
+
|
|
1859
|
+
// src/features/review/reviewLog.ts
|
|
1860
|
+
import { appendFileSync as appendFileSync2, mkdirSync as mkdirSync3 } from "fs";
|
|
1861
|
+
import { join as join7 } from "path";
|
|
1862
|
+
function reviewLog(command = process.argv.slice(2), now = () => /* @__PURE__ */ new Date()) {
|
|
1863
|
+
let path;
|
|
1864
|
+
let buffered = [];
|
|
1660
1865
|
return {
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
return { update, succeed: log, fail: log };
|
|
1866
|
+
append: (line) => {
|
|
1867
|
+
if (path) appendFileSync2(path, `${line}
|
|
1868
|
+
`);
|
|
1869
|
+
else buffered.push(line);
|
|
1666
1870
|
},
|
|
1667
|
-
|
|
1871
|
+
attach: (reviewDir) => {
|
|
1872
|
+
mkdirSync3(reviewDir, { recursive: true });
|
|
1873
|
+
path = join7(reviewDir, "review.log");
|
|
1874
|
+
appendFileSync2(path, `${["", `=== ${now().toISOString()} ===`, `$ yoke ${command.join(" ")}`, ...buffered].join("\n")}
|
|
1875
|
+
`);
|
|
1876
|
+
buffered = [];
|
|
1668
1877
|
}
|
|
1669
1878
|
};
|
|
1670
1879
|
}
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
const running = [];
|
|
1676
|
-
let frame = 0;
|
|
1677
|
-
let timer;
|
|
1678
|
-
const paint = (colour, text2) => styleText(colour, text2, { stream });
|
|
1679
|
-
const render = () => update(running.map((entry) => `${paint("cyan", frames[frame] ?? "")} ${entry.text}`).join("\n"));
|
|
1680
|
-
const persist = (line) => {
|
|
1681
|
-
update.clear();
|
|
1682
|
-
update.persist(line);
|
|
1683
|
-
render();
|
|
1684
|
-
};
|
|
1685
|
-
const settle = (entry, line) => {
|
|
1686
|
-
running.splice(running.indexOf(entry), 1);
|
|
1687
|
-
persist(line);
|
|
1688
|
-
if (running.length || !timer) return;
|
|
1689
|
-
clearInterval(timer);
|
|
1690
|
-
timer = void 0;
|
|
1880
|
+
function logged(ui, append) {
|
|
1881
|
+
const both = (show) => (line) => {
|
|
1882
|
+
append(line);
|
|
1883
|
+
show(line);
|
|
1691
1884
|
};
|
|
1692
1885
|
return {
|
|
1693
|
-
log:
|
|
1886
|
+
log: both(ui.log),
|
|
1694
1887
|
track: (label) => {
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
timer ??= setInterval(() => {
|
|
1698
|
-
frame = (frame + 1) % frames.length;
|
|
1699
|
-
render();
|
|
1700
|
-
}, tickMs);
|
|
1701
|
-
render();
|
|
1888
|
+
append(`${label}\u2026`);
|
|
1889
|
+
const status = ui.track(label);
|
|
1702
1890
|
return {
|
|
1703
|
-
update: (activity) =>
|
|
1704
|
-
|
|
1705
|
-
|
|
1891
|
+
update: (activity) => {
|
|
1892
|
+
append(`${label}: ${activity}`);
|
|
1893
|
+
status.update(activity);
|
|
1894
|
+
},
|
|
1895
|
+
succeed: both(status.succeed),
|
|
1896
|
+
fail: both(status.fail)
|
|
1706
1897
|
};
|
|
1707
1898
|
},
|
|
1708
|
-
stop:
|
|
1709
|
-
if (timer) clearInterval(timer);
|
|
1710
|
-
update.done();
|
|
1711
|
-
}
|
|
1899
|
+
stop: ui.stop
|
|
1712
1900
|
};
|
|
1713
1901
|
}
|
|
1714
1902
|
|
|
@@ -1723,12 +1911,12 @@ function prepareRunDir(reviewDir, force) {
|
|
|
1723
1911
|
mkdirSync4(reviewDir, { recursive: true });
|
|
1724
1912
|
if (force) for (const name of cachedOutputs) rmSync2(join8(reviewDir, name), { force: true });
|
|
1725
1913
|
}
|
|
1726
|
-
async function reviewAtHead(pr2, reviewDir, request, slots2, ui) {
|
|
1914
|
+
async function reviewAtHead(pr2, reviewDir, request, slots2, ui, strict) {
|
|
1727
1915
|
const checkoutDir = mkdtempSync(join8(tmpdir(), "yoke-review-"));
|
|
1728
1916
|
try {
|
|
1729
1917
|
const checkout = await timed(ui, `checking out ${pr2.headRefOid.slice(0, 7)}`, () => checkoutHead(pr2, checkoutDir));
|
|
1730
1918
|
try {
|
|
1731
|
-
return await runReview(request, { cwd: checkout.cwd, reviewDir, slots: slots2, runHarness, log: ui.log, track: ui.track });
|
|
1919
|
+
return await runReview(request, { cwd: checkout.cwd, reviewDir, slots: slots2, runHarness, log: ui.log, track: ui.track, strict });
|
|
1732
1920
|
} finally {
|
|
1733
1921
|
await checkout.remove();
|
|
1734
1922
|
}
|
|
@@ -1763,24 +1951,40 @@ async function reviewPr(number, settings, { post, force }, io) {
|
|
|
1763
1951
|
const reviewDir = reviewRunDir(pr2);
|
|
1764
1952
|
prepareRunDir(reviewDir, force);
|
|
1765
1953
|
file.attach(reviewDir);
|
|
1766
|
-
const
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1954
|
+
const ci = post && isCi();
|
|
1955
|
+
const review = async () => {
|
|
1956
|
+
const path = join8(reviewDir, "review.md");
|
|
1957
|
+
const saved = savedReview(path, post);
|
|
1958
|
+
if (saved) {
|
|
1959
|
+
log(`posting the saved review of #${pr2.number} at ${pr2.headRefOid.slice(0, 7)}`);
|
|
1960
|
+
out(formatSynthesis(saved));
|
|
1961
|
+
return { synthesis: saved, usage: {} };
|
|
1962
|
+
}
|
|
1772
1963
|
await timed(ui, "checking each reviewer can run", () => assertViable(slots2));
|
|
1773
1964
|
log(`review folder: ${reviewDir}`);
|
|
1774
|
-
const result = await reviewAtHead(pr2, reviewDir, buildRequest(pr2, diff, prior), slots2, ui);
|
|
1965
|
+
const result = await reviewAtHead(pr2, reviewDir, buildRequest(pr2, diff, prior), slots2, ui, ci);
|
|
1775
1966
|
out(result.report);
|
|
1776
|
-
synthesis = result.synthesis;
|
|
1777
1967
|
saveReview(path, result.markdown);
|
|
1968
|
+
if (post && !result.synthesis)
|
|
1969
|
+
throw new ReviewFailure(
|
|
1970
|
+
"the synthesis could not be parsed",
|
|
1971
|
+
"Not posting: the synthesis did not produce findings in the expected format."
|
|
1972
|
+
);
|
|
1973
|
+
return result;
|
|
1974
|
+
};
|
|
1975
|
+
const footer = () => modelsFooter(readUsedModels(reviewDir));
|
|
1976
|
+
let findings;
|
|
1977
|
+
try {
|
|
1978
|
+
const { synthesis: synthesis2, usage } = await review();
|
|
1979
|
+
findings = synthesis2;
|
|
1980
|
+
if (ci && synthesis2) writeJobSummary(synthesis2.summary, usage, log);
|
|
1981
|
+
} catch (error) {
|
|
1982
|
+
if (ci && error instanceof ReviewFailure) await reportFailure(pr2, error.reason, ui, { api: github, log, footer: footer() });
|
|
1983
|
+
throw error;
|
|
1778
1984
|
}
|
|
1779
|
-
if (!post) return;
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
const footer = modelsFooter(readUsedModels(reviewDir));
|
|
1783
|
-
await timed(ui, `posting to #${pr2.number}`, () => postFindings(pr2, findings, diff, prior, { api: github, log, footer }));
|
|
1985
|
+
if (!post || !findings) return;
|
|
1986
|
+
const synthesis = findings;
|
|
1987
|
+
await timed(ui, `posting to #${pr2.number}`, () => postFindings(pr2, synthesis, diff, prior, { api: github, log, footer: footer(), ci }));
|
|
1784
1988
|
}
|
|
1785
1989
|
|
|
1786
1990
|
// src/features/sync/sync.ts
|
|
@@ -1822,6 +2026,7 @@ var reviewCiSkill = {
|
|
|
1822
2026
|
name: "review-ci",
|
|
1823
2027
|
description: "Add yoke's adversarial PR review workflow to this repo and set the secrets it needs.",
|
|
1824
2028
|
disableModelInvocation: true,
|
|
2029
|
+
userLevel: true,
|
|
1825
2030
|
body: `
|
|
1826
2031
|
Run \`yoke review-ci $ARGUMENTS\`. If it reports that a model is required, run \`yoke models\` to list what the provider serves, ask the user which model each reviewer should run, and rerun with \`--reviewer-1-model\` and \`--reviewer-2-model\`. If it reports the workflow already exists and differs, show the user the difference and ask before rerunning with \`--force\`.
|
|
1827
2032
|
|
|
@@ -1857,10 +2062,10 @@ function skillsRoot(user, cwd = process.cwd(), home = homedir3()) {
|
|
|
1857
2062
|
function isOwnedByUser(skillFile) {
|
|
1858
2063
|
return existsSync8(skillFile) && !readFileSync8(skillFile, "utf8").includes(ownerMarker);
|
|
1859
2064
|
}
|
|
1860
|
-
function syncSkills(destination, skills = bundledSkills) {
|
|
2065
|
+
function syncSkills(destination, userDestination, skills = bundledSkills) {
|
|
1861
2066
|
const result = { written: [], skipped: [] };
|
|
1862
2067
|
for (const skill of skills) {
|
|
1863
|
-
const target = join9(destination, skill.name);
|
|
2068
|
+
const target = join9(skill.userLevel ? userDestination : destination, skill.name);
|
|
1864
2069
|
const skillFile = join9(target, "SKILL.md");
|
|
1865
2070
|
if (isOwnedByUser(skillFile)) {
|
|
1866
2071
|
result.skipped.push(target);
|
|
@@ -1878,8 +2083,9 @@ var { version } = package_default;
|
|
|
1878
2083
|
var program = new Command("yoke").description("Lean Claude Code skills and the CLI they call.").version(version);
|
|
1879
2084
|
program.command("sync").description("Write yoke's skills into this repo's .claude/skills, or ~/.claude/skills with --user").option("--user", "write to ~/.claude/skills instead, for every repo").action((options) => {
|
|
1880
2085
|
const user = options.user ?? false;
|
|
1881
|
-
const
|
|
1882
|
-
const
|
|
2086
|
+
const cwd = process.cwd();
|
|
2087
|
+
const display = (path) => path.startsWith(cwd) ? relative(cwd, path) : path;
|
|
2088
|
+
const { written, skipped } = syncSkills(skillsRoot(user), skillsRoot(true));
|
|
1883
2089
|
for (const file of written) console.log(`wrote ${display(file)}`);
|
|
1884
2090
|
for (const dir of skipped) console.log(`skipped ${display(dir)} (your own skill; delete it to take yoke's)`);
|
|
1885
2091
|
});
|