@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.
- package/README.md +2 -0
- package/dist/cli.js +199 -62
- 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.
|
|
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
|
|
822
|
+
import { existsSync as existsSync7, mkdirSync as mkdirSync4, mkdtempSync, readFileSync as readFileSync7, rmSync as rmSync2, writeFileSync as writeFileSync7 } from "fs";
|
|
720
823
|
import { homedir as homedir2, tmpdir } from "os";
|
|
721
|
-
import { join as
|
|
824
|
+
import { join as join8 } from "path";
|
|
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
|
|
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
|
|
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")
|
|
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(
|
|
1267
|
+
function slotsToRun(slots2) {
|
|
1165
1268
|
const uses = [
|
|
1166
|
-
["review.reviewer1",
|
|
1167
|
-
["review.reviewer2",
|
|
1168
|
-
["review.synthesis",
|
|
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(
|
|
1180
|
-
const problems = await Promise.all(slotsToRun(
|
|
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
|
|
1187
|
-
import { join as
|
|
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
|
-
|
|
1199
|
-
path =
|
|
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
|
|
1231
|
-
import { join as
|
|
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
|
|
1235
|
-
import { join as
|
|
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
|
|
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 (!
|
|
1245
|
-
const parsed = JSON.parse(
|
|
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
|
|
1253
|
-
|
|
1254
|
-
|
|
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
|
|
1400
|
-
import { join as
|
|
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
|
|
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 (!
|
|
1411
|
-
const parsed = JSON.parse(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
${
|
|
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 =
|
|
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(
|
|
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 =
|
|
1520
|
-
|
|
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
|
|
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
|
-
|
|
1621
|
-
if (force) for (const name of cachedOutputs) rmSync2(
|
|
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,
|
|
1624
|
-
const checkoutDir = mkdtempSync(
|
|
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 || !
|
|
1638
|
-
return parseSynthesis(
|
|
1740
|
+
if (!post || !existsSync7(path)) return null;
|
|
1741
|
+
return parseSynthesis(readFileSync7(path, "utf8"));
|
|
1639
1742
|
}
|
|
1640
1743
|
function saveReview(path, markdown) {
|
|
1641
|
-
if (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
|
|
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 =
|
|
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(
|
|
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),
|
|
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
|
|
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
|
|
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
|
|
1855
|
+
return join9(user ? home : cwd, ".claude", "skills");
|
|
1735
1856
|
}
|
|
1736
1857
|
function isOwnedByUser(skillFile) {
|
|
1737
|
-
return
|
|
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 =
|
|
1743
|
-
const skillFile =
|
|
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
|
-
|
|
1749
|
-
|
|
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.
|
|
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
|
}
|