@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.
Files changed (3) hide show
  1. package/README.md +2 -0
  2. package/dist/cli.js +724 -405
  3. 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.6.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 rows = settingKeys.map((key) => ({
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(...rows.map(({ key }) => key.length));
144
+ const keyWidth = Math.max(...rows2.map(({ key }) => key.length));
142
145
  const indent = " ".repeat(keyWidth + gap.length);
143
- const blocks = rows.map(
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 existsSync6, mkdirSync as mkdirSync3, mkdtempSync, readFileSync as readFileSync6, rmSync as rmSync2, writeFileSync as writeFileSync6 } from "fs";
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 join7 } from "path";
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
- function reviewBody(summary, outside, footer = "") {
1069
- const sections = ["## Adversarial review", ...summary ? [summary] : []];
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
- if (!comments.length && !outside.length) return log("nothing new to post");
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], deps.footer));
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
- try {
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/preflight.ts
1110
- import { execFile as execFile2 } from "child_process";
1111
- var commandTimeout = 3e4;
1112
- var probeTimeout = 6e4;
1113
- var maxBodyChars2 = 300;
1114
- function exec(harness, args) {
1115
- return new Promise((resolve) => {
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 response = await fetch(url, {
1133
- method: "POST",
1134
- headers: { "Content-Type": "application/json", ...headers },
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
- if (!(error instanceof Error)) return String(error);
1143
- const cause = error.cause instanceof Error ? error.cause.message : "";
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
- async function assertViable(slots, check = checks) {
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
- append: (line) => {
1193
- if (path) appendFileSync(path, `${line}
1194
- `);
1195
- else buffered.push(line);
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
- attach: (reviewDir) => {
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
- function logged(ui, append) {
1207
- const both = (show) => (line) => {
1208
- append(line);
1209
- show(line);
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: both(ui.log),
1145
+ log: persist,
1213
1146
  track: (label) => {
1214
- append(`${label}\u2026`);
1215
- const status = ui.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();
1216
1154
  return {
1217
- update: (activity) => {
1218
- append(`${label}: ${activity}`);
1219
- status.update(activity);
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: ui.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 existsSync5, readFileSync as readFileSync5, rmSync, writeFileSync as writeFileSync5 } from "fs";
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 existsSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "fs";
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 (!existsSync3(path)) return {};
1245
- const parsed = JSON.parse(readFileSync3(path, "utf8"));
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 slots = readCachedSlots(reviewDir);
1253
- slots[step] = slot;
1254
- writeFileSync3(slotsPath(reviewDir), `${JSON.stringify(slots, null, 2)}
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 existsSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync4 } from "fs";
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 (!existsSync4(path)) return {};
1411
- const parsed = JSON.parse(readFileSync4(path, "utf8"));
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
- writeFileSync4(modelsPath(reviewDir), `${JSON.stringify(models, null, 2)}
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
- writeFileSync4(modelsPath(reviewDir), `${JSON.stringify(models, null, 2)}
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 existsSync5(outputPath) && readFileSync5(outputPath, "utf8").trim().length > 0;
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
- ${readFileSync5(reviewPath(context, reviewer), "utf8").trim()}`).join("\n\n");
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(readFileSync5(outputPath, "utf8"));
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, context) {
1733
+ async function runReview(request, reviewContext) {
1734
+ const context = { ...reviewContext, usage: {} };
1519
1735
  const requestPath = join6(context.reviewDir, "request.md");
1520
- writeFileSync5(requestPath, request);
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
- if (!succeeded.length) throw new ReviewError("Every reviewer failed; nothing to report.");
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/status.ts
1541
- import { styleText } from "util";
1542
- import { createLogUpdate } from "log-update";
1543
- async function timed(ui, label, work) {
1544
- const started = Date.now();
1545
- const status = ui.track(label);
1546
- const elapsed = () => `${Math.round((Date.now() - started) / 1e3)}s`;
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 result = await work();
1549
- status.succeed(`${label} (${elapsed()})`);
1550
- return result;
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
- status.fail(`${label} failed after ${elapsed()}`);
1553
- throw error;
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
- function lineUi(log, { activity }) {
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
- log,
1559
- track: (label) => {
1560
- log(`${label}\u2026`);
1561
- const update = (text2) => activity ? log(`${label}: ${text2}`) : void 0;
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
- stop: () => {
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
- var frames = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
1569
- var tickMs = 80;
1570
- function spinnerUi(stream) {
1571
- const update = createLogUpdate(stream);
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: persist,
1862
+ log: both(ui.log),
1591
1863
  track: (label) => {
1592
- const entry = { label, text: label };
1593
- running.push(entry);
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) => entry.text = `${label} \u2014 ${activity}`,
1601
- succeed: (text2) => settle(entry, `${paint("green", "\u2713")} ${text2}`),
1602
- fail: (text2) => settle(entry, `${paint("red", "\u2717")} ${text2}`)
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 join7(env.XDG_CACHE_HOME || join7(home, ".cache"), "yoke", "reviews", owner, repo, run2);
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
- mkdirSync3(reviewDir, { recursive: true });
1621
- if (force) for (const name of cachedOutputs) rmSync2(join7(reviewDir, name), { force: true });
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, slots, ui) {
1624
- const checkoutDir = mkdtempSync(join7(tmpdir(), "yoke-review-"));
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 || !existsSync6(path)) return null;
1638
- return parseSynthesis(readFileSync6(path, "utf8"));
1904
+ if (!post || !existsSync7(path)) return null;
1905
+ return parseSynthesis(readFileSync7(path, "utf8"));
1639
1906
  }
1640
1907
  function saveReview(path, markdown) {
1641
- if (markdown) writeFileSync6(path, 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 slots = resolveSlots(settings);
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 path = join7(reviewDir, "review.md");
1664
- let synthesis = savedReview(path, post);
1665
- if (synthesis) {
1666
- log(`posting the saved review of #${pr2.number} at ${pr2.headRefOid.slice(0, 7)}`);
1667
- out(formatSynthesis(synthesis));
1668
- } else {
1669
- await timed(ui, "checking each reviewer can run", () => assertViable(slots));
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), slots, ui);
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
- if (!synthesis) throw new ReviewError("Not posting: the synthesis did not produce findings in the expected format.");
1678
- const findings = synthesis;
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 existsSync7, mkdirSync as mkdirSync4, readFileSync as readFileSync7, writeFileSync as writeFileSync7 } from "fs";
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 join8 } from "path";
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 join8(user ? home : cwd, ".claude", "skills");
2036
+ return join9(user ? home : cwd, ".claude", "skills");
1735
2037
  }
1736
2038
  function isOwnedByUser(skillFile) {
1737
- return existsSync7(skillFile) && !readFileSync7(skillFile, "utf8").includes(ownerMarker);
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 = join8(destination, skill.name);
1743
- const skillFile = join8(target, "SKILL.md");
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
- mkdirSync4(target, { recursive: true });
1749
- writeFileSync7(skillFile, renderSkill(skill));
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 display = (path) => user ? path : relative(process.cwd(), path);
1761
- const { written, skipped } = syncSkills(skillsRoot(user));
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