@makerx/yoke 0.6.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/README.md +2 -0
- package/dist/cli.js +724 -405
- package/package.json +4 -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",
|
|
@@ -57,6 +57,9 @@ var package_default = {
|
|
|
57
57
|
vitest: "^5.0.5",
|
|
58
58
|
yaml: "^2.9.1"
|
|
59
59
|
},
|
|
60
|
+
overrides: {
|
|
61
|
+
minimatch: "^11.0.3"
|
|
62
|
+
},
|
|
60
63
|
engines: {
|
|
61
64
|
node: ">=24"
|
|
62
65
|
}
|
|
@@ -133,14 +136,14 @@ function wrap(text2, width) {
|
|
|
133
136
|
return line ? [...lines, line] : lines;
|
|
134
137
|
}
|
|
135
138
|
function listSettings(settings, columns = Infinity) {
|
|
136
|
-
const
|
|
139
|
+
const rows2 = settingKeys.map((key) => ({
|
|
137
140
|
key,
|
|
138
141
|
value: displayValue(key, settings[key]),
|
|
139
142
|
description: settingDescription(key)
|
|
140
143
|
}));
|
|
141
|
-
const keyWidth = Math.max(...
|
|
144
|
+
const keyWidth = Math.max(...rows2.map(({ key }) => key.length));
|
|
142
145
|
const indent = " ".repeat(keyWidth + gap.length);
|
|
143
|
-
const blocks =
|
|
146
|
+
const blocks = rows2.map(
|
|
144
147
|
({ key, value, description }) => [`${key.padEnd(keyWidth)}${gap}${value}`, ...wrap(description, columns - indent.length).map((line) => indent + line)].join("\n")
|
|
145
148
|
);
|
|
146
149
|
return blocks.join("\n\n");
|
|
@@ -402,6 +405,11 @@ function raisePr({ title, body, base, draft }, run2 = runGh) {
|
|
|
402
405
|
return output.at(-1) ?? "";
|
|
403
406
|
}
|
|
404
407
|
|
|
408
|
+
// src/features/review/ci.ts
|
|
409
|
+
import { execFileSync as execFileSync2 } from "child_process";
|
|
410
|
+
import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
411
|
+
import { dirname as dirname2, join as join3 } from "path";
|
|
412
|
+
|
|
405
413
|
// src/features/review/pr.ts
|
|
406
414
|
import { execFile } from "child_process";
|
|
407
415
|
import { join as join2 } from "path";
|
|
@@ -607,6 +615,101 @@ function buildOverride({ harness, model }, provider) {
|
|
|
607
615
|
return buildClaudeOverride(provider, endpoints, model);
|
|
608
616
|
}
|
|
609
617
|
|
|
618
|
+
// src/features/review/ci.ts
|
|
619
|
+
var reviewWorkflowRef = "MakerXStudio/yoke/.github/workflows/review.yml@main";
|
|
620
|
+
var callerWorkflowPath = join3(".github", "workflows", "review-pr.yml");
|
|
621
|
+
var ciSecrets = ["REVIEW_CI_BASE_URL", "REVIEW_CI_API_KEY"];
|
|
622
|
+
var slots = [
|
|
623
|
+
{ slot: "reviewer1", defaultHarness: "claude", required: true },
|
|
624
|
+
{ slot: "reviewer2", defaultHarness: "codex", required: true },
|
|
625
|
+
{ slot: "synthesis", defaultHarness: void 0, required: false }
|
|
626
|
+
];
|
|
627
|
+
function slotInputs(settings, errors) {
|
|
628
|
+
const inputs = [];
|
|
629
|
+
for (const { slot, defaultHarness, required } of slots) {
|
|
630
|
+
const harnessKey = `review.${slot}.harness`;
|
|
631
|
+
const modelKey = `review.${slot}.model`;
|
|
632
|
+
const harness = settings[harnessKey] ?? defaultHarness;
|
|
633
|
+
const model = settings[modelKey];
|
|
634
|
+
if (harness && !isHarness(harness))
|
|
635
|
+
errors.push(`${harnessKey} "${harness}" must be claude or codex; ${howToSet(harnessKey, "<claude|codex>")}`);
|
|
636
|
+
if (required && !model) errors.push(`${modelKey} is required; ${howToSet(modelKey, "<model>")} (see \`yoke models\`)`);
|
|
637
|
+
if (!required && harness && !model)
|
|
638
|
+
errors.push(`${modelKey} is required when ${harnessKey} is set; ${howToSet(modelKey, "<model>")} (see \`yoke models\`)`);
|
|
639
|
+
if (harness) inputs.push([`${slot}-harness`, harness]);
|
|
640
|
+
if (model) inputs.push([`${slot}-model`, model]);
|
|
641
|
+
}
|
|
642
|
+
return inputs;
|
|
643
|
+
}
|
|
644
|
+
function callerWorkflow(settings, providerFlag) {
|
|
645
|
+
const errors = [];
|
|
646
|
+
const provider = providerFlag ?? settings["review.reviewer1.provider"] ?? "litellm";
|
|
647
|
+
if (!isProviderKind(provider)) errors.push(`provider "${provider}" must be ${providerKinds.join(" or ")}; pass --provider`);
|
|
648
|
+
const inputs = slotInputs(settings, errors);
|
|
649
|
+
if (errors.length || !isProviderKind(provider))
|
|
650
|
+
throw new ReviewError(["Cannot write the review workflow:", ...errors.map((error) => `- ${error}`)].join("\n"));
|
|
651
|
+
return { provider, inputs: [["provider", provider], ...inputs] };
|
|
652
|
+
}
|
|
653
|
+
function yamlValue(value) {
|
|
654
|
+
return /^[\w./:@-]+$/.test(value) && !/^([-+.\d_eE]+|0[xo][\da-f]+|true|false|yes|no|on|off|y|n|null|~)$/i.test(value) ? value : JSON.stringify(value);
|
|
655
|
+
}
|
|
656
|
+
function renderCallerWorkflow({ inputs }) {
|
|
657
|
+
return [
|
|
658
|
+
"name: Review PR",
|
|
659
|
+
"",
|
|
660
|
+
"on:",
|
|
661
|
+
" pull_request:",
|
|
662
|
+
" types: [opened, reopened, ready_for_review]",
|
|
663
|
+
"",
|
|
664
|
+
"permissions:",
|
|
665
|
+
" contents: read",
|
|
666
|
+
" pull-requests: write",
|
|
667
|
+
"",
|
|
668
|
+
"jobs:",
|
|
669
|
+
" review:",
|
|
670
|
+
` uses: ${reviewWorkflowRef}`,
|
|
671
|
+
" with:",
|
|
672
|
+
...inputs.map(([name, value]) => ` ${name}: ${yamlValue(value)}`),
|
|
673
|
+
" secrets: inherit",
|
|
674
|
+
""
|
|
675
|
+
].join("\n");
|
|
676
|
+
}
|
|
677
|
+
function shellQuote(value) {
|
|
678
|
+
return `'${value.replaceAll("'", `'\\''`)}'`;
|
|
679
|
+
}
|
|
680
|
+
function secretCommands(settings, provider) {
|
|
681
|
+
const baseUrl = settings[`${provider}.baseUrl`];
|
|
682
|
+
return [`gh secret set REVIEW_CI_BASE_URL${baseUrl ? ` --body ${shellQuote(baseUrl)}` : ""}`, "gh secret set REVIEW_CI_API_KEY"];
|
|
683
|
+
}
|
|
684
|
+
function repoRoot(cwd = process.cwd()) {
|
|
685
|
+
try {
|
|
686
|
+
return execFileSync2("git", ["rev-parse", "--show-toplevel"], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
687
|
+
} catch {
|
|
688
|
+
throw new ReviewError("Not in a git repository; run `yoke review-ci` from the repo to review.");
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
function writeCallerWorkflow(root, content, force) {
|
|
692
|
+
const path = join3(root, callerWorkflowPath);
|
|
693
|
+
if (existsSync2(path)) {
|
|
694
|
+
if (readFileSync2(path, "utf8") === content) return "unchanged";
|
|
695
|
+
if (!force) throw new ReviewError(`${callerWorkflowPath} already exists and differs; pass --force to overwrite it.`);
|
|
696
|
+
}
|
|
697
|
+
mkdirSync2(dirname2(path), { recursive: true });
|
|
698
|
+
writeFileSync2(path, content);
|
|
699
|
+
return "wrote";
|
|
700
|
+
}
|
|
701
|
+
function reviewCiReport(outcome, commands) {
|
|
702
|
+
const action = outcome === "wrote" ? "wrote" : "kept";
|
|
703
|
+
return [
|
|
704
|
+
`${action} ${callerWorkflowPath}`,
|
|
705
|
+
"",
|
|
706
|
+
`The workflow reads ${ciSecrets.join(" and ")}. Set them on the repo (or the org) with:`,
|
|
707
|
+
...commands.map((command) => ` ${command}`),
|
|
708
|
+
"",
|
|
709
|
+
"Commit and push the workflow; each PR opened from this repo then gets a review."
|
|
710
|
+
].join("\n");
|
|
711
|
+
}
|
|
712
|
+
|
|
610
713
|
// src/features/review/slots.ts
|
|
611
714
|
var slotNames = ["reviewer1", "reviewer2", "synthesis"];
|
|
612
715
|
function readProvider(settings, kind) {
|
|
@@ -716,9 +819,40 @@ async function listModels(settings, fetchModels = fetch) {
|
|
|
716
819
|
}
|
|
717
820
|
|
|
718
821
|
// src/features/review/review.ts
|
|
719
|
-
import { existsSync as
|
|
822
|
+
import { existsSync as existsSync7, mkdirSync as mkdirSync4, mkdtempSync, readFileSync as readFileSync7, rmSync as rmSync2, writeFileSync as writeFileSync7 } from "fs";
|
|
720
823
|
import { homedir as homedir2, tmpdir } from "os";
|
|
721
|
-
import { join as
|
|
824
|
+
import { join as join8 } from "path";
|
|
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
|
+
}
|
|
722
856
|
|
|
723
857
|
// src/features/review/findings.ts
|
|
724
858
|
var severities = ["blocker", "major", "minor", "nit"];
|
|
@@ -778,184 +912,6 @@ function formatSynthesis({ summary, findings }) {
|
|
|
778
912
|
return lines.join("\n").trimEnd();
|
|
779
913
|
}
|
|
780
914
|
|
|
781
|
-
// src/features/review/harness.ts
|
|
782
|
-
import { spawn } from "child_process";
|
|
783
|
-
import { existsSync as existsSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
784
|
-
|
|
785
|
-
// src/features/review/progress.ts
|
|
786
|
-
var maxSummary = 80;
|
|
787
|
-
function isJson(value) {
|
|
788
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
789
|
-
}
|
|
790
|
-
function truncate(value) {
|
|
791
|
-
const collapsed = value.replace(/\s+/g, " ").trim();
|
|
792
|
-
return collapsed.length <= maxSummary ? collapsed : `${collapsed.slice(0, maxSummary - 1)}\u2026`;
|
|
793
|
-
}
|
|
794
|
-
function text(input, key) {
|
|
795
|
-
const value = input[key];
|
|
796
|
-
return typeof value === "string" ? value : void 0;
|
|
797
|
-
}
|
|
798
|
-
function toolSummary(input) {
|
|
799
|
-
if (!isJson(input)) return "";
|
|
800
|
-
const pattern = text(input, "pattern");
|
|
801
|
-
if (pattern) {
|
|
802
|
-
const scope = text(input, "path");
|
|
803
|
-
return scope ? `${pattern} in ${scope}` : pattern;
|
|
804
|
-
}
|
|
805
|
-
const found = text(input, "file_path") ?? text(input, "path") ?? text(input, "url");
|
|
806
|
-
if (found) return found;
|
|
807
|
-
const described = text(input, "command") ?? text(input, "query") ?? text(input, "description");
|
|
808
|
-
return described ? truncate(described) : "";
|
|
809
|
-
}
|
|
810
|
-
function describeTool(tool, summary) {
|
|
811
|
-
return summary ? `${tool} ${summary}` : tool;
|
|
812
|
-
}
|
|
813
|
-
function claudeEvent(event) {
|
|
814
|
-
if (event.type === "result" && typeof event.result === "string") return { kind: "final", text: event.result };
|
|
815
|
-
if (event.type === "system" && event.subtype === "init" && typeof event.model === "string") return { kind: "model", model: event.model };
|
|
816
|
-
const content = isJson(event.message) ? event.message.content : void 0;
|
|
817
|
-
if (event.type !== "assistant" || !Array.isArray(content)) return { kind: "ignore" };
|
|
818
|
-
const uses = content.filter((block) => isJson(block) && block.type === "tool_use");
|
|
819
|
-
if (!uses.length) return { kind: "ignore" };
|
|
820
|
-
return { kind: "activity", text: uses.map((use) => describeTool(text(use, "name") ?? "tool", toolSummary(use.input))).join(", ") };
|
|
821
|
-
}
|
|
822
|
-
function unwrapShell(command) {
|
|
823
|
-
const wrapped = command.match(/^\/bin\/(?:ba|z)?sh\s+-lc\s+["'](.*)["']\s*$/s);
|
|
824
|
-
return truncate(wrapped?.[1]?.replace(/\\"/g, '"') ?? command);
|
|
825
|
-
}
|
|
826
|
-
function codexError(event) {
|
|
827
|
-
if (event.type === "error") return text(event, "message");
|
|
828
|
-
if (event.type === "turn.failed" && isJson(event.error)) return text(event.error, "message");
|
|
829
|
-
return void 0;
|
|
830
|
-
}
|
|
831
|
-
function codexEvent(event) {
|
|
832
|
-
const message = codexError(event);
|
|
833
|
-
if (message) return { kind: "error", message };
|
|
834
|
-
const item = event.item;
|
|
835
|
-
if (event.type !== "item.started" || !isJson(item)) return { kind: "ignore" };
|
|
836
|
-
const command = text(item, "command");
|
|
837
|
-
if (item.type === "command_execution" && command) return { kind: "activity", text: describeTool("shell", unwrapShell(command)) };
|
|
838
|
-
return { kind: "ignore" };
|
|
839
|
-
}
|
|
840
|
-
function parseHarnessEvent(harness, line) {
|
|
841
|
-
let event;
|
|
842
|
-
try {
|
|
843
|
-
event = JSON.parse(line);
|
|
844
|
-
} catch {
|
|
845
|
-
return { kind: "ignore" };
|
|
846
|
-
}
|
|
847
|
-
if (!isJson(event)) return { kind: "ignore" };
|
|
848
|
-
return harness === "claude" ? claudeEvent(event) : codexEvent(event);
|
|
849
|
-
}
|
|
850
|
-
|
|
851
|
-
// src/features/review/harness.ts
|
|
852
|
-
var tailLines = 20;
|
|
853
|
-
function harnessArgs({ harness, reviewDir, outputPath, override }) {
|
|
854
|
-
const extra = override?.args ?? [];
|
|
855
|
-
if (harness === "codex") return ["exec", "--sandbox", "read-only", "--json", "--output-last-message", outputPath, ...extra, "-"];
|
|
856
|
-
return [
|
|
857
|
-
"-p",
|
|
858
|
-
"--add-dir",
|
|
859
|
-
reviewDir,
|
|
860
|
-
"--disallowedTools",
|
|
861
|
-
"Edit,Write,NotebookEdit",
|
|
862
|
-
"--output-format",
|
|
863
|
-
"stream-json",
|
|
864
|
-
"--verbose",
|
|
865
|
-
...extra
|
|
866
|
-
];
|
|
867
|
-
}
|
|
868
|
-
function windowsQuote(arg) {
|
|
869
|
-
return /[\s"]/.test(arg) ? `"${arg.replace(/"/g, '\\"')}"` : arg;
|
|
870
|
-
}
|
|
871
|
-
function tail(text2) {
|
|
872
|
-
return text2.trim().split("\n").slice(-tailLines).join("\n");
|
|
873
|
-
}
|
|
874
|
-
function spawnError(harness, error) {
|
|
875
|
-
if (error.code === "ENOENT") return `${harness} CLI not found on PATH; install it or sign in, then retry`;
|
|
876
|
-
return `${harness} failed to start: ${error.message}`;
|
|
877
|
-
}
|
|
878
|
-
function exitError(harness, code, output) {
|
|
879
|
-
const detail = tail(output);
|
|
880
|
-
return [`${harness} exited with code ${code ?? "unknown"}`, ...detail ? [detail] : []].join("\n");
|
|
881
|
-
}
|
|
882
|
-
function hasReview(path) {
|
|
883
|
-
return existsSync2(path) && readFileSync2(path, "utf8").trim().length > 0;
|
|
884
|
-
}
|
|
885
|
-
var runHarness = (run2) => new Promise((resolve) => {
|
|
886
|
-
const shell = process.platform === "win32";
|
|
887
|
-
const args = harnessArgs(run2);
|
|
888
|
-
const child = spawn(run2.harness, shell ? args.map(windowsQuote) : args, {
|
|
889
|
-
cwd: run2.cwd,
|
|
890
|
-
env: { ...process.env, ...run2.override?.env },
|
|
891
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
892
|
-
shell
|
|
893
|
-
});
|
|
894
|
-
let pending = "";
|
|
895
|
-
let finalText = "";
|
|
896
|
-
let model;
|
|
897
|
-
let lastError = "";
|
|
898
|
-
let stderr = "";
|
|
899
|
-
const onLine = (line) => {
|
|
900
|
-
const event = parseHarnessEvent(run2.harness, line);
|
|
901
|
-
if (event.kind === "activity") run2.onProgress?.(event.text);
|
|
902
|
-
if (event.kind === "final") finalText = event.text;
|
|
903
|
-
if (event.kind === "model") model = event.model;
|
|
904
|
-
if (event.kind === "error") lastError = event.message;
|
|
905
|
-
};
|
|
906
|
-
child.stdout.setEncoding("utf8");
|
|
907
|
-
child.stderr.setEncoding("utf8");
|
|
908
|
-
child.stdout.on("data", (chunk) => {
|
|
909
|
-
const lines = (pending + chunk).split("\n");
|
|
910
|
-
pending = lines.pop() ?? "";
|
|
911
|
-
lines.forEach(onLine);
|
|
912
|
-
});
|
|
913
|
-
child.stderr.on("data", (chunk) => stderr += chunk);
|
|
914
|
-
child.stdin.on("error", () => {
|
|
915
|
-
});
|
|
916
|
-
child.stdin.end(run2.prompt);
|
|
917
|
-
child.on("error", (error) => resolve({ ok: false, error: spawnError(run2.harness, error) }));
|
|
918
|
-
child.on("close", (code) => {
|
|
919
|
-
onLine(pending);
|
|
920
|
-
if (code !== 0)
|
|
921
|
-
return resolve({ ok: false, error: exitError(run2.harness, code, [stderr || finalText, lastError].filter(Boolean).join("\n")) });
|
|
922
|
-
if (run2.harness === "claude") writeFileSync2(run2.outputPath, finalText);
|
|
923
|
-
if (!hasReview(run2.outputPath)) return resolve({ ok: false, error: `${run2.harness} returned an empty review` });
|
|
924
|
-
resolve(model ? { ok: true, model } : { ok: true });
|
|
925
|
-
});
|
|
926
|
-
});
|
|
927
|
-
|
|
928
|
-
// src/features/review/comments.ts
|
|
929
|
-
var markerPrefix = "<!-- yoke-review:";
|
|
930
|
-
function slug(text2) {
|
|
931
|
-
return text2.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
932
|
-
}
|
|
933
|
-
function findingMarker({ file, title }) {
|
|
934
|
-
return `${markerPrefix} ${file ?? "n/a"}#${slug(title)} -->`;
|
|
935
|
-
}
|
|
936
|
-
function hasMarker(prior, marker) {
|
|
937
|
-
return prior.some((comment) => comment.body.includes(marker));
|
|
938
|
-
}
|
|
939
|
-
function withoutMarkers(body) {
|
|
940
|
-
return body.split("\n").filter((line) => !line.trimStart().startsWith(markerPrefix)).join("\n").trim();
|
|
941
|
-
}
|
|
942
|
-
function heading({ path, line }) {
|
|
943
|
-
if (!path) return "### PR review";
|
|
944
|
-
return line === null ? `### ${path}` : `### ${path}:${line}`;
|
|
945
|
-
}
|
|
946
|
-
function formatPriorComments(prior) {
|
|
947
|
-
if (!prior.length) return "";
|
|
948
|
-
const blocks = prior.map((comment) => `${heading(comment)}
|
|
949
|
-
|
|
950
|
-
**${comment.author}**: ${withoutMarkers(comment.body)}`);
|
|
951
|
-
return `## Prior review comments
|
|
952
|
-
|
|
953
|
-
The PR already has the review comments below, including resolved and outdated threads. Do not re-raise a finding a prior comment substantively covers.
|
|
954
|
-
|
|
955
|
-
${blocks.join("\n\n")}
|
|
956
|
-
`;
|
|
957
|
-
}
|
|
958
|
-
|
|
959
915
|
// src/features/review/request.ts
|
|
960
916
|
var fileHeader = /^\+\+\+ (.+)$/;
|
|
961
917
|
var hunkHeader = /^@@ -\d+(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/;
|
|
@@ -1065,14 +1021,19 @@ function outsideEntry(finding) {
|
|
|
1065
1021
|
const entry = [`- **${finding.severity ?? "finding"}** ${location} \u2014 ${finding.title}`, ...details(finding).map((d) => ` ${d}`)];
|
|
1066
1022
|
return [...entry, ` ${findingMarker(finding)}`].join("\n");
|
|
1067
1023
|
}
|
|
1068
|
-
|
|
1069
|
-
|
|
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] : []];
|
|
1070
1028
|
if (outside.length) sections.push(`### Findings outside the diff
|
|
1071
1029
|
|
|
1072
1030
|
${outside.map(outsideEntry).join("\n")}`);
|
|
1073
1031
|
if (footer) sections.push(footer);
|
|
1074
1032
|
return sections.join("\n\n");
|
|
1075
1033
|
}
|
|
1034
|
+
function postFooter(footer, ci) {
|
|
1035
|
+
return [...footer ? [footer] : [], ...ci ? [`_${rerunNote}_`] : []].join("\n\n");
|
|
1036
|
+
}
|
|
1076
1037
|
async function addComments(review, comments, { api, log }) {
|
|
1077
1038
|
const rejected = [];
|
|
1078
1039
|
for (const { finding, ...comment } of comments) {
|
|
@@ -1086,152 +1047,393 @@ async function addComments(review, comments, { api, log }) {
|
|
|
1086
1047
|
}
|
|
1087
1048
|
return rejected;
|
|
1088
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
|
+
}
|
|
1089
1073
|
async function postFindings(pr2, synthesis, diff, prior, deps) {
|
|
1090
|
-
const { api, log } = deps;
|
|
1074
|
+
const { api, log, ci = false } = deps;
|
|
1091
1075
|
const { comments, outside, skipped } = planPost(synthesis, diff, prior);
|
|
1092
1076
|
if (skipped.length) log(`skipped ${skipped.length} finding(s) already raised on the PR`);
|
|
1093
|
-
|
|
1077
|
+
const nothingNew = !comments.length && !outside.length;
|
|
1078
|
+
if (nothingNew && !ci) return log("nothing new to post");
|
|
1079
|
+
const footer = postFooter(deps.footer, ci);
|
|
1094
1080
|
const review = await api.open(pr2);
|
|
1095
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
|
+
}
|
|
1096
1086
|
const rejected = await addComments(review, comments, deps);
|
|
1097
|
-
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));
|
|
1098
1088
|
log(`posted ${comments.length - rejected.length} inline comment(s) and ${outside.length + rejected.length} in the review body: ${url}`);
|
|
1099
1089
|
} catch (error) {
|
|
1100
|
-
|
|
1101
|
-
await api.discard(pr2, review);
|
|
1102
|
-
} catch (discardError) {
|
|
1103
|
-
log(`could not discard the pending review: ${discardError instanceof Error ? discardError.message : String(discardError)}`);
|
|
1104
|
-
}
|
|
1090
|
+
await discard(pr2, review, deps);
|
|
1105
1091
|
throw error;
|
|
1106
1092
|
}
|
|
1107
1093
|
}
|
|
1108
1094
|
|
|
1109
|
-
// src/features/review/
|
|
1110
|
-
import {
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
const options = { encoding: "utf8", timeout: commandTimeout, shell: process.platform === "win32" };
|
|
1117
|
-
execFile2(harness, args, options, (error, stdout) => resolve({ ok: !error, stdout }));
|
|
1118
|
-
});
|
|
1119
|
-
}
|
|
1120
|
-
async function claudeSignedIn() {
|
|
1121
|
-
const { ok, stdout } = await exec("claude", ["auth", "status", "--json"]);
|
|
1122
|
-
if (!ok) return false;
|
|
1123
|
-
try {
|
|
1124
|
-
const parsed = JSON.parse(stdout);
|
|
1125
|
-
return typeof parsed === "object" && parsed !== null && "loggedIn" in parsed && parsed.loggedIn === true;
|
|
1126
|
-
} catch {
|
|
1127
|
-
return false;
|
|
1128
|
-
}
|
|
1129
|
-
}
|
|
1130
|
-
async function reach({ url, headers, body }) {
|
|
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`;
|
|
1131
1102
|
try {
|
|
1132
|
-
const
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
body: JSON.stringify(body),
|
|
1136
|
-
signal: AbortSignal.timeout(probeTimeout)
|
|
1137
|
-
});
|
|
1138
|
-
if (response.ok) return void 0;
|
|
1139
|
-
const text2 = (await response.text()).trim().slice(0, maxBodyChars2);
|
|
1140
|
-
return `HTTP ${response.status}${text2 ? `: ${text2}` : ""}`;
|
|
1103
|
+
const result = await work();
|
|
1104
|
+
status.succeed(`${label} (${elapsed()})`);
|
|
1105
|
+
return result;
|
|
1141
1106
|
} catch (error) {
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
return cause ? `${error.message} (${cause})` : error.message;
|
|
1145
|
-
}
|
|
1146
|
-
}
|
|
1147
|
-
var checks = {
|
|
1148
|
-
onPath: async (harness) => (await exec(harness, ["--version"])).ok,
|
|
1149
|
-
signedIn: async (harness) => harness === "claude" ? claudeSignedIn() : (await exec("codex", ["login", "status"])).ok,
|
|
1150
|
-
reach
|
|
1151
|
-
};
|
|
1152
|
-
var loginCommand = { claude: "claude auth login", codex: "codex login" };
|
|
1153
|
-
function signedOut({ slot: { harness }, keys }) {
|
|
1154
|
-
const settings = keys.map((key) => `${key}.provider and ${key}.model`).join(", and ");
|
|
1155
|
-
return `${harness} is not signed in (${keys.join(", ")}); run \`${loginCommand[harness]}\`, or run it through LiteLLM or Foundry by setting ${settings} (see \`yoke models\`)`;
|
|
1156
|
-
}
|
|
1157
|
-
async function slotProblem(use, check) {
|
|
1158
|
-
const { harness, model, probe } = use.slot;
|
|
1159
|
-
if (!await check.onPath(harness)) return `${harness} CLI not found on PATH; install it`;
|
|
1160
|
-
if (!probe) return await check.signedIn(harness) ? void 0 : signedOut(use);
|
|
1161
|
-
const error = await check.reach(probe);
|
|
1162
|
-
return error && `${harness} model "${model}" at ${probe.url}: ${error}`;
|
|
1163
|
-
}
|
|
1164
|
-
function slotsToRun(slots) {
|
|
1165
|
-
const uses = [
|
|
1166
|
-
["review.reviewer1", slots.reviewer1],
|
|
1167
|
-
["review.reviewer2", slots.reviewer2],
|
|
1168
|
-
["review.synthesis", slots.synthesis ?? { harness: slots.reviewer1.harness }]
|
|
1169
|
-
];
|
|
1170
|
-
const distinct = /* @__PURE__ */ new Map();
|
|
1171
|
-
for (const [key, slot] of uses) {
|
|
1172
|
-
const id = `${slot.harness}:${slot.provider ?? "own"}:${slot.model ?? ""}`;
|
|
1173
|
-
const use = distinct.get(id);
|
|
1174
|
-
if (use) use.keys.push(key);
|
|
1175
|
-
else distinct.set(id, { slot, keys: [key] });
|
|
1107
|
+
status.fail(`${label} failed after ${elapsed()}`);
|
|
1108
|
+
throw error;
|
|
1176
1109
|
}
|
|
1177
|
-
return [...distinct.values()];
|
|
1178
1110
|
}
|
|
1179
|
-
|
|
1180
|
-
const problems = await Promise.all(slotsToRun(slots).map((use) => slotProblem(use, check)));
|
|
1181
|
-
const errors = problems.filter((problem) => problem !== void 0);
|
|
1182
|
-
if (errors.length) throw new ReviewError(["Cannot review:", ...errors.map((error) => `- ${error}`)].join("\n"));
|
|
1183
|
-
}
|
|
1184
|
-
|
|
1185
|
-
// src/features/review/reviewLog.ts
|
|
1186
|
-
import { appendFileSync, mkdirSync as mkdirSync2 } from "fs";
|
|
1187
|
-
import { join as join3 } from "path";
|
|
1188
|
-
function reviewLog(command = process.argv.slice(2), now = () => /* @__PURE__ */ new Date()) {
|
|
1189
|
-
let path;
|
|
1190
|
-
let buffered = [];
|
|
1111
|
+
function lineUi(log, { activity }) {
|
|
1191
1112
|
return {
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
`);
|
|
1195
|
-
|
|
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 };
|
|
1196
1118
|
},
|
|
1197
|
-
|
|
1198
|
-
mkdirSync2(reviewDir, { recursive: true });
|
|
1199
|
-
path = join3(reviewDir, "review.log");
|
|
1200
|
-
appendFileSync(path, `${["", `=== ${now().toISOString()} ===`, `$ yoke ${command.join(" ")}`, ...buffered].join("\n")}
|
|
1201
|
-
`);
|
|
1202
|
-
buffered = [];
|
|
1119
|
+
stop: () => {
|
|
1203
1120
|
}
|
|
1204
1121
|
};
|
|
1205
1122
|
}
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
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;
|
|
1210
1143
|
};
|
|
1211
1144
|
return {
|
|
1212
|
-
log:
|
|
1145
|
+
log: persist,
|
|
1213
1146
|
track: (label) => {
|
|
1214
|
-
|
|
1215
|
-
|
|
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();
|
|
1216
1154
|
return {
|
|
1217
|
-
update: (activity) => {
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
},
|
|
1221
|
-
succeed: both(status.succeed),
|
|
1222
|
-
fail: both(status.fail)
|
|
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}`)
|
|
1223
1158
|
};
|
|
1224
1159
|
},
|
|
1225
|
-
stop:
|
|
1160
|
+
stop: () => {
|
|
1161
|
+
if (timer) clearInterval(timer);
|
|
1162
|
+
update.done();
|
|
1163
|
+
}
|
|
1226
1164
|
};
|
|
1227
1165
|
}
|
|
1228
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) {
|
|
1177
|
+
try {
|
|
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)}`);
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
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;
|
|
1251
|
+
try {
|
|
1252
|
+
appendFileSync(path, `${jobSummary(summary, usage)}
|
|
1253
|
+
`);
|
|
1254
|
+
log("wrote the review summary and token usage to the job summary");
|
|
1255
|
+
} catch (error) {
|
|
1256
|
+
log(`could not write the job summary: ${error instanceof Error ? error.message : String(error)}`);
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
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);
|
|
1264
|
+
}
|
|
1265
|
+
function truncate(value) {
|
|
1266
|
+
const collapsed = value.replace(/\s+/g, " ").trim();
|
|
1267
|
+
return collapsed.length <= maxSummary ? collapsed : `${collapsed.slice(0, maxSummary - 1)}\u2026`;
|
|
1268
|
+
}
|
|
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
|
+
}
|
|
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}`;
|
|
1372
|
+
}
|
|
1373
|
+
function exitError(harness, code, output) {
|
|
1374
|
+
const detail = tail(output);
|
|
1375
|
+
return [`${harness} exited with code ${code ?? "unknown"}`, ...detail ? [detail] : []].join("\n");
|
|
1376
|
+
}
|
|
1377
|
+
function hasReview(path) {
|
|
1378
|
+
return existsSync3(path) && readFileSync3(path, "utf8").trim().length > 0;
|
|
1379
|
+
}
|
|
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;
|
|
1405
|
+
};
|
|
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";
|
|
1430
|
+
|
|
1229
1431
|
// src/features/review/run.ts
|
|
1230
|
-
import { existsSync as
|
|
1432
|
+
import { existsSync as existsSync6, readFileSync as readFileSync6, rmSync, writeFileSync as writeFileSync6 } from "fs";
|
|
1231
1433
|
import { join as join6 } from "path";
|
|
1232
1434
|
|
|
1233
1435
|
// src/features/review/cachedSlots.ts
|
|
1234
|
-
import { existsSync as
|
|
1436
|
+
import { existsSync as existsSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync4 } from "fs";
|
|
1235
1437
|
import { join as join4 } from "path";
|
|
1236
1438
|
function slotsPath(reviewDir) {
|
|
1237
1439
|
return join4(reviewDir, "slots.json");
|
|
@@ -1241,17 +1443,17 @@ function isCachedSlots(value) {
|
|
|
1241
1443
|
}
|
|
1242
1444
|
function readCachedSlots(reviewDir) {
|
|
1243
1445
|
const path = slotsPath(reviewDir);
|
|
1244
|
-
if (!
|
|
1245
|
-
const parsed = JSON.parse(
|
|
1446
|
+
if (!existsSync4(path)) return {};
|
|
1447
|
+
const parsed = JSON.parse(readFileSync4(path, "utf8"));
|
|
1246
1448
|
return isCachedSlots(parsed) ? parsed : {};
|
|
1247
1449
|
}
|
|
1248
1450
|
function cachedSlot(reviewDir, step) {
|
|
1249
1451
|
return readCachedSlots(reviewDir)[step];
|
|
1250
1452
|
}
|
|
1251
1453
|
function recordSlot(reviewDir, step, slot) {
|
|
1252
|
-
const
|
|
1253
|
-
|
|
1254
|
-
|
|
1454
|
+
const slots2 = readCachedSlots(reviewDir);
|
|
1455
|
+
slots2[step] = slot;
|
|
1456
|
+
writeFileSync4(slotsPath(reviewDir), `${JSON.stringify(slots2, null, 2)}
|
|
1255
1457
|
`);
|
|
1256
1458
|
}
|
|
1257
1459
|
|
|
@@ -1396,7 +1598,7 @@ ${files.join("\n")}
|
|
|
1396
1598
|
}
|
|
1397
1599
|
|
|
1398
1600
|
// src/features/review/usedModels.ts
|
|
1399
|
-
import { existsSync as
|
|
1601
|
+
import { existsSync as existsSync5, readFileSync as readFileSync5, writeFileSync as writeFileSync5 } from "fs";
|
|
1400
1602
|
import { join as join5 } from "path";
|
|
1401
1603
|
var repoUrl = "https://github.com/MakerXStudio/yoke";
|
|
1402
1604
|
function modelsPath(reviewDir) {
|
|
@@ -1407,21 +1609,21 @@ function isUsedModels(value) {
|
|
|
1407
1609
|
}
|
|
1408
1610
|
function readUsedModels(reviewDir) {
|
|
1409
1611
|
const path = modelsPath(reviewDir);
|
|
1410
|
-
if (!
|
|
1411
|
-
const parsed = JSON.parse(
|
|
1612
|
+
if (!existsSync5(path)) return {};
|
|
1613
|
+
const parsed = JSON.parse(readFileSync5(path, "utf8"));
|
|
1412
1614
|
return isUsedModels(parsed) ? parsed : {};
|
|
1413
1615
|
}
|
|
1414
1616
|
function recordUsedModel(reviewDir, step, harness, model) {
|
|
1415
1617
|
const models = readUsedModels(reviewDir);
|
|
1416
1618
|
models[step] = model ? model.split("/").at(-1) ?? model : `${harness} default`;
|
|
1417
|
-
|
|
1619
|
+
writeFileSync5(modelsPath(reviewDir), `${JSON.stringify(models, null, 2)}
|
|
1418
1620
|
`);
|
|
1419
1621
|
}
|
|
1420
1622
|
function forgetUsedModel(reviewDir, step) {
|
|
1421
1623
|
const models = readUsedModels(reviewDir);
|
|
1422
1624
|
if (!(step in models)) return;
|
|
1423
1625
|
delete models[step];
|
|
1424
|
-
|
|
1626
|
+
writeFileSync5(modelsPath(reviewDir), `${JSON.stringify(models, null, 2)}
|
|
1425
1627
|
`);
|
|
1426
1628
|
}
|
|
1427
1629
|
function list(items) {
|
|
@@ -1435,6 +1637,17 @@ _Adversarial review by [yoke](${repoUrl})${used.length ? `: ${used.join("; ")}`
|
|
|
1435
1637
|
}
|
|
1436
1638
|
|
|
1437
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
|
+
}
|
|
1438
1651
|
var reviewers = ["reviewer1", "reviewer2"];
|
|
1439
1652
|
function seconds(since) {
|
|
1440
1653
|
return `${Math.round((Date.now() - since) / 1e3)}s`;
|
|
@@ -1458,6 +1671,7 @@ async function runStep({ name, label, slot, ...step }, context) {
|
|
|
1458
1671
|
status.succeed(`${label} done in ${seconds(started)}`);
|
|
1459
1672
|
recordUsedModel(context.reviewDir, name, harness, slot.model ?? result.model);
|
|
1460
1673
|
recordSlot(context.reviewDir, name, describeSlot(slot));
|
|
1674
|
+
if (result.usage) context.usage[name] = result.usage;
|
|
1461
1675
|
return true;
|
|
1462
1676
|
}
|
|
1463
1677
|
status.fail(`${label} failed after ${seconds(started)}: ${result.error}`);
|
|
@@ -1467,7 +1681,7 @@ async function runStep({ name, label, slot, ...step }, context) {
|
|
|
1467
1681
|
}
|
|
1468
1682
|
function hasOutput({ name, slot, outputPath }, context) {
|
|
1469
1683
|
if (cachedSlot(context.reviewDir, name) !== describeSlot(slot)) return false;
|
|
1470
|
-
return
|
|
1684
|
+
return existsSync6(outputPath) && readFileSync6(outputPath, "utf8").trim().length > 0;
|
|
1471
1685
|
}
|
|
1472
1686
|
async function runOrReuse(step, context) {
|
|
1473
1687
|
if (!hasOutput(step, context)) return { ok: await runStep(step, context), ran: true };
|
|
@@ -1484,7 +1698,7 @@ function withReviewers(result, context) {
|
|
|
1484
1698
|
const line = reviewers.map((reviewer) => reviewerLabel(reviewer, context)).join(", ");
|
|
1485
1699
|
return { ...result, report: `Reviewers: ${line}
|
|
1486
1700
|
|
|
1487
|
-
${result.report}
|
|
1701
|
+
${result.report}`, usage: context.usage };
|
|
1488
1702
|
}
|
|
1489
1703
|
function synthesisResult(markdown) {
|
|
1490
1704
|
const synthesis = parseSynthesis(markdown);
|
|
@@ -1494,7 +1708,7 @@ function synthesisResult(markdown) {
|
|
|
1494
1708
|
function rawReviews(succeeded, context) {
|
|
1495
1709
|
const report = succeeded.map((reviewer) => `# ${reviewerLabel(reviewer, context)} review
|
|
1496
1710
|
|
|
1497
|
-
${
|
|
1711
|
+
${readFileSync6(reviewPath(context, reviewer), "utf8").trim()}`).join("\n\n");
|
|
1498
1712
|
return { report, synthesis: null, markdown: null };
|
|
1499
1713
|
}
|
|
1500
1714
|
function synthesisSlot(succeeded, context) {
|
|
@@ -1511,13 +1725,15 @@ async function synthesise(requestPath, succeeded, reviewersRan, context) {
|
|
|
1511
1725
|
}));
|
|
1512
1726
|
const prompt = buildSynthesisPrompt(requestPath, files);
|
|
1513
1727
|
if ((await runOrReuse({ name: "synthesis", label: `synthesis (${describeSlot(slot)})`, slot, prompt, outputPath }, context)).ok)
|
|
1514
|
-
return synthesisResult(
|
|
1728
|
+
return synthesisResult(readFileSync6(outputPath, "utf8"));
|
|
1729
|
+
if (context.strict) throw new ReviewFailure("synthesis failed", "The synthesis failed; nothing to post.");
|
|
1515
1730
|
context.log("showing the raw reviews instead");
|
|
1516
1731
|
return rawReviews(succeeded, context);
|
|
1517
1732
|
}
|
|
1518
|
-
async function runReview(request,
|
|
1733
|
+
async function runReview(request, reviewContext) {
|
|
1734
|
+
const context = { ...reviewContext, usage: {} };
|
|
1519
1735
|
const requestPath = join6(context.reviewDir, "request.md");
|
|
1520
|
-
|
|
1736
|
+
writeFileSync6(requestPath, request);
|
|
1521
1737
|
const prompt = buildReviewerPrompt(requestPath);
|
|
1522
1738
|
const results = await Promise.all(
|
|
1523
1739
|
reviewers.map((reviewer) => {
|
|
@@ -1527,7 +1743,10 @@ async function runReview(request, context) {
|
|
|
1527
1743
|
})
|
|
1528
1744
|
);
|
|
1529
1745
|
const succeeded = reviewers.filter((_, i) => results[i]?.ok);
|
|
1530
|
-
|
|
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.`);
|
|
1531
1750
|
const synthesised = await synthesise(
|
|
1532
1751
|
requestPath,
|
|
1533
1752
|
succeeded,
|
|
@@ -1537,75 +1756,123 @@ async function runReview(request, context) {
|
|
|
1537
1756
|
return withReviewers(synthesised, context);
|
|
1538
1757
|
}
|
|
1539
1758
|
|
|
1540
|
-
// src/features/review/
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
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;
|
|
1547
1772
|
try {
|
|
1548
|
-
const
|
|
1549
|
-
|
|
1550
|
-
|
|
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}` : ""}`;
|
|
1551
1790
|
} catch (error) {
|
|
1552
|
-
|
|
1553
|
-
|
|
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;
|
|
1554
1794
|
}
|
|
1555
1795
|
}
|
|
1556
|
-
|
|
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 = [];
|
|
1557
1841
|
return {
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
return { update, succeed: log, fail: log };
|
|
1842
|
+
append: (line) => {
|
|
1843
|
+
if (path) appendFileSync2(path, `${line}
|
|
1844
|
+
`);
|
|
1845
|
+
else buffered.push(line);
|
|
1563
1846
|
},
|
|
1564
|
-
|
|
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 = [];
|
|
1565
1853
|
}
|
|
1566
1854
|
};
|
|
1567
1855
|
}
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
const running = [];
|
|
1573
|
-
let frame = 0;
|
|
1574
|
-
let timer;
|
|
1575
|
-
const paint = (colour, text2) => styleText(colour, text2, { stream });
|
|
1576
|
-
const render = () => update(running.map((entry) => `${paint("cyan", frames[frame] ?? "")} ${entry.text}`).join("\n"));
|
|
1577
|
-
const persist = (line) => {
|
|
1578
|
-
update.clear();
|
|
1579
|
-
update.persist(line);
|
|
1580
|
-
render();
|
|
1581
|
-
};
|
|
1582
|
-
const settle = (entry, line) => {
|
|
1583
|
-
running.splice(running.indexOf(entry), 1);
|
|
1584
|
-
persist(line);
|
|
1585
|
-
if (running.length || !timer) return;
|
|
1586
|
-
clearInterval(timer);
|
|
1587
|
-
timer = void 0;
|
|
1856
|
+
function logged(ui, append) {
|
|
1857
|
+
const both = (show) => (line) => {
|
|
1858
|
+
append(line);
|
|
1859
|
+
show(line);
|
|
1588
1860
|
};
|
|
1589
1861
|
return {
|
|
1590
|
-
log:
|
|
1862
|
+
log: both(ui.log),
|
|
1591
1863
|
track: (label) => {
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
timer ??= setInterval(() => {
|
|
1595
|
-
frame = (frame + 1) % frames.length;
|
|
1596
|
-
render();
|
|
1597
|
-
}, tickMs);
|
|
1598
|
-
render();
|
|
1864
|
+
append(`${label}\u2026`);
|
|
1865
|
+
const status = ui.track(label);
|
|
1599
1866
|
return {
|
|
1600
|
-
update: (activity) =>
|
|
1601
|
-
|
|
1602
|
-
|
|
1867
|
+
update: (activity) => {
|
|
1868
|
+
append(`${label}: ${activity}`);
|
|
1869
|
+
status.update(activity);
|
|
1870
|
+
},
|
|
1871
|
+
succeed: both(status.succeed),
|
|
1872
|
+
fail: both(status.fail)
|
|
1603
1873
|
};
|
|
1604
1874
|
},
|
|
1605
|
-
stop:
|
|
1606
|
-
if (timer) clearInterval(timer);
|
|
1607
|
-
update.done();
|
|
1608
|
-
}
|
|
1875
|
+
stop: ui.stop
|
|
1609
1876
|
};
|
|
1610
1877
|
}
|
|
1611
1878
|
|
|
@@ -1613,19 +1880,19 @@ function spinnerUi(stream) {
|
|
|
1613
1880
|
function reviewRunDir(pr2, env = process.env, home = homedir2()) {
|
|
1614
1881
|
const [, owner = "", repo = ""] = new URL(pr2.url).pathname.split("/");
|
|
1615
1882
|
const run2 = `${pr2.number}-${pr2.baseRefOid}-${pr2.headRefOid}`;
|
|
1616
|
-
return
|
|
1883
|
+
return join8(env.XDG_CACHE_HOME || join8(home, ".cache"), "yoke", "reviews", owner, repo, run2);
|
|
1617
1884
|
}
|
|
1618
1885
|
var cachedOutputs = ["reviewer1.md", "reviewer2.md", "synthesis.md", "review.md", "models.json", "slots.json"];
|
|
1619
1886
|
function prepareRunDir(reviewDir, force) {
|
|
1620
|
-
|
|
1621
|
-
if (force) for (const name of cachedOutputs) rmSync2(
|
|
1887
|
+
mkdirSync4(reviewDir, { recursive: true });
|
|
1888
|
+
if (force) for (const name of cachedOutputs) rmSync2(join8(reviewDir, name), { force: true });
|
|
1622
1889
|
}
|
|
1623
|
-
async function reviewAtHead(pr2, reviewDir, request,
|
|
1624
|
-
const checkoutDir = mkdtempSync(
|
|
1890
|
+
async function reviewAtHead(pr2, reviewDir, request, slots2, ui, strict) {
|
|
1891
|
+
const checkoutDir = mkdtempSync(join8(tmpdir(), "yoke-review-"));
|
|
1625
1892
|
try {
|
|
1626
1893
|
const checkout = await timed(ui, `checking out ${pr2.headRefOid.slice(0, 7)}`, () => checkoutHead(pr2, checkoutDir));
|
|
1627
1894
|
try {
|
|
1628
|
-
return await runReview(request, { cwd: checkout.cwd, reviewDir, slots, 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 });
|
|
1629
1896
|
} finally {
|
|
1630
1897
|
await checkout.remove();
|
|
1631
1898
|
}
|
|
@@ -1634,11 +1901,11 @@ async function reviewAtHead(pr2, reviewDir, request, slots, ui) {
|
|
|
1634
1901
|
}
|
|
1635
1902
|
}
|
|
1636
1903
|
function savedReview(path, post) {
|
|
1637
|
-
if (!post || !
|
|
1638
|
-
return parseSynthesis(
|
|
1904
|
+
if (!post || !existsSync7(path)) return null;
|
|
1905
|
+
return parseSynthesis(readFileSync7(path, "utf8"));
|
|
1639
1906
|
}
|
|
1640
1907
|
function saveReview(path, markdown) {
|
|
1641
|
-
if (markdown)
|
|
1908
|
+
if (markdown) writeFileSync7(path, markdown);
|
|
1642
1909
|
else rmSync2(path, { force: true });
|
|
1643
1910
|
}
|
|
1644
1911
|
async function reviewPr(number, settings, { post, force }, io) {
|
|
@@ -1649,7 +1916,7 @@ async function reviewPr(number, settings, { post, force }, io) {
|
|
|
1649
1916
|
io.out(text2);
|
|
1650
1917
|
};
|
|
1651
1918
|
const { log } = ui;
|
|
1652
|
-
const
|
|
1919
|
+
const slots2 = resolveSlots(settings);
|
|
1653
1920
|
const pr2 = await timed(ui, number ? `fetching PR #${number}` : "fetching this branch's PR", () => fetchPr(number));
|
|
1654
1921
|
log(`#${pr2.number} ${pr2.title}`);
|
|
1655
1922
|
const [diff, prior] = await timed(
|
|
@@ -1660,30 +1927,46 @@ async function reviewPr(number, settings, { post, force }, io) {
|
|
|
1660
1927
|
const reviewDir = reviewRunDir(pr2);
|
|
1661
1928
|
prepareRunDir(reviewDir, force);
|
|
1662
1929
|
file.attach(reviewDir);
|
|
1663
|
-
const
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
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
|
+
}
|
|
1939
|
+
await timed(ui, "checking each reviewer can run", () => assertViable(slots2));
|
|
1670
1940
|
log(`review folder: ${reviewDir}`);
|
|
1671
|
-
const result = await reviewAtHead(pr2, reviewDir, buildRequest(pr2, diff, prior),
|
|
1941
|
+
const result = await reviewAtHead(pr2, reviewDir, buildRequest(pr2, diff, prior), slots2, ui, ci);
|
|
1672
1942
|
out(result.report);
|
|
1673
|
-
synthesis = result.synthesis;
|
|
1674
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;
|
|
1675
1960
|
}
|
|
1676
|
-
if (!post) return;
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
const footer = modelsFooter(readUsedModels(reviewDir));
|
|
1680
|
-
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 }));
|
|
1681
1964
|
}
|
|
1682
1965
|
|
|
1683
1966
|
// src/features/sync/sync.ts
|
|
1684
|
-
import { existsSync as
|
|
1967
|
+
import { existsSync as existsSync8, mkdirSync as mkdirSync5, readFileSync as readFileSync8, writeFileSync as writeFileSync8 } from "fs";
|
|
1685
1968
|
import { homedir as homedir3 } from "os";
|
|
1686
|
-
import { join as
|
|
1969
|
+
import { join as join9 } from "path";
|
|
1687
1970
|
|
|
1688
1971
|
// src/skill.ts
|
|
1689
1972
|
var ownerMarker = 'owner: "@makerx/yoke"';
|
|
@@ -1714,6 +1997,25 @@ If \`yoke\` is not found, run \`npm i -g @makerx/yoke\` and retry.
|
|
|
1714
1997
|
`
|
|
1715
1998
|
};
|
|
1716
1999
|
|
|
2000
|
+
// src/features/review/ciSkill.ts
|
|
2001
|
+
var reviewCiSkill = {
|
|
2002
|
+
name: "review-ci",
|
|
2003
|
+
description: "Add yoke's adversarial PR review workflow to this repo and set the secrets it needs.",
|
|
2004
|
+
disableModelInvocation: true,
|
|
2005
|
+
userLevel: true,
|
|
2006
|
+
body: `
|
|
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\`.
|
|
2008
|
+
|
|
2009
|
+
Then set the two secrets it printed. Run \`gh secret list\` first; if either secret is missing, ask whether it is already set at the org level before setting it on the repo.
|
|
2010
|
+
- \`REVIEW_CI_BASE_URL\` is not secret: confirm the URL with the user and run the printed command.
|
|
2011
|
+
- \`REVIEW_CI_API_KEY\` must never enter this conversation: ask the user to run \`gh secret set REVIEW_CI_API_KEY\` in their own terminal, outside this session, and paste the key at its prompt.
|
|
2012
|
+
|
|
2013
|
+
Finish by running \`gh secret list\` and telling the user to commit and push the workflow file.
|
|
2014
|
+
|
|
2015
|
+
If \`yoke\` is not found, run \`npm i -g @makerx/yoke\` and retry.
|
|
2016
|
+
`
|
|
2017
|
+
};
|
|
2018
|
+
|
|
1717
2019
|
// src/features/review/skill.ts
|
|
1718
2020
|
var reviewSkill = {
|
|
1719
2021
|
name: "review",
|
|
@@ -1729,24 +2031,24 @@ If \`yoke\` is not found, run \`npm i -g @makerx/yoke\` and retry.
|
|
|
1729
2031
|
};
|
|
1730
2032
|
|
|
1731
2033
|
// src/features/sync/sync.ts
|
|
1732
|
-
var bundledSkills = [prSkill, reviewSkill];
|
|
2034
|
+
var bundledSkills = [prSkill, reviewSkill, reviewCiSkill];
|
|
1733
2035
|
function skillsRoot(user, cwd = process.cwd(), home = homedir3()) {
|
|
1734
|
-
return
|
|
2036
|
+
return join9(user ? home : cwd, ".claude", "skills");
|
|
1735
2037
|
}
|
|
1736
2038
|
function isOwnedByUser(skillFile) {
|
|
1737
|
-
return
|
|
2039
|
+
return existsSync8(skillFile) && !readFileSync8(skillFile, "utf8").includes(ownerMarker);
|
|
1738
2040
|
}
|
|
1739
|
-
function syncSkills(destination, skills = bundledSkills) {
|
|
2041
|
+
function syncSkills(destination, userDestination, skills = bundledSkills) {
|
|
1740
2042
|
const result = { written: [], skipped: [] };
|
|
1741
2043
|
for (const skill of skills) {
|
|
1742
|
-
const target =
|
|
1743
|
-
const skillFile =
|
|
2044
|
+
const target = join9(skill.userLevel ? userDestination : destination, skill.name);
|
|
2045
|
+
const skillFile = join9(target, "SKILL.md");
|
|
1744
2046
|
if (isOwnedByUser(skillFile)) {
|
|
1745
2047
|
result.skipped.push(target);
|
|
1746
2048
|
continue;
|
|
1747
2049
|
}
|
|
1748
|
-
|
|
1749
|
-
|
|
2050
|
+
mkdirSync5(target, { recursive: true });
|
|
2051
|
+
writeFileSync8(skillFile, renderSkill(skill));
|
|
1750
2052
|
result.written.push(skillFile);
|
|
1751
2053
|
}
|
|
1752
2054
|
return result;
|
|
@@ -1757,8 +2059,9 @@ var { version } = package_default;
|
|
|
1757
2059
|
var program = new Command("yoke").description("Lean Claude Code skills and the CLI they call.").version(version);
|
|
1758
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) => {
|
|
1759
2061
|
const user = options.user ?? false;
|
|
1760
|
-
const
|
|
1761
|
-
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));
|
|
1762
2065
|
for (const file of written) console.log(`wrote ${display(file)}`);
|
|
1763
2066
|
for (const dir of skipped) console.log(`skipped ${display(dir)} (your own skill; delete it to take yoke's)`);
|
|
1764
2067
|
});
|
|
@@ -1811,6 +2114,22 @@ addSettingOptions(
|
|
|
1811
2114
|
ui.stop();
|
|
1812
2115
|
}
|
|
1813
2116
|
});
|
|
2117
|
+
var ciKeys = settingKeys.filter((key) => /^review\.\w+\.(harness|model)$/.test(key));
|
|
2118
|
+
addSettingOptions(
|
|
2119
|
+
program.command("review-ci").description(`Write ${callerWorkflowPath} so each PR in this repo is reviewed in CI, then print the gh secret set commands it needs`).option("--provider <provider>", "litellm or foundry; every slot uses it in CI. Default: review.reviewer1.provider, else litellm").option("--force", `overwrite an existing ${callerWorkflowPath} that differs`),
|
|
2120
|
+
ciKeys
|
|
2121
|
+
).addHelpText("after", settingsHelp).action((options) => {
|
|
2122
|
+
try {
|
|
2123
|
+
const settings = resolveSettings(flagSettings(options, ciKeys), process.env, readUserConfig(userConfigPath()));
|
|
2124
|
+
const workflow = callerWorkflow(settings, options.provider);
|
|
2125
|
+
const outcome = writeCallerWorkflow(repoRoot(), renderCallerWorkflow(workflow), options.force ?? false);
|
|
2126
|
+
console.log(reviewCiReport(outcome, secretCommands(settings, workflow.provider)));
|
|
2127
|
+
} catch (error) {
|
|
2128
|
+
if (!(error instanceof ReviewError || error instanceof ConfigError)) throw error;
|
|
2129
|
+
console.error(error.message);
|
|
2130
|
+
process.exitCode = 1;
|
|
2131
|
+
}
|
|
2132
|
+
});
|
|
1814
2133
|
addSettingOptions(
|
|
1815
2134
|
program.command("models").description("List the models each configured provider serves, for review.*.provider and review.*.model"),
|
|
1816
2135
|
providerKeys
|