@makerx/yoke 0.7.0 → 0.8.1

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