@makerx/yoke 0.7.0 → 0.8.0
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 +562 -380
- 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.0",
|
|
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");
|
|
@@ -823,6 +823,37 @@ import { existsSync as existsSync7, mkdirSync as mkdirSync4, mkdtempSync, readFi
|
|
|
823
823
|
import { homedir as homedir2, tmpdir } from "os";
|
|
824
824
|
import { join as join8 } from "path";
|
|
825
825
|
|
|
826
|
+
// src/features/review/comments.ts
|
|
827
|
+
var markerPrefix = "<!-- yoke-review:";
|
|
828
|
+
function slug(text2) {
|
|
829
|
+
return text2.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
830
|
+
}
|
|
831
|
+
function findingMarker({ file, title }) {
|
|
832
|
+
return `${markerPrefix} ${file ?? "n/a"}#${slug(title)} -->`;
|
|
833
|
+
}
|
|
834
|
+
function hasMarker(prior, marker) {
|
|
835
|
+
return prior.some((comment) => comment.body.includes(marker));
|
|
836
|
+
}
|
|
837
|
+
function withoutMarkers(body) {
|
|
838
|
+
return body.split("\n").filter((line) => !line.trimStart().startsWith(markerPrefix)).join("\n").trim();
|
|
839
|
+
}
|
|
840
|
+
function heading({ path, line }) {
|
|
841
|
+
if (!path) return "### PR review";
|
|
842
|
+
return line === null ? `### ${path}` : `### ${path}:${line}`;
|
|
843
|
+
}
|
|
844
|
+
function formatPriorComments(prior) {
|
|
845
|
+
if (!prior.length) return "";
|
|
846
|
+
const blocks = prior.map((comment) => `${heading(comment)}
|
|
847
|
+
|
|
848
|
+
**${comment.author}**: ${withoutMarkers(comment.body)}`);
|
|
849
|
+
return `## Prior review comments
|
|
850
|
+
|
|
851
|
+
The PR already has the review comments below, including resolved and outdated threads. Do not re-raise a finding a prior comment substantively covers.
|
|
852
|
+
|
|
853
|
+
${blocks.join("\n\n")}
|
|
854
|
+
`;
|
|
855
|
+
}
|
|
856
|
+
|
|
826
857
|
// src/features/review/findings.ts
|
|
827
858
|
var severities = ["blocker", "major", "minor", "nit"];
|
|
828
859
|
var sources = ["confirmed", "disputed", "reviewer1-only", "reviewer2-only", "already-raised"];
|
|
@@ -881,184 +912,6 @@ function formatSynthesis({ summary, findings }) {
|
|
|
881
912
|
return lines.join("\n").trimEnd();
|
|
882
913
|
}
|
|
883
914
|
|
|
884
|
-
// src/features/review/harness.ts
|
|
885
|
-
import { spawn } from "child_process";
|
|
886
|
-
import { existsSync as existsSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "fs";
|
|
887
|
-
|
|
888
|
-
// src/features/review/progress.ts
|
|
889
|
-
var maxSummary = 80;
|
|
890
|
-
function isJson(value) {
|
|
891
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
892
|
-
}
|
|
893
|
-
function truncate(value) {
|
|
894
|
-
const collapsed = value.replace(/\s+/g, " ").trim();
|
|
895
|
-
return collapsed.length <= maxSummary ? collapsed : `${collapsed.slice(0, maxSummary - 1)}\u2026`;
|
|
896
|
-
}
|
|
897
|
-
function text(input, key) {
|
|
898
|
-
const value = input[key];
|
|
899
|
-
return typeof value === "string" ? value : void 0;
|
|
900
|
-
}
|
|
901
|
-
function toolSummary(input) {
|
|
902
|
-
if (!isJson(input)) return "";
|
|
903
|
-
const pattern = text(input, "pattern");
|
|
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) : "";
|
|
912
|
-
}
|
|
913
|
-
function describeTool(tool, summary) {
|
|
914
|
-
return summary ? `${tool} ${summary}` : tool;
|
|
915
|
-
}
|
|
916
|
-
function claudeEvent(event) {
|
|
917
|
-
if (event.type === "result" && typeof event.result === "string") return { kind: "final", text: event.result };
|
|
918
|
-
if (event.type === "system" && event.subtype === "init" && typeof event.model === "string") return { kind: "model", model: event.model };
|
|
919
|
-
const content = isJson(event.message) ? event.message.content : void 0;
|
|
920
|
-
if (event.type !== "assistant" || !Array.isArray(content)) return { kind: "ignore" };
|
|
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(", ") };
|
|
924
|
-
}
|
|
925
|
-
function unwrapShell(command) {
|
|
926
|
-
const wrapped = command.match(/^\/bin\/(?:ba|z)?sh\s+-lc\s+["'](.*)["']\s*$/s);
|
|
927
|
-
return truncate(wrapped?.[1]?.replace(/\\"/g, '"') ?? command);
|
|
928
|
-
}
|
|
929
|
-
function codexError(event) {
|
|
930
|
-
if (event.type === "error") return text(event, "message");
|
|
931
|
-
if (event.type === "turn.failed" && isJson(event.error)) return text(event.error, "message");
|
|
932
|
-
return void 0;
|
|
933
|
-
}
|
|
934
|
-
function codexEvent(event) {
|
|
935
|
-
const message = codexError(event);
|
|
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" };
|
|
942
|
-
}
|
|
943
|
-
function parseHarnessEvent(harness, line) {
|
|
944
|
-
let event;
|
|
945
|
-
try {
|
|
946
|
-
event = JSON.parse(line);
|
|
947
|
-
} catch {
|
|
948
|
-
return { kind: "ignore" };
|
|
949
|
-
}
|
|
950
|
-
if (!isJson(event)) return { kind: "ignore" };
|
|
951
|
-
return harness === "claude" ? claudeEvent(event) : codexEvent(event);
|
|
952
|
-
}
|
|
953
|
-
|
|
954
|
-
// src/features/review/harness.ts
|
|
955
|
-
var tailLines = 20;
|
|
956
|
-
function harnessArgs({ harness, reviewDir, outputPath, override }) {
|
|
957
|
-
const extra = override?.args ?? [];
|
|
958
|
-
if (harness === "codex") return ["exec", "--sandbox", "read-only", "--json", "--output-last-message", outputPath, ...extra, "-"];
|
|
959
|
-
return [
|
|
960
|
-
"-p",
|
|
961
|
-
"--add-dir",
|
|
962
|
-
reviewDir,
|
|
963
|
-
"--disallowedTools",
|
|
964
|
-
"Edit,Write,NotebookEdit",
|
|
965
|
-
"--output-format",
|
|
966
|
-
"stream-json",
|
|
967
|
-
"--verbose",
|
|
968
|
-
...extra
|
|
969
|
-
];
|
|
970
|
-
}
|
|
971
|
-
function windowsQuote(arg) {
|
|
972
|
-
return /[\s"]/.test(arg) ? `"${arg.replace(/"/g, '\\"')}"` : arg;
|
|
973
|
-
}
|
|
974
|
-
function tail(text2) {
|
|
975
|
-
return text2.trim().split("\n").slice(-tailLines).join("\n");
|
|
976
|
-
}
|
|
977
|
-
function spawnError(harness, error) {
|
|
978
|
-
if (error.code === "ENOENT") return `${harness} CLI not found on PATH; install it or sign in, then retry`;
|
|
979
|
-
return `${harness} failed to start: ${error.message}`;
|
|
980
|
-
}
|
|
981
|
-
function exitError(harness, code, output) {
|
|
982
|
-
const detail = tail(output);
|
|
983
|
-
return [`${harness} exited with code ${code ?? "unknown"}`, ...detail ? [detail] : []].join("\n");
|
|
984
|
-
}
|
|
985
|
-
function hasReview(path) {
|
|
986
|
-
return existsSync3(path) && readFileSync3(path, "utf8").trim().length > 0;
|
|
987
|
-
}
|
|
988
|
-
var runHarness = (run2) => new Promise((resolve) => {
|
|
989
|
-
const shell = process.platform === "win32";
|
|
990
|
-
const args = harnessArgs(run2);
|
|
991
|
-
const child = spawn(run2.harness, shell ? args.map(windowsQuote) : args, {
|
|
992
|
-
cwd: run2.cwd,
|
|
993
|
-
env: { ...process.env, ...run2.override?.env },
|
|
994
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
995
|
-
shell
|
|
996
|
-
});
|
|
997
|
-
let pending = "";
|
|
998
|
-
let finalText = "";
|
|
999
|
-
let model;
|
|
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)} -->`;
|
|
1038
|
-
}
|
|
1039
|
-
function hasMarker(prior, marker) {
|
|
1040
|
-
return prior.some((comment) => comment.body.includes(marker));
|
|
1041
|
-
}
|
|
1042
|
-
function withoutMarkers(body) {
|
|
1043
|
-
return body.split("\n").filter((line) => !line.trimStart().startsWith(markerPrefix)).join("\n").trim();
|
|
1044
|
-
}
|
|
1045
|
-
function heading({ path, line }) {
|
|
1046
|
-
if (!path) return "### PR review";
|
|
1047
|
-
return line === null ? `### ${path}` : `### ${path}:${line}`;
|
|
1048
|
-
}
|
|
1049
|
-
function formatPriorComments(prior) {
|
|
1050
|
-
if (!prior.length) return "";
|
|
1051
|
-
const blocks = prior.map((comment) => `${heading(comment)}
|
|
1052
|
-
|
|
1053
|
-
**${comment.author}**: ${withoutMarkers(comment.body)}`);
|
|
1054
|
-
return `## Prior review comments
|
|
1055
|
-
|
|
1056
|
-
The PR already has the review comments below, including resolved and outdated threads. Do not re-raise a finding a prior comment substantively covers.
|
|
1057
|
-
|
|
1058
|
-
${blocks.join("\n\n")}
|
|
1059
|
-
`;
|
|
1060
|
-
}
|
|
1061
|
-
|
|
1062
915
|
// src/features/review/request.ts
|
|
1063
916
|
var fileHeader = /^\+\+\+ (.+)$/;
|
|
1064
917
|
var hunkHeader = /^@@ -\d+(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/;
|
|
@@ -1168,14 +1021,19 @@ function outsideEntry(finding) {
|
|
|
1168
1021
|
const entry = [`- **${finding.severity ?? "finding"}** ${location} \u2014 ${finding.title}`, ...details(finding).map((d) => ` ${d}`)];
|
|
1169
1022
|
return [...entry, ` ${findingMarker(finding)}`].join("\n");
|
|
1170
1023
|
}
|
|
1171
|
-
|
|
1172
|
-
|
|
1024
|
+
var noFindingsNote = "No new findings were raised.";
|
|
1025
|
+
var rerunNote = "To re-run this review, mark the PR as draft and then ready for review again.";
|
|
1026
|
+
function reviewBody(summary, outside, footer = "", note = "") {
|
|
1027
|
+
const sections = ["## Adversarial review", ...summary ? [summary] : [], ...note ? [note] : []];
|
|
1173
1028
|
if (outside.length) sections.push(`### Findings outside the diff
|
|
1174
1029
|
|
|
1175
1030
|
${outside.map(outsideEntry).join("\n")}`);
|
|
1176
1031
|
if (footer) sections.push(footer);
|
|
1177
1032
|
return sections.join("\n\n");
|
|
1178
1033
|
}
|
|
1034
|
+
function postFooter(footer, ci) {
|
|
1035
|
+
return [...footer ? [footer] : [], ...ci ? [`_${rerunNote}_`] : []].join("\n\n");
|
|
1036
|
+
}
|
|
1179
1037
|
async function addComments(review, comments, { api, log }) {
|
|
1180
1038
|
const rejected = [];
|
|
1181
1039
|
for (const { finding, ...comment } of comments) {
|
|
@@ -1189,155 +1047,396 @@ async function addComments(review, comments, { api, log }) {
|
|
|
1189
1047
|
}
|
|
1190
1048
|
return rejected;
|
|
1191
1049
|
}
|
|
1050
|
+
function failedReviewBody(reason, runUrl, footer = "") {
|
|
1051
|
+
const sections = ["## Code review failed", `The review did not complete: ${reason}. No findings were posted.`];
|
|
1052
|
+
if (runUrl) sections.push(`See the [workflow run](${runUrl}) for details.`);
|
|
1053
|
+
if (footer) sections.push(footer);
|
|
1054
|
+
return sections.join("\n\n");
|
|
1055
|
+
}
|
|
1056
|
+
async function postFailure(pr2, reason, runUrl, deps) {
|
|
1057
|
+
const review = await deps.api.open(pr2);
|
|
1058
|
+
try {
|
|
1059
|
+
const url = await deps.api.submit(pr2, review, failedReviewBody(reason, runUrl, postFooter(deps.footer, true)));
|
|
1060
|
+
deps.log(`posted the failed review: ${url}`);
|
|
1061
|
+
} catch (error) {
|
|
1062
|
+
await discard(pr2, review, deps);
|
|
1063
|
+
throw error;
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
async function discard(pr2, review, { api, log }) {
|
|
1067
|
+
try {
|
|
1068
|
+
await api.discard(pr2, review);
|
|
1069
|
+
} catch (discardError) {
|
|
1070
|
+
log(`could not discard the pending review: ${discardError instanceof Error ? discardError.message : String(discardError)}`);
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1192
1073
|
async function postFindings(pr2, synthesis, diff, prior, deps) {
|
|
1193
|
-
const { api, log } = deps;
|
|
1074
|
+
const { api, log, ci = false } = deps;
|
|
1194
1075
|
const { comments, outside, skipped } = planPost(synthesis, diff, prior);
|
|
1195
1076
|
if (skipped.length) log(`skipped ${skipped.length} finding(s) already raised on the PR`);
|
|
1196
|
-
|
|
1077
|
+
const nothingNew = !comments.length && !outside.length;
|
|
1078
|
+
if (nothingNew && !ci) return log("nothing new to post");
|
|
1079
|
+
const footer = postFooter(deps.footer, ci);
|
|
1197
1080
|
const review = await api.open(pr2);
|
|
1198
1081
|
try {
|
|
1082
|
+
if (nothingNew) {
|
|
1083
|
+
const url2 = await api.submit(pr2, review, reviewBody(synthesis.summary, [], footer, noFindingsNote));
|
|
1084
|
+
return log(`nothing new to post, so posted a review without findings: ${url2}`);
|
|
1085
|
+
}
|
|
1199
1086
|
const rejected = await addComments(review, comments, deps);
|
|
1200
|
-
const url = await api.submit(pr2, review, reviewBody(synthesis.summary, [...outside, ...rejected],
|
|
1087
|
+
const url = await api.submit(pr2, review, reviewBody(synthesis.summary, [...outside, ...rejected], footer));
|
|
1201
1088
|
log(`posted ${comments.length - rejected.length} inline comment(s) and ${outside.length + rejected.length} in the review body: ${url}`);
|
|
1202
1089
|
} 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
|
-
}
|
|
1090
|
+
await discard(pr2, review, deps);
|
|
1208
1091
|
throw error;
|
|
1209
1092
|
}
|
|
1210
1093
|
}
|
|
1211
1094
|
|
|
1212
|
-
// src/features/review/
|
|
1213
|
-
import {
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1095
|
+
// src/features/review/status.ts
|
|
1096
|
+
import { styleText } from "util";
|
|
1097
|
+
import { createLogUpdate } from "log-update";
|
|
1098
|
+
async function timed(ui, label, work) {
|
|
1099
|
+
const started = Date.now();
|
|
1100
|
+
const status = ui.track(label);
|
|
1101
|
+
const elapsed = () => `${Math.round((Date.now() - started) / 1e3)}s`;
|
|
1102
|
+
try {
|
|
1103
|
+
const result = await work();
|
|
1104
|
+
status.succeed(`${label} (${elapsed()})`);
|
|
1105
|
+
return result;
|
|
1106
|
+
} catch (error) {
|
|
1107
|
+
status.fail(`${label} failed after ${elapsed()}`);
|
|
1108
|
+
throw error;
|
|
1109
|
+
}
|
|
1222
1110
|
}
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1111
|
+
function lineUi(log, { activity }) {
|
|
1112
|
+
return {
|
|
1113
|
+
log,
|
|
1114
|
+
track: (label) => {
|
|
1115
|
+
log(`${label}\u2026`);
|
|
1116
|
+
const update = (text2) => activity ? log(`${label}: ${text2}`) : void 0;
|
|
1117
|
+
return { update, succeed: log, fail: log };
|
|
1118
|
+
},
|
|
1119
|
+
stop: () => {
|
|
1120
|
+
}
|
|
1121
|
+
};
|
|
1122
|
+
}
|
|
1123
|
+
var frames = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
|
|
1124
|
+
var tickMs = 80;
|
|
1125
|
+
function spinnerUi(stream) {
|
|
1126
|
+
const update = createLogUpdate(stream);
|
|
1127
|
+
const running = [];
|
|
1128
|
+
let frame = 0;
|
|
1129
|
+
let timer;
|
|
1130
|
+
const paint = (colour, text2) => styleText(colour, text2, { stream });
|
|
1131
|
+
const render = () => update(running.map((entry) => `${paint("cyan", frames[frame] ?? "")} ${entry.text}`).join("\n"));
|
|
1132
|
+
const persist = (line) => {
|
|
1133
|
+
update.clear();
|
|
1134
|
+
update.persist(line);
|
|
1135
|
+
render();
|
|
1136
|
+
};
|
|
1137
|
+
const settle = (entry, line) => {
|
|
1138
|
+
running.splice(running.indexOf(entry), 1);
|
|
1139
|
+
persist(line);
|
|
1140
|
+
if (running.length || !timer) return;
|
|
1141
|
+
clearInterval(timer);
|
|
1142
|
+
timer = void 0;
|
|
1143
|
+
};
|
|
1144
|
+
return {
|
|
1145
|
+
log: persist,
|
|
1146
|
+
track: (label) => {
|
|
1147
|
+
const entry = { label, text: label };
|
|
1148
|
+
running.push(entry);
|
|
1149
|
+
timer ??= setInterval(() => {
|
|
1150
|
+
frame = (frame + 1) % frames.length;
|
|
1151
|
+
render();
|
|
1152
|
+
}, tickMs);
|
|
1153
|
+
render();
|
|
1154
|
+
return {
|
|
1155
|
+
update: (activity) => entry.text = `${label} \u2014 ${activity}`,
|
|
1156
|
+
succeed: (text2) => settle(entry, `${paint("green", "\u2713")} ${text2}`),
|
|
1157
|
+
fail: (text2) => settle(entry, `${paint("red", "\u2717")} ${text2}`)
|
|
1158
|
+
};
|
|
1159
|
+
},
|
|
1160
|
+
stop: () => {
|
|
1161
|
+
if (timer) clearInterval(timer);
|
|
1162
|
+
update.done();
|
|
1163
|
+
}
|
|
1164
|
+
};
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
// src/features/review/ciRun.ts
|
|
1168
|
+
function isCi(env = process.env) {
|
|
1169
|
+
return env.GITHUB_ACTIONS === "true";
|
|
1170
|
+
}
|
|
1171
|
+
function ciRunUrl(env = process.env) {
|
|
1172
|
+
const { GITHUB_SERVER_URL, GITHUB_REPOSITORY, GITHUB_RUN_ID } = env;
|
|
1173
|
+
if (!GITHUB_SERVER_URL || !GITHUB_REPOSITORY || !GITHUB_RUN_ID) return void 0;
|
|
1174
|
+
return `${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}`;
|
|
1175
|
+
}
|
|
1176
|
+
async function reportFailure(pr2, reason, ui, deps) {
|
|
1226
1177
|
try {
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
return false;
|
|
1178
|
+
await timed(ui, `posting the failed review to #${pr2.number}`, () => postFailure(pr2, reason, ciRunUrl(), deps));
|
|
1179
|
+
} catch (error) {
|
|
1180
|
+
ui.log(`could not post the failed review: ${error instanceof Error ? error.message : String(error)}`);
|
|
1231
1181
|
}
|
|
1232
1182
|
}
|
|
1233
|
-
|
|
1183
|
+
|
|
1184
|
+
// src/features/review/harness.ts
|
|
1185
|
+
import { spawn } from "child_process";
|
|
1186
|
+
import { existsSync as existsSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "fs";
|
|
1187
|
+
|
|
1188
|
+
// src/features/review/usage.ts
|
|
1189
|
+
import { appendFileSync } from "fs";
|
|
1190
|
+
var fields2 = ["input", "output", "cacheRead", "cacheWrite"];
|
|
1191
|
+
var rows = [
|
|
1192
|
+
["reviewer1", "Reviewer 1"],
|
|
1193
|
+
["reviewer2", "Reviewer 2"],
|
|
1194
|
+
["synthesis", "Synthesis"]
|
|
1195
|
+
];
|
|
1196
|
+
var dash = "\u2014";
|
|
1197
|
+
function count(source, key) {
|
|
1198
|
+
const value = key ? source[key] : void 0;
|
|
1199
|
+
return typeof value === "number" && Number.isFinite(value) ? value : void 0;
|
|
1200
|
+
}
|
|
1201
|
+
function isRecord(value) {
|
|
1202
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1203
|
+
}
|
|
1204
|
+
function parseUsage(raw, keys) {
|
|
1205
|
+
if (!isRecord(raw)) return void 0;
|
|
1206
|
+
const usage = {};
|
|
1207
|
+
for (const field2 of fields2) {
|
|
1208
|
+
const value = count(raw, keys[field2]);
|
|
1209
|
+
if (value !== void 0) usage[field2] = value;
|
|
1210
|
+
}
|
|
1211
|
+
return Object.keys(usage).length ? usage : void 0;
|
|
1212
|
+
}
|
|
1213
|
+
function sum(values) {
|
|
1214
|
+
const reported = values.filter((value) => value !== void 0);
|
|
1215
|
+
return reported.length ? reported.reduce((a, b) => a + b, 0) : void 0;
|
|
1216
|
+
}
|
|
1217
|
+
function sumUsage(usages) {
|
|
1218
|
+
const total = {};
|
|
1219
|
+
for (const field2 of fields2) {
|
|
1220
|
+
const value = sum(usages.map((usage) => usage?.[field2]));
|
|
1221
|
+
if (value !== void 0) total[field2] = value;
|
|
1222
|
+
}
|
|
1223
|
+
return total;
|
|
1224
|
+
}
|
|
1225
|
+
function addUsage(a, b) {
|
|
1226
|
+
return sumUsage([a, b]);
|
|
1227
|
+
}
|
|
1228
|
+
function cell(value) {
|
|
1229
|
+
return value === void 0 ? dash : value.toLocaleString("en-US");
|
|
1230
|
+
}
|
|
1231
|
+
function row(label, usage) {
|
|
1232
|
+
return `| ${label} | ${fields2.map((field2) => cell(usage?.[field2])).join(" | ")} |`;
|
|
1233
|
+
}
|
|
1234
|
+
function usageTable(usage) {
|
|
1235
|
+
const total = sumUsage(rows.map(([step]) => usage[step]));
|
|
1236
|
+
return [
|
|
1237
|
+
"## Token usage",
|
|
1238
|
+
"",
|
|
1239
|
+
"| Slot | Input | Output | Cache read | Cache write |",
|
|
1240
|
+
"| --- | ---: | ---: | ---: | ---: |",
|
|
1241
|
+
...rows.map(([step, label]) => row(label, usage[step])),
|
|
1242
|
+
row("**Total**", total)
|
|
1243
|
+
].join("\n");
|
|
1244
|
+
}
|
|
1245
|
+
function jobSummary(summary, usage) {
|
|
1246
|
+
return ["## Adversarial review", summary || "No summary was given.", usageTable(usage)].join("\n\n");
|
|
1247
|
+
}
|
|
1248
|
+
function writeJobSummary(summary, usage, log, env = process.env) {
|
|
1249
|
+
const path = env.GITHUB_STEP_SUMMARY;
|
|
1250
|
+
if (!path) return;
|
|
1234
1251
|
try {
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
body: JSON.stringify(body),
|
|
1239
|
-
signal: AbortSignal.timeout(probeTimeout)
|
|
1240
|
-
});
|
|
1241
|
-
if (response.ok) return void 0;
|
|
1242
|
-
const text2 = (await response.text()).trim().slice(0, maxBodyChars2);
|
|
1243
|
-
return `HTTP ${response.status}${text2 ? `: ${text2}` : ""}`;
|
|
1252
|
+
appendFileSync(path, `${jobSummary(summary, usage)}
|
|
1253
|
+
`);
|
|
1254
|
+
log("wrote the review summary and token usage to the job summary");
|
|
1244
1255
|
} catch (error) {
|
|
1245
|
-
|
|
1246
|
-
const cause = error.cause instanceof Error ? error.cause.message : "";
|
|
1247
|
-
return cause ? `${error.message} (${cause})` : error.message;
|
|
1256
|
+
log(`could not write the job summary: ${error instanceof Error ? error.message : String(error)}`);
|
|
1248
1257
|
}
|
|
1249
1258
|
}
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
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\`)`;
|
|
1259
|
+
|
|
1260
|
+
// src/features/review/progress.ts
|
|
1261
|
+
var maxSummary = 80;
|
|
1262
|
+
function isJson(value) {
|
|
1263
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1259
1264
|
}
|
|
1260
|
-
|
|
1261
|
-
const
|
|
1262
|
-
|
|
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}`;
|
|
1265
|
+
function truncate(value) {
|
|
1266
|
+
const collapsed = value.replace(/\s+/g, " ").trim();
|
|
1267
|
+
return collapsed.length <= maxSummary ? collapsed : `${collapsed.slice(0, maxSummary - 1)}\u2026`;
|
|
1266
1268
|
}
|
|
1267
|
-
function
|
|
1268
|
-
const
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
const
|
|
1274
|
-
|
|
1275
|
-
const
|
|
1276
|
-
|
|
1277
|
-
if (use) use.keys.push(key);
|
|
1278
|
-
else distinct.set(id, { slot, keys: [key] });
|
|
1269
|
+
function text(input, key) {
|
|
1270
|
+
const value = input[key];
|
|
1271
|
+
return typeof value === "string" ? value : void 0;
|
|
1272
|
+
}
|
|
1273
|
+
function toolSummary(input) {
|
|
1274
|
+
if (!isJson(input)) return "";
|
|
1275
|
+
const pattern = text(input, "pattern");
|
|
1276
|
+
if (pattern) {
|
|
1277
|
+
const scope = text(input, "path");
|
|
1278
|
+
return scope ? `${pattern} in ${scope}` : pattern;
|
|
1279
1279
|
}
|
|
1280
|
-
|
|
1280
|
+
const found = text(input, "file_path") ?? text(input, "path") ?? text(input, "url");
|
|
1281
|
+
if (found) return found;
|
|
1282
|
+
const described = text(input, "command") ?? text(input, "query") ?? text(input, "description");
|
|
1283
|
+
return described ? truncate(described) : "";
|
|
1284
|
+
}
|
|
1285
|
+
function describeTool(tool, summary) {
|
|
1286
|
+
return summary ? `${tool} ${summary}` : tool;
|
|
1287
|
+
}
|
|
1288
|
+
function claudeUsage(raw) {
|
|
1289
|
+
return parseUsage(raw, {
|
|
1290
|
+
input: "input_tokens",
|
|
1291
|
+
output: "output_tokens",
|
|
1292
|
+
cacheRead: "cache_read_input_tokens",
|
|
1293
|
+
cacheWrite: "cache_creation_input_tokens"
|
|
1294
|
+
});
|
|
1295
|
+
}
|
|
1296
|
+
function codexUsage(raw) {
|
|
1297
|
+
const usage = parseUsage(raw, { input: "input_tokens", output: "output_tokens", cacheRead: "cached_input_tokens" });
|
|
1298
|
+
if (usage?.input !== void 0 && usage.cacheRead !== void 0) usage.input = Math.max(0, usage.input - usage.cacheRead);
|
|
1299
|
+
return usage;
|
|
1300
|
+
}
|
|
1301
|
+
function claudeResult(event) {
|
|
1302
|
+
const usage = claudeUsage(event.usage);
|
|
1303
|
+
if (typeof event.result === "string") return usage ? { kind: "final", text: event.result, usage } : { kind: "final", text: event.result };
|
|
1304
|
+
return usage ? { kind: "usage", usage } : { kind: "ignore" };
|
|
1305
|
+
}
|
|
1306
|
+
function claudeEvent(event) {
|
|
1307
|
+
if (event.type === "result") return claudeResult(event);
|
|
1308
|
+
if (event.type === "system" && event.subtype === "init" && typeof event.model === "string") return { kind: "model", model: event.model };
|
|
1309
|
+
const content = isJson(event.message) ? event.message.content : void 0;
|
|
1310
|
+
if (event.type !== "assistant" || !Array.isArray(content)) return { kind: "ignore" };
|
|
1311
|
+
const uses = content.filter((block) => isJson(block) && block.type === "tool_use");
|
|
1312
|
+
if (!uses.length) return { kind: "ignore" };
|
|
1313
|
+
return { kind: "activity", text: uses.map((use) => describeTool(text(use, "name") ?? "tool", toolSummary(use.input))).join(", ") };
|
|
1314
|
+
}
|
|
1315
|
+
function unwrapShell(command) {
|
|
1316
|
+
const wrapped = command.match(/^\/bin\/(?:ba|z)?sh\s+-lc\s+["'](.*)["']\s*$/s);
|
|
1317
|
+
return truncate(wrapped?.[1]?.replace(/\\"/g, '"') ?? command);
|
|
1318
|
+
}
|
|
1319
|
+
function codexError(event) {
|
|
1320
|
+
if (event.type === "error") return text(event, "message");
|
|
1321
|
+
if (event.type === "turn.failed" && isJson(event.error)) return text(event.error, "message");
|
|
1322
|
+
return void 0;
|
|
1323
|
+
}
|
|
1324
|
+
function codexEvent(event) {
|
|
1325
|
+
const message = codexError(event);
|
|
1326
|
+
if (message) return { kind: "error", message };
|
|
1327
|
+
const usage = event.type === "turn.completed" ? codexUsage(event.usage) : void 0;
|
|
1328
|
+
if (usage) return { kind: "usage", usage };
|
|
1329
|
+
const item = event.item;
|
|
1330
|
+
if (event.type !== "item.started" || !isJson(item)) return { kind: "ignore" };
|
|
1331
|
+
const command = text(item, "command");
|
|
1332
|
+
if (item.type === "command_execution" && command) return { kind: "activity", text: describeTool("shell", unwrapShell(command)) };
|
|
1333
|
+
return { kind: "ignore" };
|
|
1334
|
+
}
|
|
1335
|
+
function parseHarnessEvent(harness, line) {
|
|
1336
|
+
let event;
|
|
1337
|
+
try {
|
|
1338
|
+
event = JSON.parse(line);
|
|
1339
|
+
} catch {
|
|
1340
|
+
return { kind: "ignore" };
|
|
1341
|
+
}
|
|
1342
|
+
if (!isJson(event)) return { kind: "ignore" };
|
|
1343
|
+
return harness === "claude" ? claudeEvent(event) : codexEvent(event);
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
// src/features/review/harness.ts
|
|
1347
|
+
var tailLines = 20;
|
|
1348
|
+
function harnessArgs({ harness, reviewDir, outputPath, override }) {
|
|
1349
|
+
const extra = override?.args ?? [];
|
|
1350
|
+
if (harness === "codex") return ["exec", "--sandbox", "read-only", "--json", "--output-last-message", outputPath, ...extra, "-"];
|
|
1351
|
+
return [
|
|
1352
|
+
"-p",
|
|
1353
|
+
"--add-dir",
|
|
1354
|
+
reviewDir,
|
|
1355
|
+
"--disallowedTools",
|
|
1356
|
+
"Edit,Write,NotebookEdit",
|
|
1357
|
+
"--output-format",
|
|
1358
|
+
"stream-json",
|
|
1359
|
+
"--verbose",
|
|
1360
|
+
...extra
|
|
1361
|
+
];
|
|
1362
|
+
}
|
|
1363
|
+
function windowsQuote(arg) {
|
|
1364
|
+
return /[\s"]/.test(arg) ? `"${arg.replace(/"/g, '\\"')}"` : arg;
|
|
1365
|
+
}
|
|
1366
|
+
function tail(text2) {
|
|
1367
|
+
return text2.trim().split("\n").slice(-tailLines).join("\n");
|
|
1368
|
+
}
|
|
1369
|
+
function spawnError(harness, error) {
|
|
1370
|
+
if (error.code === "ENOENT") return `${harness} CLI not found on PATH; install it or sign in, then retry`;
|
|
1371
|
+
return `${harness} failed to start: ${error.message}`;
|
|
1281
1372
|
}
|
|
1282
|
-
|
|
1283
|
-
const
|
|
1284
|
-
|
|
1285
|
-
if (errors.length) throw new ReviewError(["Cannot review:", ...errors.map((error) => `- ${error}`)].join("\n"));
|
|
1373
|
+
function exitError(harness, code, output) {
|
|
1374
|
+
const detail = tail(output);
|
|
1375
|
+
return [`${harness} exited with code ${code ?? "unknown"}`, ...detail ? [detail] : []].join("\n");
|
|
1286
1376
|
}
|
|
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
|
-
};
|
|
1377
|
+
function hasReview(path) {
|
|
1378
|
+
return existsSync3(path) && readFileSync3(path, "utf8").trim().length > 0;
|
|
1308
1379
|
}
|
|
1309
|
-
|
|
1310
|
-
const
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1380
|
+
var runHarness = (run2) => new Promise((resolve) => {
|
|
1381
|
+
const shell = process.platform === "win32";
|
|
1382
|
+
const args = harnessArgs(run2);
|
|
1383
|
+
const child = spawn(run2.harness, shell ? args.map(windowsQuote) : args, {
|
|
1384
|
+
cwd: run2.cwd,
|
|
1385
|
+
env: { ...process.env, ...run2.override?.env },
|
|
1386
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
1387
|
+
shell
|
|
1388
|
+
});
|
|
1389
|
+
let pending = "";
|
|
1390
|
+
let finalText = "";
|
|
1391
|
+
let model;
|
|
1392
|
+
let usage;
|
|
1393
|
+
let lastError = "";
|
|
1394
|
+
let stderr = "";
|
|
1395
|
+
const onLine = (line) => {
|
|
1396
|
+
const event = parseHarnessEvent(run2.harness, line);
|
|
1397
|
+
if (event.kind === "activity") run2.onProgress?.(event.text);
|
|
1398
|
+
if (event.kind === "final") {
|
|
1399
|
+
finalText = event.text;
|
|
1400
|
+
if (event.usage) usage = event.usage;
|
|
1401
|
+
}
|
|
1402
|
+
if (event.kind === "usage") usage = addUsage(usage, event.usage);
|
|
1403
|
+
if (event.kind === "model") model = event.model;
|
|
1404
|
+
if (event.kind === "error") lastError = event.message;
|
|
1329
1405
|
};
|
|
1330
|
-
|
|
1406
|
+
child.stdout.setEncoding("utf8");
|
|
1407
|
+
child.stderr.setEncoding("utf8");
|
|
1408
|
+
child.stdout.on("data", (chunk) => {
|
|
1409
|
+
const lines = (pending + chunk).split("\n");
|
|
1410
|
+
pending = lines.pop() ?? "";
|
|
1411
|
+
lines.forEach(onLine);
|
|
1412
|
+
});
|
|
1413
|
+
child.stderr.on("data", (chunk) => stderr += chunk);
|
|
1414
|
+
child.stdin.on("error", () => {
|
|
1415
|
+
});
|
|
1416
|
+
child.stdin.end(run2.prompt);
|
|
1417
|
+
child.on("error", (error) => resolve({ ok: false, error: spawnError(run2.harness, error) }));
|
|
1418
|
+
child.on("close", (code) => {
|
|
1419
|
+
onLine(pending);
|
|
1420
|
+
if (code !== 0)
|
|
1421
|
+
return resolve({ ok: false, error: exitError(run2.harness, code, [stderr || finalText, lastError].filter(Boolean).join("\n")) });
|
|
1422
|
+
if (run2.harness === "claude") writeFileSync3(run2.outputPath, finalText);
|
|
1423
|
+
if (!hasReview(run2.outputPath)) return resolve({ ok: false, error: `${run2.harness} returned an empty review` });
|
|
1424
|
+
resolve({ ok: true, ...model ? { model } : {}, ...usage ? { usage } : {} });
|
|
1425
|
+
});
|
|
1426
|
+
});
|
|
1427
|
+
|
|
1428
|
+
// src/features/review/preflight.ts
|
|
1429
|
+
import { execFile as execFile2 } from "child_process";
|
|
1331
1430
|
|
|
1332
1431
|
// src/features/review/run.ts
|
|
1333
1432
|
import { existsSync as existsSync6, readFileSync as readFileSync6, rmSync, writeFileSync as writeFileSync6 } from "fs";
|
|
1334
|
-
import { join as
|
|
1433
|
+
import { join as join6 } from "path";
|
|
1335
1434
|
|
|
1336
1435
|
// src/features/review/cachedSlots.ts
|
|
1337
1436
|
import { existsSync as existsSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync4 } from "fs";
|
|
1338
|
-
import { join as
|
|
1437
|
+
import { join as join4 } from "path";
|
|
1339
1438
|
function slotsPath(reviewDir) {
|
|
1340
|
-
return
|
|
1439
|
+
return join4(reviewDir, "slots.json");
|
|
1341
1440
|
}
|
|
1342
1441
|
function isCachedSlots(value) {
|
|
1343
1442
|
return typeof value === "object" && value !== null && Object.values(value).every((slot) => typeof slot === "string");
|
|
@@ -1500,10 +1599,10 @@ ${files.join("\n")}
|
|
|
1500
1599
|
|
|
1501
1600
|
// src/features/review/usedModels.ts
|
|
1502
1601
|
import { existsSync as existsSync5, readFileSync as readFileSync5, writeFileSync as writeFileSync5 } from "fs";
|
|
1503
|
-
import { join as
|
|
1602
|
+
import { join as join5 } from "path";
|
|
1504
1603
|
var repoUrl = "https://github.com/MakerXStudio/yoke";
|
|
1505
1604
|
function modelsPath(reviewDir) {
|
|
1506
|
-
return
|
|
1605
|
+
return join5(reviewDir, "models.json");
|
|
1507
1606
|
}
|
|
1508
1607
|
function isUsedModels(value) {
|
|
1509
1608
|
return typeof value === "object" && value !== null && Object.values(value).every((model) => typeof model === "string");
|
|
@@ -1538,6 +1637,17 @@ _Adversarial review by [yoke](${repoUrl})${used.length ? `: ${used.join("; ")}`
|
|
|
1538
1637
|
}
|
|
1539
1638
|
|
|
1540
1639
|
// src/features/review/run.ts
|
|
1640
|
+
var ReviewFailure = class extends ReviewError {
|
|
1641
|
+
constructor(reason, message) {
|
|
1642
|
+
super(message);
|
|
1643
|
+
this.reason = reason;
|
|
1644
|
+
this.name = "ReviewFailure";
|
|
1645
|
+
}
|
|
1646
|
+
reason;
|
|
1647
|
+
};
|
|
1648
|
+
function stepNames(steps) {
|
|
1649
|
+
return steps.map((step) => step.replace(/(\d)$/, " $1")).join(" and ");
|
|
1650
|
+
}
|
|
1541
1651
|
var reviewers = ["reviewer1", "reviewer2"];
|
|
1542
1652
|
function seconds(since) {
|
|
1543
1653
|
return `${Math.round((Date.now() - since) / 1e3)}s`;
|
|
@@ -1561,6 +1671,7 @@ async function runStep({ name, label, slot, ...step }, context) {
|
|
|
1561
1671
|
status.succeed(`${label} done in ${seconds(started)}`);
|
|
1562
1672
|
recordUsedModel(context.reviewDir, name, harness, slot.model ?? result.model);
|
|
1563
1673
|
recordSlot(context.reviewDir, name, describeSlot(slot));
|
|
1674
|
+
if (result.usage) context.usage[name] = result.usage;
|
|
1564
1675
|
return true;
|
|
1565
1676
|
}
|
|
1566
1677
|
status.fail(`${label} failed after ${seconds(started)}: ${result.error}`);
|
|
@@ -1578,7 +1689,7 @@ async function runOrReuse(step, context) {
|
|
|
1578
1689
|
return { ok: true, ran: false };
|
|
1579
1690
|
}
|
|
1580
1691
|
function reviewPath(context, reviewer) {
|
|
1581
|
-
return
|
|
1692
|
+
return join6(context.reviewDir, `${reviewer}.md`);
|
|
1582
1693
|
}
|
|
1583
1694
|
function reviewerLabel(reviewer, context) {
|
|
1584
1695
|
return `${reviewer} (${describeSlot(context.slots[reviewer])})`;
|
|
@@ -1587,7 +1698,7 @@ function withReviewers(result, context) {
|
|
|
1587
1698
|
const line = reviewers.map((reviewer) => reviewerLabel(reviewer, context)).join(", ");
|
|
1588
1699
|
return { ...result, report: `Reviewers: ${line}
|
|
1589
1700
|
|
|
1590
|
-
${result.report}
|
|
1701
|
+
${result.report}`, usage: context.usage };
|
|
1591
1702
|
}
|
|
1592
1703
|
function synthesisResult(markdown) {
|
|
1593
1704
|
const synthesis = parseSynthesis(markdown);
|
|
@@ -1606,7 +1717,7 @@ function synthesisSlot(succeeded, context) {
|
|
|
1606
1717
|
}
|
|
1607
1718
|
async function synthesise(requestPath, succeeded, reviewersRan, context) {
|
|
1608
1719
|
const slot = synthesisSlot(succeeded, context);
|
|
1609
|
-
const outputPath =
|
|
1720
|
+
const outputPath = join6(context.reviewDir, "synthesis.md");
|
|
1610
1721
|
if (reviewersRan) rmSync(outputPath, { force: true });
|
|
1611
1722
|
const files = reviewers.map((reviewer) => ({
|
|
1612
1723
|
reviewer: reviewerLabel(reviewer, context),
|
|
@@ -1615,11 +1726,13 @@ async function synthesise(requestPath, succeeded, reviewersRan, context) {
|
|
|
1615
1726
|
const prompt = buildSynthesisPrompt(requestPath, files);
|
|
1616
1727
|
if ((await runOrReuse({ name: "synthesis", label: `synthesis (${describeSlot(slot)})`, slot, prompt, outputPath }, context)).ok)
|
|
1617
1728
|
return synthesisResult(readFileSync6(outputPath, "utf8"));
|
|
1729
|
+
if (context.strict) throw new ReviewFailure("synthesis failed", "The synthesis failed; nothing to post.");
|
|
1618
1730
|
context.log("showing the raw reviews instead");
|
|
1619
1731
|
return rawReviews(succeeded, context);
|
|
1620
1732
|
}
|
|
1621
|
-
async function runReview(request,
|
|
1622
|
-
const
|
|
1733
|
+
async function runReview(request, reviewContext) {
|
|
1734
|
+
const context = { ...reviewContext, usage: {} };
|
|
1735
|
+
const requestPath = join6(context.reviewDir, "request.md");
|
|
1623
1736
|
writeFileSync6(requestPath, request);
|
|
1624
1737
|
const prompt = buildReviewerPrompt(requestPath);
|
|
1625
1738
|
const results = await Promise.all(
|
|
@@ -1630,7 +1743,10 @@ async function runReview(request, context) {
|
|
|
1630
1743
|
})
|
|
1631
1744
|
);
|
|
1632
1745
|
const succeeded = reviewers.filter((_, i) => results[i]?.ok);
|
|
1633
|
-
|
|
1746
|
+
const failed = reviewers.filter((reviewer) => !succeeded.includes(reviewer));
|
|
1747
|
+
if (!succeeded.length) throw new ReviewFailure(`${stepNames(failed)} failed`, "Every reviewer failed; nothing to report.");
|
|
1748
|
+
if (context.strict && failed.length)
|
|
1749
|
+
throw new ReviewFailure(`${stepNames(failed)} failed`, `${stepNames(failed)} failed; not synthesising a partial review.`);
|
|
1634
1750
|
const synthesised = await synthesise(
|
|
1635
1751
|
requestPath,
|
|
1636
1752
|
succeeded,
|
|
@@ -1640,75 +1756,123 @@ async function runReview(request, context) {
|
|
|
1640
1756
|
return withReviewers(synthesised, context);
|
|
1641
1757
|
}
|
|
1642
1758
|
|
|
1643
|
-
// src/features/review/
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1759
|
+
// src/features/review/preflight.ts
|
|
1760
|
+
var commandTimeout = 3e4;
|
|
1761
|
+
var probeTimeout = 6e4;
|
|
1762
|
+
var maxBodyChars2 = 300;
|
|
1763
|
+
function exec(harness, args) {
|
|
1764
|
+
return new Promise((resolve) => {
|
|
1765
|
+
const options = { encoding: "utf8", timeout: commandTimeout, shell: process.platform === "win32" };
|
|
1766
|
+
execFile2(harness, args, options, (error, stdout) => resolve({ ok: !error, stdout }));
|
|
1767
|
+
});
|
|
1768
|
+
}
|
|
1769
|
+
async function claudeSignedIn() {
|
|
1770
|
+
const { ok, stdout } = await exec("claude", ["auth", "status", "--json"]);
|
|
1771
|
+
if (!ok) return false;
|
|
1650
1772
|
try {
|
|
1651
|
-
const
|
|
1652
|
-
|
|
1653
|
-
|
|
1773
|
+
const parsed = JSON.parse(stdout);
|
|
1774
|
+
return typeof parsed === "object" && parsed !== null && "loggedIn" in parsed && parsed.loggedIn === true;
|
|
1775
|
+
} catch {
|
|
1776
|
+
return false;
|
|
1777
|
+
}
|
|
1778
|
+
}
|
|
1779
|
+
async function reach({ url, headers, body }) {
|
|
1780
|
+
try {
|
|
1781
|
+
const response = await fetch(url, {
|
|
1782
|
+
method: "POST",
|
|
1783
|
+
headers: { "Content-Type": "application/json", ...headers },
|
|
1784
|
+
body: JSON.stringify(body),
|
|
1785
|
+
signal: AbortSignal.timeout(probeTimeout)
|
|
1786
|
+
});
|
|
1787
|
+
if (response.ok) return void 0;
|
|
1788
|
+
const text2 = (await response.text()).trim().slice(0, maxBodyChars2);
|
|
1789
|
+
return `HTTP ${response.status}${text2 ? `: ${text2}` : ""}`;
|
|
1654
1790
|
} catch (error) {
|
|
1655
|
-
|
|
1656
|
-
|
|
1791
|
+
if (!(error instanceof Error)) return String(error);
|
|
1792
|
+
const cause = error.cause instanceof Error ? error.cause.message : "";
|
|
1793
|
+
return cause ? `${error.message} (${cause})` : error.message;
|
|
1657
1794
|
}
|
|
1658
1795
|
}
|
|
1659
|
-
|
|
1796
|
+
var checks = {
|
|
1797
|
+
onPath: async (harness) => (await exec(harness, ["--version"])).ok,
|
|
1798
|
+
signedIn: async (harness) => harness === "claude" ? claudeSignedIn() : (await exec("codex", ["login", "status"])).ok,
|
|
1799
|
+
reach
|
|
1800
|
+
};
|
|
1801
|
+
var loginCommand = { claude: "claude auth login", codex: "codex login" };
|
|
1802
|
+
function signedOut({ slot: { harness }, keys }) {
|
|
1803
|
+
const settings = keys.map((key) => `${key}.provider and ${key}.model`).join(", and ");
|
|
1804
|
+
return `${harness} is not signed in (${keys.join(", ")}); run \`${loginCommand[harness]}\`, or run it through LiteLLM or Foundry by setting ${settings} (see \`yoke models\`)`;
|
|
1805
|
+
}
|
|
1806
|
+
async function slotProblem(use, check) {
|
|
1807
|
+
const { harness, model, probe } = use.slot;
|
|
1808
|
+
if (!await check.onPath(harness)) return `${harness} CLI not found on PATH; install it`;
|
|
1809
|
+
if (!probe) return await check.signedIn(harness) ? void 0 : signedOut(use);
|
|
1810
|
+
const error = await check.reach(probe);
|
|
1811
|
+
return error && `${harness} model "${model}" at ${probe.url}: ${error}`;
|
|
1812
|
+
}
|
|
1813
|
+
function slotsToRun(slots2) {
|
|
1814
|
+
const uses = [
|
|
1815
|
+
["review.reviewer1", slots2.reviewer1],
|
|
1816
|
+
["review.reviewer2", slots2.reviewer2],
|
|
1817
|
+
["review.synthesis", slots2.synthesis ?? { harness: slots2.reviewer1.harness }]
|
|
1818
|
+
];
|
|
1819
|
+
const distinct = /* @__PURE__ */ new Map();
|
|
1820
|
+
for (const [key, slot] of uses) {
|
|
1821
|
+
const id = `${slot.harness}:${slot.provider ?? "own"}:${slot.model ?? ""}`;
|
|
1822
|
+
const use = distinct.get(id);
|
|
1823
|
+
if (use) use.keys.push(key);
|
|
1824
|
+
else distinct.set(id, { slot, keys: [key] });
|
|
1825
|
+
}
|
|
1826
|
+
return [...distinct.values()];
|
|
1827
|
+
}
|
|
1828
|
+
async function assertViable(slots2, check = checks) {
|
|
1829
|
+
const problems = await Promise.all(slotsToRun(slots2).map((use) => slotProblem(use, check)));
|
|
1830
|
+
const errors = problems.filter((problem) => problem !== void 0);
|
|
1831
|
+
if (errors.length)
|
|
1832
|
+
throw new ReviewFailure("a reviewer could not run", ["Cannot review:", ...errors.map((error) => `- ${error}`)].join("\n"));
|
|
1833
|
+
}
|
|
1834
|
+
|
|
1835
|
+
// src/features/review/reviewLog.ts
|
|
1836
|
+
import { appendFileSync as appendFileSync2, mkdirSync as mkdirSync3 } from "fs";
|
|
1837
|
+
import { join as join7 } from "path";
|
|
1838
|
+
function reviewLog(command = process.argv.slice(2), now = () => /* @__PURE__ */ new Date()) {
|
|
1839
|
+
let path;
|
|
1840
|
+
let buffered = [];
|
|
1660
1841
|
return {
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
return { update, succeed: log, fail: log };
|
|
1842
|
+
append: (line) => {
|
|
1843
|
+
if (path) appendFileSync2(path, `${line}
|
|
1844
|
+
`);
|
|
1845
|
+
else buffered.push(line);
|
|
1666
1846
|
},
|
|
1667
|
-
|
|
1847
|
+
attach: (reviewDir) => {
|
|
1848
|
+
mkdirSync3(reviewDir, { recursive: true });
|
|
1849
|
+
path = join7(reviewDir, "review.log");
|
|
1850
|
+
appendFileSync2(path, `${["", `=== ${now().toISOString()} ===`, `$ yoke ${command.join(" ")}`, ...buffered].join("\n")}
|
|
1851
|
+
`);
|
|
1852
|
+
buffered = [];
|
|
1668
1853
|
}
|
|
1669
1854
|
};
|
|
1670
1855
|
}
|
|
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;
|
|
1856
|
+
function logged(ui, append) {
|
|
1857
|
+
const both = (show) => (line) => {
|
|
1858
|
+
append(line);
|
|
1859
|
+
show(line);
|
|
1691
1860
|
};
|
|
1692
1861
|
return {
|
|
1693
|
-
log:
|
|
1862
|
+
log: both(ui.log),
|
|
1694
1863
|
track: (label) => {
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
timer ??= setInterval(() => {
|
|
1698
|
-
frame = (frame + 1) % frames.length;
|
|
1699
|
-
render();
|
|
1700
|
-
}, tickMs);
|
|
1701
|
-
render();
|
|
1864
|
+
append(`${label}\u2026`);
|
|
1865
|
+
const status = ui.track(label);
|
|
1702
1866
|
return {
|
|
1703
|
-
update: (activity) =>
|
|
1704
|
-
|
|
1705
|
-
|
|
1867
|
+
update: (activity) => {
|
|
1868
|
+
append(`${label}: ${activity}`);
|
|
1869
|
+
status.update(activity);
|
|
1870
|
+
},
|
|
1871
|
+
succeed: both(status.succeed),
|
|
1872
|
+
fail: both(status.fail)
|
|
1706
1873
|
};
|
|
1707
1874
|
},
|
|
1708
|
-
stop:
|
|
1709
|
-
if (timer) clearInterval(timer);
|
|
1710
|
-
update.done();
|
|
1711
|
-
}
|
|
1875
|
+
stop: ui.stop
|
|
1712
1876
|
};
|
|
1713
1877
|
}
|
|
1714
1878
|
|
|
@@ -1723,12 +1887,12 @@ function prepareRunDir(reviewDir, force) {
|
|
|
1723
1887
|
mkdirSync4(reviewDir, { recursive: true });
|
|
1724
1888
|
if (force) for (const name of cachedOutputs) rmSync2(join8(reviewDir, name), { force: true });
|
|
1725
1889
|
}
|
|
1726
|
-
async function reviewAtHead(pr2, reviewDir, request, slots2, ui) {
|
|
1890
|
+
async function reviewAtHead(pr2, reviewDir, request, slots2, ui, strict) {
|
|
1727
1891
|
const checkoutDir = mkdtempSync(join8(tmpdir(), "yoke-review-"));
|
|
1728
1892
|
try {
|
|
1729
1893
|
const checkout = await timed(ui, `checking out ${pr2.headRefOid.slice(0, 7)}`, () => checkoutHead(pr2, checkoutDir));
|
|
1730
1894
|
try {
|
|
1731
|
-
return await runReview(request, { cwd: checkout.cwd, reviewDir, slots: slots2, runHarness, log: ui.log, track: ui.track });
|
|
1895
|
+
return await runReview(request, { cwd: checkout.cwd, reviewDir, slots: slots2, runHarness, log: ui.log, track: ui.track, strict });
|
|
1732
1896
|
} finally {
|
|
1733
1897
|
await checkout.remove();
|
|
1734
1898
|
}
|
|
@@ -1763,24 +1927,40 @@ async function reviewPr(number, settings, { post, force }, io) {
|
|
|
1763
1927
|
const reviewDir = reviewRunDir(pr2);
|
|
1764
1928
|
prepareRunDir(reviewDir, force);
|
|
1765
1929
|
file.attach(reviewDir);
|
|
1766
|
-
const
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1930
|
+
const ci = post && isCi();
|
|
1931
|
+
const review = async () => {
|
|
1932
|
+
const path = join8(reviewDir, "review.md");
|
|
1933
|
+
const saved = savedReview(path, post);
|
|
1934
|
+
if (saved) {
|
|
1935
|
+
log(`posting the saved review of #${pr2.number} at ${pr2.headRefOid.slice(0, 7)}`);
|
|
1936
|
+
out(formatSynthesis(saved));
|
|
1937
|
+
return { synthesis: saved, usage: {} };
|
|
1938
|
+
}
|
|
1772
1939
|
await timed(ui, "checking each reviewer can run", () => assertViable(slots2));
|
|
1773
1940
|
log(`review folder: ${reviewDir}`);
|
|
1774
|
-
const result = await reviewAtHead(pr2, reviewDir, buildRequest(pr2, diff, prior), slots2, ui);
|
|
1941
|
+
const result = await reviewAtHead(pr2, reviewDir, buildRequest(pr2, diff, prior), slots2, ui, ci);
|
|
1775
1942
|
out(result.report);
|
|
1776
|
-
synthesis = result.synthesis;
|
|
1777
1943
|
saveReview(path, result.markdown);
|
|
1944
|
+
if (post && !result.synthesis)
|
|
1945
|
+
throw new ReviewFailure(
|
|
1946
|
+
"the synthesis could not be parsed",
|
|
1947
|
+
"Not posting: the synthesis did not produce findings in the expected format."
|
|
1948
|
+
);
|
|
1949
|
+
return result;
|
|
1950
|
+
};
|
|
1951
|
+
const footer = () => modelsFooter(readUsedModels(reviewDir));
|
|
1952
|
+
let findings;
|
|
1953
|
+
try {
|
|
1954
|
+
const { synthesis: synthesis2, usage } = await review();
|
|
1955
|
+
findings = synthesis2;
|
|
1956
|
+
if (ci && synthesis2) writeJobSummary(synthesis2.summary, usage, log);
|
|
1957
|
+
} catch (error) {
|
|
1958
|
+
if (ci && error instanceof ReviewFailure) await reportFailure(pr2, error.reason, ui, { api: github, log, footer: footer() });
|
|
1959
|
+
throw error;
|
|
1778
1960
|
}
|
|
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 }));
|
|
1961
|
+
if (!post || !findings) return;
|
|
1962
|
+
const synthesis = findings;
|
|
1963
|
+
await timed(ui, `posting to #${pr2.number}`, () => postFindings(pr2, synthesis, diff, prior, { api: github, log, footer: footer(), ci }));
|
|
1784
1964
|
}
|
|
1785
1965
|
|
|
1786
1966
|
// src/features/sync/sync.ts
|
|
@@ -1822,6 +2002,7 @@ var reviewCiSkill = {
|
|
|
1822
2002
|
name: "review-ci",
|
|
1823
2003
|
description: "Add yoke's adversarial PR review workflow to this repo and set the secrets it needs.",
|
|
1824
2004
|
disableModelInvocation: true,
|
|
2005
|
+
userLevel: true,
|
|
1825
2006
|
body: `
|
|
1826
2007
|
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
2008
|
|
|
@@ -1857,10 +2038,10 @@ function skillsRoot(user, cwd = process.cwd(), home = homedir3()) {
|
|
|
1857
2038
|
function isOwnedByUser(skillFile) {
|
|
1858
2039
|
return existsSync8(skillFile) && !readFileSync8(skillFile, "utf8").includes(ownerMarker);
|
|
1859
2040
|
}
|
|
1860
|
-
function syncSkills(destination, skills = bundledSkills) {
|
|
2041
|
+
function syncSkills(destination, userDestination, skills = bundledSkills) {
|
|
1861
2042
|
const result = { written: [], skipped: [] };
|
|
1862
2043
|
for (const skill of skills) {
|
|
1863
|
-
const target = join9(destination, skill.name);
|
|
2044
|
+
const target = join9(skill.userLevel ? userDestination : destination, skill.name);
|
|
1864
2045
|
const skillFile = join9(target, "SKILL.md");
|
|
1865
2046
|
if (isOwnedByUser(skillFile)) {
|
|
1866
2047
|
result.skipped.push(target);
|
|
@@ -1878,8 +2059,9 @@ var { version } = package_default;
|
|
|
1878
2059
|
var program = new Command("yoke").description("Lean Claude Code skills and the CLI they call.").version(version);
|
|
1879
2060
|
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
2061
|
const user = options.user ?? false;
|
|
1881
|
-
const
|
|
1882
|
-
const
|
|
2062
|
+
const cwd = process.cwd();
|
|
2063
|
+
const display = (path) => path.startsWith(cwd) ? relative(cwd, path) : path;
|
|
2064
|
+
const { written, skipped } = syncSkills(skillsRoot(user), skillsRoot(true));
|
|
1883
2065
|
for (const file of written) console.log(`wrote ${display(file)}`);
|
|
1884
2066
|
for (const dir of skipped) console.log(`skipped ${display(dir)} (your own skill; delete it to take yoke's)`);
|
|
1885
2067
|
});
|