@makerx/yoke 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +2 -0
  2. package/dist/cli.js +199 -62
  3. package/package.json +4 -1
package/README.md CHANGED
@@ -23,12 +23,14 @@ yoke sync --user # ~/.claude/skills
23
23
 
24
24
  - `/pr` raises a PR with a short What/Why/(How) description.
25
25
  - `/review [number]` reviews a PR with Claude and Codex, combines their findings and offers to post them. [Configure](src/features/review/README.md#settings).
26
+ - `/review-ci` adds a workflow that reviews each PR in CI and helps set its secrets. [Details](src/features/review/README.md#ci).
26
27
 
27
28
  ## CLI
28
29
 
29
30
  ```bash
30
31
  yoke sync [--user] # write yoke's skills
31
32
  yoke review [number] # review a PR (used by /review)
33
+ yoke review-ci # add the CI review workflow (used by /review-ci)
32
34
  yoke models # list each provider's models
33
35
  ```
34
36
 
package/dist/cli.js CHANGED
@@ -7,7 +7,7 @@ import { Command } from "commander";
7
7
  // package.json
8
8
  var package_default = {
9
9
  name: "@makerx/yoke",
10
- version: "0.6.0",
10
+ version: "0.7.0",
11
11
  description: "Lean, user-invoked Claude Code skills for MakerX AI-assisted delivery, plus the CLI they call.",
12
12
  license: "MIT",
13
13
  author: "MakerX",
@@ -57,6 +57,9 @@ var package_default = {
57
57
  vitest: "^5.0.5",
58
58
  yaml: "^2.9.1"
59
59
  },
60
+ overrides: {
61
+ minimatch: "^11.0.3"
62
+ },
60
63
  engines: {
61
64
  node: ">=24"
62
65
  }
@@ -402,6 +405,11 @@ function raisePr({ title, body, base, draft }, run2 = runGh) {
402
405
  return output.at(-1) ?? "";
403
406
  }
404
407
 
408
+ // src/features/review/ci.ts
409
+ import { execFileSync as execFileSync2 } from "child_process";
410
+ import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
411
+ import { dirname as dirname2, join as join3 } from "path";
412
+
405
413
  // src/features/review/pr.ts
406
414
  import { execFile } from "child_process";
407
415
  import { join as join2 } from "path";
@@ -607,6 +615,101 @@ function buildOverride({ harness, model }, provider) {
607
615
  return buildClaudeOverride(provider, endpoints, model);
608
616
  }
609
617
 
618
+ // src/features/review/ci.ts
619
+ var reviewWorkflowRef = "MakerXStudio/yoke/.github/workflows/review.yml@main";
620
+ var callerWorkflowPath = join3(".github", "workflows", "review-pr.yml");
621
+ var ciSecrets = ["REVIEW_CI_BASE_URL", "REVIEW_CI_API_KEY"];
622
+ var slots = [
623
+ { slot: "reviewer1", defaultHarness: "claude", required: true },
624
+ { slot: "reviewer2", defaultHarness: "codex", required: true },
625
+ { slot: "synthesis", defaultHarness: void 0, required: false }
626
+ ];
627
+ function slotInputs(settings, errors) {
628
+ const inputs = [];
629
+ for (const { slot, defaultHarness, required } of slots) {
630
+ const harnessKey = `review.${slot}.harness`;
631
+ const modelKey = `review.${slot}.model`;
632
+ const harness = settings[harnessKey] ?? defaultHarness;
633
+ const model = settings[modelKey];
634
+ if (harness && !isHarness(harness))
635
+ errors.push(`${harnessKey} "${harness}" must be claude or codex; ${howToSet(harnessKey, "<claude|codex>")}`);
636
+ if (required && !model) errors.push(`${modelKey} is required; ${howToSet(modelKey, "<model>")} (see \`yoke models\`)`);
637
+ if (!required && harness && !model)
638
+ errors.push(`${modelKey} is required when ${harnessKey} is set; ${howToSet(modelKey, "<model>")} (see \`yoke models\`)`);
639
+ if (harness) inputs.push([`${slot}-harness`, harness]);
640
+ if (model) inputs.push([`${slot}-model`, model]);
641
+ }
642
+ return inputs;
643
+ }
644
+ function callerWorkflow(settings, providerFlag) {
645
+ const errors = [];
646
+ const provider = providerFlag ?? settings["review.reviewer1.provider"] ?? "litellm";
647
+ if (!isProviderKind(provider)) errors.push(`provider "${provider}" must be ${providerKinds.join(" or ")}; pass --provider`);
648
+ const inputs = slotInputs(settings, errors);
649
+ if (errors.length || !isProviderKind(provider))
650
+ throw new ReviewError(["Cannot write the review workflow:", ...errors.map((error) => `- ${error}`)].join("\n"));
651
+ return { provider, inputs: [["provider", provider], ...inputs] };
652
+ }
653
+ function yamlValue(value) {
654
+ return /^[\w./:@-]+$/.test(value) && !/^([-+.\d_eE]+|0[xo][\da-f]+|true|false|yes|no|on|off|y|n|null|~)$/i.test(value) ? value : JSON.stringify(value);
655
+ }
656
+ function renderCallerWorkflow({ inputs }) {
657
+ return [
658
+ "name: Review PR",
659
+ "",
660
+ "on:",
661
+ " pull_request:",
662
+ " types: [opened, reopened, ready_for_review]",
663
+ "",
664
+ "permissions:",
665
+ " contents: read",
666
+ " pull-requests: write",
667
+ "",
668
+ "jobs:",
669
+ " review:",
670
+ ` uses: ${reviewWorkflowRef}`,
671
+ " with:",
672
+ ...inputs.map(([name, value]) => ` ${name}: ${yamlValue(value)}`),
673
+ " secrets: inherit",
674
+ ""
675
+ ].join("\n");
676
+ }
677
+ function shellQuote(value) {
678
+ return `'${value.replaceAll("'", `'\\''`)}'`;
679
+ }
680
+ function secretCommands(settings, provider) {
681
+ const baseUrl = settings[`${provider}.baseUrl`];
682
+ return [`gh secret set REVIEW_CI_BASE_URL${baseUrl ? ` --body ${shellQuote(baseUrl)}` : ""}`, "gh secret set REVIEW_CI_API_KEY"];
683
+ }
684
+ function repoRoot(cwd = process.cwd()) {
685
+ try {
686
+ return execFileSync2("git", ["rev-parse", "--show-toplevel"], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
687
+ } catch {
688
+ throw new ReviewError("Not in a git repository; run `yoke review-ci` from the repo to review.");
689
+ }
690
+ }
691
+ function writeCallerWorkflow(root, content, force) {
692
+ const path = join3(root, callerWorkflowPath);
693
+ if (existsSync2(path)) {
694
+ if (readFileSync2(path, "utf8") === content) return "unchanged";
695
+ if (!force) throw new ReviewError(`${callerWorkflowPath} already exists and differs; pass --force to overwrite it.`);
696
+ }
697
+ mkdirSync2(dirname2(path), { recursive: true });
698
+ writeFileSync2(path, content);
699
+ return "wrote";
700
+ }
701
+ function reviewCiReport(outcome, commands) {
702
+ const action = outcome === "wrote" ? "wrote" : "kept";
703
+ return [
704
+ `${action} ${callerWorkflowPath}`,
705
+ "",
706
+ `The workflow reads ${ciSecrets.join(" and ")}. Set them on the repo (or the org) with:`,
707
+ ...commands.map((command) => ` ${command}`),
708
+ "",
709
+ "Commit and push the workflow; each PR opened from this repo then gets a review."
710
+ ].join("\n");
711
+ }
712
+
610
713
  // src/features/review/slots.ts
611
714
  var slotNames = ["reviewer1", "reviewer2", "synthesis"];
612
715
  function readProvider(settings, kind) {
@@ -716,9 +819,9 @@ async function listModels(settings, fetchModels = fetch) {
716
819
  }
717
820
 
718
821
  // src/features/review/review.ts
719
- import { existsSync as existsSync6, mkdirSync as mkdirSync3, mkdtempSync, readFileSync as readFileSync6, rmSync as rmSync2, writeFileSync as writeFileSync6 } from "fs";
822
+ import { existsSync as existsSync7, mkdirSync as mkdirSync4, mkdtempSync, readFileSync as readFileSync7, rmSync as rmSync2, writeFileSync as writeFileSync7 } from "fs";
720
823
  import { homedir as homedir2, tmpdir } from "os";
721
- import { join as join7 } from "path";
824
+ import { join as join8 } from "path";
722
825
 
723
826
  // src/features/review/findings.ts
724
827
  var severities = ["blocker", "major", "minor", "nit"];
@@ -780,7 +883,7 @@ function formatSynthesis({ summary, findings }) {
780
883
 
781
884
  // src/features/review/harness.ts
782
885
  import { spawn } from "child_process";
783
- import { existsSync as existsSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
886
+ import { existsSync as existsSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "fs";
784
887
 
785
888
  // src/features/review/progress.ts
786
889
  var maxSummary = 80;
@@ -880,7 +983,7 @@ function exitError(harness, code, output) {
880
983
  return [`${harness} exited with code ${code ?? "unknown"}`, ...detail ? [detail] : []].join("\n");
881
984
  }
882
985
  function hasReview(path) {
883
- return existsSync2(path) && readFileSync2(path, "utf8").trim().length > 0;
986
+ return existsSync3(path) && readFileSync3(path, "utf8").trim().length > 0;
884
987
  }
885
988
  var runHarness = (run2) => new Promise((resolve) => {
886
989
  const shell = process.platform === "win32";
@@ -919,7 +1022,7 @@ var runHarness = (run2) => new Promise((resolve) => {
919
1022
  onLine(pending);
920
1023
  if (code !== 0)
921
1024
  return resolve({ ok: false, error: exitError(run2.harness, code, [stderr || finalText, lastError].filter(Boolean).join("\n")) });
922
- if (run2.harness === "claude") writeFileSync2(run2.outputPath, finalText);
1025
+ if (run2.harness === "claude") writeFileSync3(run2.outputPath, finalText);
923
1026
  if (!hasReview(run2.outputPath)) return resolve({ ok: false, error: `${run2.harness} returned an empty review` });
924
1027
  resolve(model ? { ok: true, model } : { ok: true });
925
1028
  });
@@ -1161,11 +1264,11 @@ async function slotProblem(use, check) {
1161
1264
  const error = await check.reach(probe);
1162
1265
  return error && `${harness} model "${model}" at ${probe.url}: ${error}`;
1163
1266
  }
1164
- function slotsToRun(slots) {
1267
+ function slotsToRun(slots2) {
1165
1268
  const uses = [
1166
- ["review.reviewer1", slots.reviewer1],
1167
- ["review.reviewer2", slots.reviewer2],
1168
- ["review.synthesis", slots.synthesis ?? { harness: slots.reviewer1.harness }]
1269
+ ["review.reviewer1", slots2.reviewer1],
1270
+ ["review.reviewer2", slots2.reviewer2],
1271
+ ["review.synthesis", slots2.synthesis ?? { harness: slots2.reviewer1.harness }]
1169
1272
  ];
1170
1273
  const distinct = /* @__PURE__ */ new Map();
1171
1274
  for (const [key, slot] of uses) {
@@ -1176,15 +1279,15 @@ function slotsToRun(slots) {
1176
1279
  }
1177
1280
  return [...distinct.values()];
1178
1281
  }
1179
- async function assertViable(slots, check = checks) {
1180
- const problems = await Promise.all(slotsToRun(slots).map((use) => slotProblem(use, check)));
1282
+ async function assertViable(slots2, check = checks) {
1283
+ const problems = await Promise.all(slotsToRun(slots2).map((use) => slotProblem(use, check)));
1181
1284
  const errors = problems.filter((problem) => problem !== void 0);
1182
1285
  if (errors.length) throw new ReviewError(["Cannot review:", ...errors.map((error) => `- ${error}`)].join("\n"));
1183
1286
  }
1184
1287
 
1185
1288
  // src/features/review/reviewLog.ts
1186
- import { appendFileSync, mkdirSync as mkdirSync2 } from "fs";
1187
- import { join as join3 } from "path";
1289
+ import { appendFileSync, mkdirSync as mkdirSync3 } from "fs";
1290
+ import { join as join4 } from "path";
1188
1291
  function reviewLog(command = process.argv.slice(2), now = () => /* @__PURE__ */ new Date()) {
1189
1292
  let path;
1190
1293
  let buffered = [];
@@ -1195,8 +1298,8 @@ function reviewLog(command = process.argv.slice(2), now = () => /* @__PURE__ */
1195
1298
  else buffered.push(line);
1196
1299
  },
1197
1300
  attach: (reviewDir) => {
1198
- mkdirSync2(reviewDir, { recursive: true });
1199
- path = join3(reviewDir, "review.log");
1301
+ mkdirSync3(reviewDir, { recursive: true });
1302
+ path = join4(reviewDir, "review.log");
1200
1303
  appendFileSync(path, `${["", `=== ${now().toISOString()} ===`, `$ yoke ${command.join(" ")}`, ...buffered].join("\n")}
1201
1304
  `);
1202
1305
  buffered = [];
@@ -1227,31 +1330,31 @@ function logged(ui, append) {
1227
1330
  }
1228
1331
 
1229
1332
  // src/features/review/run.ts
1230
- import { existsSync as existsSync5, readFileSync as readFileSync5, rmSync, writeFileSync as writeFileSync5 } from "fs";
1231
- import { join as join6 } from "path";
1333
+ import { existsSync as existsSync6, readFileSync as readFileSync6, rmSync, writeFileSync as writeFileSync6 } from "fs";
1334
+ import { join as join7 } from "path";
1232
1335
 
1233
1336
  // src/features/review/cachedSlots.ts
1234
- import { existsSync as existsSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "fs";
1235
- import { join as join4 } from "path";
1337
+ import { existsSync as existsSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync4 } from "fs";
1338
+ import { join as join5 } from "path";
1236
1339
  function slotsPath(reviewDir) {
1237
- return join4(reviewDir, "slots.json");
1340
+ return join5(reviewDir, "slots.json");
1238
1341
  }
1239
1342
  function isCachedSlots(value) {
1240
1343
  return typeof value === "object" && value !== null && Object.values(value).every((slot) => typeof slot === "string");
1241
1344
  }
1242
1345
  function readCachedSlots(reviewDir) {
1243
1346
  const path = slotsPath(reviewDir);
1244
- if (!existsSync3(path)) return {};
1245
- const parsed = JSON.parse(readFileSync3(path, "utf8"));
1347
+ if (!existsSync4(path)) return {};
1348
+ const parsed = JSON.parse(readFileSync4(path, "utf8"));
1246
1349
  return isCachedSlots(parsed) ? parsed : {};
1247
1350
  }
1248
1351
  function cachedSlot(reviewDir, step) {
1249
1352
  return readCachedSlots(reviewDir)[step];
1250
1353
  }
1251
1354
  function recordSlot(reviewDir, step, slot) {
1252
- const slots = readCachedSlots(reviewDir);
1253
- slots[step] = slot;
1254
- writeFileSync3(slotsPath(reviewDir), `${JSON.stringify(slots, null, 2)}
1355
+ const slots2 = readCachedSlots(reviewDir);
1356
+ slots2[step] = slot;
1357
+ writeFileSync4(slotsPath(reviewDir), `${JSON.stringify(slots2, null, 2)}
1255
1358
  `);
1256
1359
  }
1257
1360
 
@@ -1396,32 +1499,32 @@ ${files.join("\n")}
1396
1499
  }
1397
1500
 
1398
1501
  // src/features/review/usedModels.ts
1399
- import { existsSync as existsSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync4 } from "fs";
1400
- import { join as join5 } from "path";
1502
+ import { existsSync as existsSync5, readFileSync as readFileSync5, writeFileSync as writeFileSync5 } from "fs";
1503
+ import { join as join6 } from "path";
1401
1504
  var repoUrl = "https://github.com/MakerXStudio/yoke";
1402
1505
  function modelsPath(reviewDir) {
1403
- return join5(reviewDir, "models.json");
1506
+ return join6(reviewDir, "models.json");
1404
1507
  }
1405
1508
  function isUsedModels(value) {
1406
1509
  return typeof value === "object" && value !== null && Object.values(value).every((model) => typeof model === "string");
1407
1510
  }
1408
1511
  function readUsedModels(reviewDir) {
1409
1512
  const path = modelsPath(reviewDir);
1410
- if (!existsSync4(path)) return {};
1411
- const parsed = JSON.parse(readFileSync4(path, "utf8"));
1513
+ if (!existsSync5(path)) return {};
1514
+ const parsed = JSON.parse(readFileSync5(path, "utf8"));
1412
1515
  return isUsedModels(parsed) ? parsed : {};
1413
1516
  }
1414
1517
  function recordUsedModel(reviewDir, step, harness, model) {
1415
1518
  const models = readUsedModels(reviewDir);
1416
1519
  models[step] = model ? model.split("/").at(-1) ?? model : `${harness} default`;
1417
- writeFileSync4(modelsPath(reviewDir), `${JSON.stringify(models, null, 2)}
1520
+ writeFileSync5(modelsPath(reviewDir), `${JSON.stringify(models, null, 2)}
1418
1521
  `);
1419
1522
  }
1420
1523
  function forgetUsedModel(reviewDir, step) {
1421
1524
  const models = readUsedModels(reviewDir);
1422
1525
  if (!(step in models)) return;
1423
1526
  delete models[step];
1424
- writeFileSync4(modelsPath(reviewDir), `${JSON.stringify(models, null, 2)}
1527
+ writeFileSync5(modelsPath(reviewDir), `${JSON.stringify(models, null, 2)}
1425
1528
  `);
1426
1529
  }
1427
1530
  function list(items) {
@@ -1467,7 +1570,7 @@ async function runStep({ name, label, slot, ...step }, context) {
1467
1570
  }
1468
1571
  function hasOutput({ name, slot, outputPath }, context) {
1469
1572
  if (cachedSlot(context.reviewDir, name) !== describeSlot(slot)) return false;
1470
- return existsSync5(outputPath) && readFileSync5(outputPath, "utf8").trim().length > 0;
1573
+ return existsSync6(outputPath) && readFileSync6(outputPath, "utf8").trim().length > 0;
1471
1574
  }
1472
1575
  async function runOrReuse(step, context) {
1473
1576
  if (!hasOutput(step, context)) return { ok: await runStep(step, context), ran: true };
@@ -1475,7 +1578,7 @@ async function runOrReuse(step, context) {
1475
1578
  return { ok: true, ran: false };
1476
1579
  }
1477
1580
  function reviewPath(context, reviewer) {
1478
- return join6(context.reviewDir, `${reviewer}.md`);
1581
+ return join7(context.reviewDir, `${reviewer}.md`);
1479
1582
  }
1480
1583
  function reviewerLabel(reviewer, context) {
1481
1584
  return `${reviewer} (${describeSlot(context.slots[reviewer])})`;
@@ -1494,7 +1597,7 @@ function synthesisResult(markdown) {
1494
1597
  function rawReviews(succeeded, context) {
1495
1598
  const report = succeeded.map((reviewer) => `# ${reviewerLabel(reviewer, context)} review
1496
1599
 
1497
- ${readFileSync5(reviewPath(context, reviewer), "utf8").trim()}`).join("\n\n");
1600
+ ${readFileSync6(reviewPath(context, reviewer), "utf8").trim()}`).join("\n\n");
1498
1601
  return { report, synthesis: null, markdown: null };
1499
1602
  }
1500
1603
  function synthesisSlot(succeeded, context) {
@@ -1503,7 +1606,7 @@ function synthesisSlot(succeeded, context) {
1503
1606
  }
1504
1607
  async function synthesise(requestPath, succeeded, reviewersRan, context) {
1505
1608
  const slot = synthesisSlot(succeeded, context);
1506
- const outputPath = join6(context.reviewDir, "synthesis.md");
1609
+ const outputPath = join7(context.reviewDir, "synthesis.md");
1507
1610
  if (reviewersRan) rmSync(outputPath, { force: true });
1508
1611
  const files = reviewers.map((reviewer) => ({
1509
1612
  reviewer: reviewerLabel(reviewer, context),
@@ -1511,13 +1614,13 @@ async function synthesise(requestPath, succeeded, reviewersRan, context) {
1511
1614
  }));
1512
1615
  const prompt = buildSynthesisPrompt(requestPath, files);
1513
1616
  if ((await runOrReuse({ name: "synthesis", label: `synthesis (${describeSlot(slot)})`, slot, prompt, outputPath }, context)).ok)
1514
- return synthesisResult(readFileSync5(outputPath, "utf8"));
1617
+ return synthesisResult(readFileSync6(outputPath, "utf8"));
1515
1618
  context.log("showing the raw reviews instead");
1516
1619
  return rawReviews(succeeded, context);
1517
1620
  }
1518
1621
  async function runReview(request, context) {
1519
- const requestPath = join6(context.reviewDir, "request.md");
1520
- writeFileSync5(requestPath, request);
1622
+ const requestPath = join7(context.reviewDir, "request.md");
1623
+ writeFileSync6(requestPath, request);
1521
1624
  const prompt = buildReviewerPrompt(requestPath);
1522
1625
  const results = await Promise.all(
1523
1626
  reviewers.map((reviewer) => {
@@ -1613,19 +1716,19 @@ function spinnerUi(stream) {
1613
1716
  function reviewRunDir(pr2, env = process.env, home = homedir2()) {
1614
1717
  const [, owner = "", repo = ""] = new URL(pr2.url).pathname.split("/");
1615
1718
  const run2 = `${pr2.number}-${pr2.baseRefOid}-${pr2.headRefOid}`;
1616
- return join7(env.XDG_CACHE_HOME || join7(home, ".cache"), "yoke", "reviews", owner, repo, run2);
1719
+ return join8(env.XDG_CACHE_HOME || join8(home, ".cache"), "yoke", "reviews", owner, repo, run2);
1617
1720
  }
1618
1721
  var cachedOutputs = ["reviewer1.md", "reviewer2.md", "synthesis.md", "review.md", "models.json", "slots.json"];
1619
1722
  function prepareRunDir(reviewDir, force) {
1620
- mkdirSync3(reviewDir, { recursive: true });
1621
- if (force) for (const name of cachedOutputs) rmSync2(join7(reviewDir, name), { force: true });
1723
+ mkdirSync4(reviewDir, { recursive: true });
1724
+ if (force) for (const name of cachedOutputs) rmSync2(join8(reviewDir, name), { force: true });
1622
1725
  }
1623
- async function reviewAtHead(pr2, reviewDir, request, slots, ui) {
1624
- const checkoutDir = mkdtempSync(join7(tmpdir(), "yoke-review-"));
1726
+ async function reviewAtHead(pr2, reviewDir, request, slots2, ui) {
1727
+ const checkoutDir = mkdtempSync(join8(tmpdir(), "yoke-review-"));
1625
1728
  try {
1626
1729
  const checkout = await timed(ui, `checking out ${pr2.headRefOid.slice(0, 7)}`, () => checkoutHead(pr2, checkoutDir));
1627
1730
  try {
1628
- return await runReview(request, { cwd: checkout.cwd, reviewDir, slots, runHarness, log: ui.log, track: ui.track });
1731
+ return await runReview(request, { cwd: checkout.cwd, reviewDir, slots: slots2, runHarness, log: ui.log, track: ui.track });
1629
1732
  } finally {
1630
1733
  await checkout.remove();
1631
1734
  }
@@ -1634,11 +1737,11 @@ async function reviewAtHead(pr2, reviewDir, request, slots, ui) {
1634
1737
  }
1635
1738
  }
1636
1739
  function savedReview(path, post) {
1637
- if (!post || !existsSync6(path)) return null;
1638
- return parseSynthesis(readFileSync6(path, "utf8"));
1740
+ if (!post || !existsSync7(path)) return null;
1741
+ return parseSynthesis(readFileSync7(path, "utf8"));
1639
1742
  }
1640
1743
  function saveReview(path, markdown) {
1641
- if (markdown) writeFileSync6(path, markdown);
1744
+ if (markdown) writeFileSync7(path, markdown);
1642
1745
  else rmSync2(path, { force: true });
1643
1746
  }
1644
1747
  async function reviewPr(number, settings, { post, force }, io) {
@@ -1649,7 +1752,7 @@ async function reviewPr(number, settings, { post, force }, io) {
1649
1752
  io.out(text2);
1650
1753
  };
1651
1754
  const { log } = ui;
1652
- const slots = resolveSlots(settings);
1755
+ const slots2 = resolveSlots(settings);
1653
1756
  const pr2 = await timed(ui, number ? `fetching PR #${number}` : "fetching this branch's PR", () => fetchPr(number));
1654
1757
  log(`#${pr2.number} ${pr2.title}`);
1655
1758
  const [diff, prior] = await timed(
@@ -1660,15 +1763,15 @@ async function reviewPr(number, settings, { post, force }, io) {
1660
1763
  const reviewDir = reviewRunDir(pr2);
1661
1764
  prepareRunDir(reviewDir, force);
1662
1765
  file.attach(reviewDir);
1663
- const path = join7(reviewDir, "review.md");
1766
+ const path = join8(reviewDir, "review.md");
1664
1767
  let synthesis = savedReview(path, post);
1665
1768
  if (synthesis) {
1666
1769
  log(`posting the saved review of #${pr2.number} at ${pr2.headRefOid.slice(0, 7)}`);
1667
1770
  out(formatSynthesis(synthesis));
1668
1771
  } else {
1669
- await timed(ui, "checking each reviewer can run", () => assertViable(slots));
1772
+ await timed(ui, "checking each reviewer can run", () => assertViable(slots2));
1670
1773
  log(`review folder: ${reviewDir}`);
1671
- const result = await reviewAtHead(pr2, reviewDir, buildRequest(pr2, diff, prior), slots, ui);
1774
+ const result = await reviewAtHead(pr2, reviewDir, buildRequest(pr2, diff, prior), slots2, ui);
1672
1775
  out(result.report);
1673
1776
  synthesis = result.synthesis;
1674
1777
  saveReview(path, result.markdown);
@@ -1681,9 +1784,9 @@ async function reviewPr(number, settings, { post, force }, io) {
1681
1784
  }
1682
1785
 
1683
1786
  // src/features/sync/sync.ts
1684
- import { existsSync as existsSync7, mkdirSync as mkdirSync4, readFileSync as readFileSync7, writeFileSync as writeFileSync7 } from "fs";
1787
+ import { existsSync as existsSync8, mkdirSync as mkdirSync5, readFileSync as readFileSync8, writeFileSync as writeFileSync8 } from "fs";
1685
1788
  import { homedir as homedir3 } from "os";
1686
- import { join as join8 } from "path";
1789
+ import { join as join9 } from "path";
1687
1790
 
1688
1791
  // src/skill.ts
1689
1792
  var ownerMarker = 'owner: "@makerx/yoke"';
@@ -1714,6 +1817,24 @@ If \`yoke\` is not found, run \`npm i -g @makerx/yoke\` and retry.
1714
1817
  `
1715
1818
  };
1716
1819
 
1820
+ // src/features/review/ciSkill.ts
1821
+ var reviewCiSkill = {
1822
+ name: "review-ci",
1823
+ description: "Add yoke's adversarial PR review workflow to this repo and set the secrets it needs.",
1824
+ disableModelInvocation: true,
1825
+ body: `
1826
+ 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
+
1828
+ Then set the two secrets it printed. Run \`gh secret list\` first; if either secret is missing, ask whether it is already set at the org level before setting it on the repo.
1829
+ - \`REVIEW_CI_BASE_URL\` is not secret: confirm the URL with the user and run the printed command.
1830
+ - \`REVIEW_CI_API_KEY\` must never enter this conversation: ask the user to run \`gh secret set REVIEW_CI_API_KEY\` in their own terminal, outside this session, and paste the key at its prompt.
1831
+
1832
+ Finish by running \`gh secret list\` and telling the user to commit and push the workflow file.
1833
+
1834
+ If \`yoke\` is not found, run \`npm i -g @makerx/yoke\` and retry.
1835
+ `
1836
+ };
1837
+
1717
1838
  // src/features/review/skill.ts
1718
1839
  var reviewSkill = {
1719
1840
  name: "review",
@@ -1729,24 +1850,24 @@ If \`yoke\` is not found, run \`npm i -g @makerx/yoke\` and retry.
1729
1850
  };
1730
1851
 
1731
1852
  // src/features/sync/sync.ts
1732
- var bundledSkills = [prSkill, reviewSkill];
1853
+ var bundledSkills = [prSkill, reviewSkill, reviewCiSkill];
1733
1854
  function skillsRoot(user, cwd = process.cwd(), home = homedir3()) {
1734
- return join8(user ? home : cwd, ".claude", "skills");
1855
+ return join9(user ? home : cwd, ".claude", "skills");
1735
1856
  }
1736
1857
  function isOwnedByUser(skillFile) {
1737
- return existsSync7(skillFile) && !readFileSync7(skillFile, "utf8").includes(ownerMarker);
1858
+ return existsSync8(skillFile) && !readFileSync8(skillFile, "utf8").includes(ownerMarker);
1738
1859
  }
1739
1860
  function syncSkills(destination, skills = bundledSkills) {
1740
1861
  const result = { written: [], skipped: [] };
1741
1862
  for (const skill of skills) {
1742
- const target = join8(destination, skill.name);
1743
- const skillFile = join8(target, "SKILL.md");
1863
+ const target = join9(destination, skill.name);
1864
+ const skillFile = join9(target, "SKILL.md");
1744
1865
  if (isOwnedByUser(skillFile)) {
1745
1866
  result.skipped.push(target);
1746
1867
  continue;
1747
1868
  }
1748
- mkdirSync4(target, { recursive: true });
1749
- writeFileSync7(skillFile, renderSkill(skill));
1869
+ mkdirSync5(target, { recursive: true });
1870
+ writeFileSync8(skillFile, renderSkill(skill));
1750
1871
  result.written.push(skillFile);
1751
1872
  }
1752
1873
  return result;
@@ -1811,6 +1932,22 @@ addSettingOptions(
1811
1932
  ui.stop();
1812
1933
  }
1813
1934
  });
1935
+ var ciKeys = settingKeys.filter((key) => /^review\.\w+\.(harness|model)$/.test(key));
1936
+ addSettingOptions(
1937
+ program.command("review-ci").description(`Write ${callerWorkflowPath} so each PR in this repo is reviewed in CI, then print the gh secret set commands it needs`).option("--provider <provider>", "litellm or foundry; every slot uses it in CI. Default: review.reviewer1.provider, else litellm").option("--force", `overwrite an existing ${callerWorkflowPath} that differs`),
1938
+ ciKeys
1939
+ ).addHelpText("after", settingsHelp).action((options) => {
1940
+ try {
1941
+ const settings = resolveSettings(flagSettings(options, ciKeys), process.env, readUserConfig(userConfigPath()));
1942
+ const workflow = callerWorkflow(settings, options.provider);
1943
+ const outcome = writeCallerWorkflow(repoRoot(), renderCallerWorkflow(workflow), options.force ?? false);
1944
+ console.log(reviewCiReport(outcome, secretCommands(settings, workflow.provider)));
1945
+ } catch (error) {
1946
+ if (!(error instanceof ReviewError || error instanceof ConfigError)) throw error;
1947
+ console.error(error.message);
1948
+ process.exitCode = 1;
1949
+ }
1950
+ });
1814
1951
  addSettingOptions(
1815
1952
  program.command("models").description("List the models each configured provider serves, for review.*.provider and review.*.model"),
1816
1953
  providerKeys
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@makerx/yoke",
3
- "version": "0.6.0",
3
+ "version": "0.7.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",
@@ -50,6 +50,9 @@
50
50
  "vitest": "^5.0.5",
51
51
  "yaml": "^2.9.1"
52
52
  },
53
+ "overrides": {
54
+ "minimatch": "^11.0.3"
55
+ },
53
56
  "engines": {
54
57
  "node": ">=24"
55
58
  }