@makerx/yoke 0.7.0 → 0.8.0

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