@gethmy/agent 1.39.0 → 1.40.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/dist/index.js CHANGED
@@ -851,6 +851,9 @@ function buildGaveUpComment(maxAttempts, failures, pauseEnabled) {
851
851
  function mergeAgentConfig(defaults, overrides) {
852
852
  return mergeObjects(defaults, overrides);
853
853
  }
854
+ function own(source, key) {
855
+ return Object.hasOwn(source, key) ? source[key] : undefined;
856
+ }
854
857
  function isPlainObject(value) {
855
858
  if (typeof value !== "object" || value === null || Array.isArray(value)) {
856
859
  return false;
@@ -863,7 +866,7 @@ function mergeObjects(base, override) {
863
866
  for (const key of Object.keys(base)) {
864
867
  if (FORBIDDEN_KEYS.has(key))
865
868
  continue;
866
- result[key] = mergeValue(base[key], override[key]);
869
+ result[key] = mergeValue(base[key], own(override, key));
867
870
  }
868
871
  for (const key of Object.keys(override)) {
869
872
  if (FORBIDDEN_KEYS.has(key))
@@ -902,7 +905,7 @@ function walk(base, override, path, found) {
902
905
  if (FORBIDDEN_KEYS.has(key))
903
906
  continue;
904
907
  const baseValue = base[key];
905
- const overrideValue = override[key];
908
+ const overrideValue = own(override, key);
906
909
  if (overrideValue === undefined)
907
910
  continue;
908
911
  const here = `${path}.${key}`;
@@ -947,7 +950,11 @@ function copy(value) {
947
950
  }
948
951
  var FORBIDDEN_KEYS;
949
952
  var init_config_merge = __esm(() => {
950
- FORBIDDEN_KEYS = new Set(["__proto__", "constructor", "prototype"]);
953
+ FORBIDDEN_KEYS = new Set([
954
+ "__proto__",
955
+ "constructor",
956
+ "prototype"
957
+ ]);
951
958
  });
952
959
 
953
960
  // src/config-validation.ts
@@ -962,8 +969,10 @@ __export(exports_config_validation, {
962
969
  validateBudgetConfig: () => validateBudgetConfig,
963
970
  validateAutoMergeConfig: () => validateAutoMergeConfig,
964
971
  validateAndListColumns: () => validateAndListColumns,
972
+ repoArmedGatesOf: () => repoArmedGatesOf,
965
973
  isLoopbackBindAddr: () => isLoopbackBindAddr,
966
974
  findRemovedConfigKeys: () => findRemovedConfigKeys,
975
+ REPO_ARMABLE_GATES: () => REPO_ARMABLE_GATES,
967
976
  REMOVED_CONFIG_KEYS: () => REMOVED_CONFIG_KEYS,
968
977
  ConfigValidationError: () => ConfigValidationError
969
978
  });
@@ -978,7 +987,7 @@ function findRemovedConfigKeys(rawConfig) {
978
987
  cursor = undefined;
979
988
  break;
980
989
  }
981
- cursor = cursor[segment];
990
+ cursor = own(cursor, segment);
982
991
  }
983
992
  if (cursor !== undefined) {
984
993
  found.push(`${path} is no longer used (#988) — ${note}. Remove it.`);
@@ -1011,9 +1020,49 @@ function validateVerificationConfig(config) {
1011
1020
  - `)}`, issues);
1012
1021
  }
1013
1022
  }
1014
- function validateAutoMergeConfig(config) {
1023
+ function repoArmedGatesOf(suppliedPaths, operatorAgent, merged) {
1024
+ const armed = new Set;
1025
+ for (const gate of Object.keys(REPO_ARMABLE_GATES)) {
1026
+ if (!suppliedPaths.has(gate))
1027
+ continue;
1028
+ if (ownValueAt(operatorAgent, gate))
1029
+ continue;
1030
+ if (ownValueAt(merged, gate) !== true)
1031
+ continue;
1032
+ armed.add(gate);
1033
+ }
1034
+ return armed;
1035
+ }
1036
+ function ownValueAt(root, dotted) {
1037
+ let cursor = root;
1038
+ for (const segment of dotted.split(".")) {
1039
+ if (cursor === null || typeof cursor !== "object" || !Object.hasOwn(cursor, segment)) {
1040
+ return;
1041
+ }
1042
+ cursor = cursor[segment];
1043
+ }
1044
+ return cursor;
1045
+ }
1046
+ function armedByRepoPin(gate) {
1047
+ if (REPO_ARMABLE_GATES[gate] === "tightening") {
1048
+ return `This repository's .hmy.json sets "${gate}", and your own config had that gate off — so the repository is what makes this rule apply at all. It is a tightening, so the repository may set it without your "allowRepoOverrides", and your own config was legal until you cloned this checkout. Two remedies: drop "${gate}" from this repository's .hmy.json, or give the key this message names a usable value in ~/.hmy/agent/config.json — which keeps the gate armed, as the repository asked, unless the pin supplies that key too (it is the top rung, and any key it supplied is named in the warnings above).`;
1049
+ }
1050
+ return `This repository's .hmy.json sets "${gate}", and your own config had it off — so the repository is what makes this rule apply at all. That is a loosening, accepted only because your ~/.hmy/agent/config.json sets "allowRepoOverrides": true, and your own config was legal until you cloned this checkout. Two remedies: drop "${gate}" from this repository's .hmy.json, or turn "allowRepoOverrides" off in your own config, which drops every repo-supplied loosening at once. To keep what the repository asked for instead, satisfy the rule this message opens with — unless this repository's .hmy.json supplies that key as well, in which case only the two remedies above can help, because the pin is the top rung.`;
1051
+ }
1052
+ function validateAutoMergeConfig(config, repoSuppliedPaths = NO_REPO_PATHS, repoArmedGates = NO_REPO_PATHS) {
1015
1053
  const autoMerge = config.review.autoMerge;
1016
1054
  const issues = [];
1055
+ const warnings = [];
1056
+ const armedRefusal = (gate, issue) => {
1057
+ issues.push(repoArmedGates.has(gate) ? `${issue}. ${armedByRepoPin(gate)}` : issue);
1058
+ };
1059
+ const inertCombination = (args) => {
1060
+ if (repoSuppliedPaths.has(args.gate)) {
1061
+ warnings.push(`${args.pin} is inert here: this repository's .hmy.json sets ${args.gate}, and ${args.pin} only runs behind it. The repository's tightening wins — your own config is not wrong, and nothing else is switched off.`);
1062
+ return;
1063
+ }
1064
+ armedRefusal(args.pin, args.issue);
1065
+ };
1017
1066
  const valid = ["squash", "merge", "rebase"];
1018
1067
  const s = autoMerge.strategy;
1019
1068
  if (!valid.includes(s)) {
@@ -1022,34 +1071,44 @@ function validateAutoMergeConfig(config) {
1022
1071
  const repair = autoMerge.ciRepair;
1023
1072
  if (repair.enabled) {
1024
1073
  if (!autoMerge.enabled) {
1025
- issues.push("review.autoMerge.ciRepair.enabled: needs review.autoMerge.enabled — a repair only runs on a card the daemon would merge itself");
1074
+ inertCombination({
1075
+ pin: "review.autoMerge.ciRepair.enabled",
1076
+ gate: "review.autoMerge.enabled",
1077
+ issue: "review.autoMerge.ciRepair.enabled: needs review.autoMerge.enabled — a repair only runs on a card the daemon would merge itself"
1078
+ });
1026
1079
  }
1027
1080
  if (!autoMerge.requireGreenCi) {
1028
- issues.push("review.autoMerge.ciRepair.enabled: needs review.autoMerge.requireGreenCi — without it a red CI never reaches the repair branch, so the loop can never run");
1081
+ armedRefusal("review.autoMerge.ciRepair.enabled", "review.autoMerge.ciRepair.enabled: needs review.autoMerge.requireGreenCi — without it a red CI never reaches the repair branch, so the loop can never run");
1029
1082
  }
1030
1083
  if (!Number.isInteger(repair.maxAttempts) || repair.maxAttempts < 1) {
1031
- issues.push(`review.autoMerge.ciRepair.maxAttempts: must be an integer >= 1 (got ${repair.maxAttempts}) — the cap is the safety property and has no opt-out`);
1084
+ armedRefusal("review.autoMerge.ciRepair.enabled", `review.autoMerge.ciRepair.maxAttempts: must be an integer >= 1 (got ${repair.maxAttempts}) — the cap is the safety property and has no opt-out`);
1032
1085
  }
1033
1086
  }
1034
1087
  const patch = repair.patch;
1035
1088
  if (patch?.enabled) {
1036
1089
  if (!repair.enabled) {
1037
- issues.push("review.autoMerge.ciRepair.patch.enabled: needs review.autoMerge.ciRepair.enabled — the repair runs inside the loop that cap, park latch and ownership re-read belong to");
1090
+ inertCombination({
1091
+ pin: "review.autoMerge.ciRepair.patch.enabled",
1092
+ gate: "review.autoMerge.ciRepair.enabled",
1093
+ issue: "review.autoMerge.ciRepair.patch.enabled: needs review.autoMerge.ciRepair.enabled — the repair runs inside the loop that cap, park latch and ownership re-read belong to"
1094
+ });
1038
1095
  }
1039
1096
  if (!autoMerge.reReviewOnBranchChange) {
1040
- issues.push("review.autoMerge.ciRepair.patch.enabled: needs review.autoMerge.reReviewOnBranchChange — without it a machine-authored repair could merge with nothing having reviewed its diff");
1097
+ armedRefusal("review.autoMerge.ciRepair.patch.enabled", "review.autoMerge.ciRepair.patch.enabled: needs review.autoMerge.reReviewOnBranchChange — without it a machine-authored repair could merge with nothing having reviewed its diff");
1041
1098
  }
1042
- issues.push(...sandboxConfigIssues({
1099
+ for (const issue of sandboxConfigIssues({
1043
1100
  image: patch.sandboxImage,
1044
1101
  timeoutMs: patch.sandboxTimeoutMs,
1045
1102
  imagePath: "review.autoMerge.ciRepair.patch.sandboxImage",
1046
1103
  timeoutPath: "review.autoMerge.ciRepair.patch.sandboxTimeoutMs"
1047
- }));
1104
+ })) {
1105
+ armedRefusal("review.autoMerge.ciRepair.patch.enabled", issue);
1106
+ }
1048
1107
  if (!Number.isInteger(patch.maxTurns) || patch.maxTurns < 1) {
1049
- issues.push(`review.autoMerge.ciRepair.patch.maxTurns: must be an integer >= 1 (got ${patch.maxTurns})`);
1108
+ armedRefusal("review.autoMerge.ciRepair.patch.enabled", `review.autoMerge.ciRepair.patch.maxTurns: must be an integer >= 1 (got ${patch.maxTurns})`);
1050
1109
  }
1051
1110
  if (!(patch.maxBudgetUsd > 0)) {
1052
- issues.push(`review.autoMerge.ciRepair.patch.maxBudgetUsd: must be greater than 0 (got ${patch.maxBudgetUsd})`);
1111
+ armedRefusal("review.autoMerge.ciRepair.patch.enabled", `review.autoMerge.ciRepair.patch.maxBudgetUsd: must be greater than 0 (got ${patch.maxBudgetUsd})`);
1053
1112
  }
1054
1113
  }
1055
1114
  const independent = autoMerge.independentReview;
@@ -1058,38 +1117,46 @@ function validateAutoMergeConfig(config) {
1058
1117
  }
1059
1118
  if (independent?.enabled) {
1060
1119
  if (typeof independent.checkName !== "string" || !independent.checkName.trim()) {
1061
- issues.push("review.autoMerge.independentReview.checkName: must name a non-empty check — an unmatched name reads as no review at all and holds every merge");
1120
+ armedRefusal("review.autoMerge.independentReview.enabled", "review.autoMerge.independentReview.checkName: must name a non-empty check — an unmatched name reads as no review at all and holds every merge");
1062
1121
  }
1063
1122
  if (typeof independent.label !== "string" || !independent.label.trim()) {
1064
- issues.push("review.autoMerge.independentReview.label: must name the PR label that requests a CI review");
1123
+ armedRefusal("review.autoMerge.independentReview.enabled", "review.autoMerge.independentReview.label: must name the PR label that requests a CI review");
1065
1124
  }
1066
1125
  const verdict = independent.verdict;
1067
1126
  if (typeof verdict?.enabled !== "boolean") {
1068
- issues.push(`review.autoMerge.independentReview.verdict.enabled: must be true or false (got ${JSON.stringify(verdict?.enabled)}) — an absent value would switch this safety gate off without saying so`);
1127
+ armedRefusal("review.autoMerge.independentReview.enabled", `review.autoMerge.independentReview.verdict.enabled: must be true or false (got ${JSON.stringify(verdict?.enabled)}) — an absent value would switch this safety gate off without saying so`);
1069
1128
  }
1070
1129
  if (typeof verdict?.checkName !== "string" || !verdict.checkName.trim()) {
1071
- issues.push("review.autoMerge.independentReview.verdict.checkName: must name a non-empty check even when verdict.enabled is false — the name keeps that check out of the collapsed CI verdict, so a blank one turns a review finding into a red build");
1130
+ armedRefusal("review.autoMerge.independentReview.enabled", "review.autoMerge.independentReview.verdict.checkName: must name a non-empty check even when verdict.enabled is false — the name keeps that check out of the collapsed CI verdict, so a blank one turns a review finding into a red build");
1072
1131
  }
1073
1132
  if (typeof independent.checkName === "string" && typeof verdict?.checkName === "string" && independent.checkName.trim().toLowerCase() === verdict.checkName.trim().toLowerCase() && independent.checkName.trim()) {
1074
- issues.push(`review.autoMerge.independentReview.verdict.checkName: must not be the same check as independentReview.checkName ("${independent.checkName}") — one says a review RAN and the other says what it concluded, and each reader skips the other's check, so naming one check for both holds every merge`);
1133
+ armedRefusal("review.autoMerge.independentReview.enabled", `review.autoMerge.independentReview.verdict.checkName: must not be the same check as independentReview.checkName ("${independent.checkName}") — one says a review RAN and the other says what it concluded, and each reader skips the other's check, so naming one check for both holds every merge`);
1075
1134
  }
1076
1135
  }
1077
1136
  if (typeof autoMerge.interactive !== "boolean") {
1078
1137
  issues.push(`review.autoMerge.interactive: must be true or false (got ${JSON.stringify(autoMerge.interactive)}) — an absent value would switch the interactive merge on or off without saying so`);
1079
1138
  }
1139
+ if (autoMerge.interactive || autoMerge.enabled) {
1140
+ const driver = autoMerge.interactive ? "review.autoMerge.interactive" : "review.autoMerge.enabled";
1141
+ for (const named of REPO_UNSETTABLE_CHECK_NAMES) {
1142
+ if (!repoSuppliedPaths.has(named))
1143
+ continue;
1144
+ issues.push(`${named}: must not come from a repository's .hmy.json while ${driver} is on — the named check IS the independent review on that driver, and it is also excluded from the collapsed CI verdict, so a repository choosing the name chooses both what counts as having read the diff and which red check the merge gate stops seeing. Two remedies: set it in your own ~/.hmy/agent/config.json (an operator naming their own checks is untouched), or drop the key from this repository's .hmy.json. Turning off "allowRepoOverrides" in your own config also drops it, at the cost of every other repo-supplied setting.`);
1145
+ }
1146
+ }
1080
1147
  if (autoMerge.interactive) {
1081
1148
  if (independent?.enabled === false) {
1082
- issues.push("review.autoMerge.independentReview.enabled: must be true while review.autoMerge.interactive is on — the interactive merge relaxes the Reviewed-SHA rule (no hmy card carries that marker), so the independent review is the only thing left that reads the diff, and nobody else is looking");
1149
+ armedRefusal("review.autoMerge.interactive", "review.autoMerge.independentReview.enabled: must be true while review.autoMerge.interactive is on — the interactive merge relaxes the Reviewed-SHA rule (no hmy card carries that marker), so the independent review is the only thing left that reads the diff, and nobody else is looking");
1083
1150
  }
1084
1151
  if (!autoMerge.reReviewOnBranchChange) {
1085
- issues.push("review.autoMerge.reReviewOnBranchChange: must be true while review.autoMerge.interactive is on — with the independent review off it is the only rule left that holds an interactive merge, and the interactive driver reads no Reviewed-SHA of its own");
1152
+ armedRefusal("review.autoMerge.interactive", "review.autoMerge.reReviewOnBranchChange: must be true while review.autoMerge.interactive is on — with the independent review off it is the only rule left that holds an interactive merge, and the interactive driver reads no Reviewed-SHA of its own");
1086
1153
  }
1087
1154
  if (independent?.enabled && independent.verdict?.enabled === false) {
1088
- issues.push("review.autoMerge.independentReview.verdict.enabled: must be true while review.autoMerge.interactive is on — the interactive driver reads no Reviewed-SHA of its own, so the verdict is the only remaining signal that a review approved the diff, and a green review check alone means the pass ran, not that it approved (#1018)");
1155
+ armedRefusal("review.autoMerge.interactive", "review.autoMerge.independentReview.verdict.enabled: must be true while review.autoMerge.interactive is on — the interactive driver reads no Reviewed-SHA of its own, so the verdict is the only remaining signal that a review approved the diff, and a green review check alone means the pass ran, not that it approved (#1018)");
1089
1156
  }
1090
1157
  const w = autoMerge.waitMinutes;
1091
1158
  if (!Number.isInteger(w) || w < 1 || w > 55) {
1092
- issues.push(`review.autoMerge.waitMinutes: must be an integer between 1 and 55 (got ${w}) — an interactive agent session goes stale at about an hour, and a command that outlives its session reports into nothing`);
1159
+ armedRefusal("review.autoMerge.interactive", `review.autoMerge.waitMinutes: must be an integer between 1 and 55 (got ${w}) — an interactive agent session goes stale at about an hour, and a command that outlives its session reports into nothing`);
1093
1160
  }
1094
1161
  }
1095
1162
  if (typeof autoMerge.findingsLoop !== "boolean") {
@@ -1101,16 +1168,21 @@ function validateAutoMergeConfig(config) {
1101
1168
  }
1102
1169
  if (autoMerge.findingsLoop) {
1103
1170
  if (!autoMerge.interactive) {
1104
- issues.push("review.autoMerge.findingsLoop: needs review.autoMerge.interactive — the loop only runs on the interactive merge path, so armed alone it is inert rather than weaker");
1171
+ inertCombination({
1172
+ pin: "review.autoMerge.findingsLoop",
1173
+ gate: "review.autoMerge.interactive",
1174
+ issue: "review.autoMerge.findingsLoop: needs review.autoMerge.interactive — the loop only runs on the interactive merge path, so armed alone it is inert rather than weaker"
1175
+ });
1105
1176
  }
1106
1177
  const findingAuthors = independent?.findingAuthors;
1107
1178
  if (!Array.isArray(findingAuthors) || findingAuthors.filter((a) => typeof a === "string" && a.trim()).length === 0) {
1108
- issues.push("review.autoMerge.independentReview.findingAuthors: must name at least one login while review.autoMerge.findingsLoop is on — it is the only thing separating the review's findings from every other commenter's text, and an empty list parks every round");
1179
+ armedRefusal("review.autoMerge.findingsLoop", "review.autoMerge.independentReview.findingAuthors: must name at least one login while review.autoMerge.findingsLoop is on — it is the only thing separating the review's findings from every other commenter's text, and an empty list parks every round");
1109
1180
  }
1110
1181
  }
1111
1182
  if (issues.length > 0) {
1112
1183
  throw new ConfigValidationError(`Invalid agent config — ${issues.join("; ")}`, issues);
1113
1184
  }
1185
+ return warnings;
1114
1186
  }
1115
1187
  function isLoopbackBindAddr(addr) {
1116
1188
  const value = addr.toLowerCase();
@@ -1332,8 +1404,9 @@ async function validateAndListColumns(client, projectId, config) {
1332
1404
  }
1333
1405
  return Array.from(new Set(names.filter(Boolean)));
1334
1406
  }
1335
- var ConfigValidationError, REMOVED_CONFIG_KEYS;
1407
+ var ConfigValidationError, REMOVED_CONFIG_KEYS, NO_REPO_PATHS, REPO_UNSETTABLE_CHECK_NAMES, REPO_ARMABLE_GATES;
1336
1408
  var init_config_validation = __esm(() => {
1409
+ init_config_merge();
1337
1410
  ConfigValidationError = class ConfigValidationError extends Error {
1338
1411
  issues;
1339
1412
  constructor(message, issues) {
@@ -1351,6 +1424,18 @@ var init_config_validation = __esm(() => {
1351
1424
  note: "review, auto-fix and deep-review are contained and pin their own sources"
1352
1425
  }
1353
1426
  ];
1427
+ NO_REPO_PATHS = new Set;
1428
+ REPO_UNSETTABLE_CHECK_NAMES = [
1429
+ "review.autoMerge.independentReview.checkName",
1430
+ "review.autoMerge.independentReview.verdict.checkName"
1431
+ ];
1432
+ REPO_ARMABLE_GATES = {
1433
+ "review.autoMerge.independentReview.enabled": "tightening",
1434
+ "review.autoMerge.interactive": "handshake",
1435
+ "review.autoMerge.ciRepair.enabled": "handshake",
1436
+ "review.autoMerge.ciRepair.patch.enabled": "handshake",
1437
+ "review.autoMerge.findingsLoop": "handshake"
1438
+ };
1354
1439
  });
1355
1440
 
1356
1441
  // src/paths.ts
@@ -1497,6 +1582,9 @@ var init_branchRef = __esm(() => {
1497
1582
  PR_LINK_PATTERN = /PR:\s*(https?:\/\/[^\s)]+)/;
1498
1583
  });
1499
1584
 
1585
+ // ../harmony-shared/dist/cardLifecycle.js
1586
+ var init_cardLifecycle = () => {};
1587
+
1500
1588
  // ../harmony-shared/dist/cardLinks.js
1501
1589
  var init_cardLinks = () => {};
1502
1590
 
@@ -2985,6 +3073,7 @@ function untrustedDataBlock(text, options) {
2985
3073
  var init_dist = __esm(() => {
2986
3074
  init_agentStaleness();
2987
3075
  init_branchRef();
3076
+ init_cardLifecycle();
2988
3077
  init_cardLinks();
2989
3078
  init_cardModelStat();
2990
3079
  init_classification();
@@ -3348,16 +3437,17 @@ var init_types2 = __esm(() => {
3348
3437
  },
3349
3438
  claude: {
3350
3439
  model: "claude-opus-5",
3351
- escalateModel: "claude-fable-5",
3440
+ escalateModel: "claude-fable-5-1",
3352
3441
  escalateAfterAttempts: 2,
3353
3442
  tiers: {
3354
3443
  simple: "claude-sonnet-5",
3355
3444
  advanced: "claude-opus-5",
3356
- research: "claude-fable-5"
3445
+ research: "claude-fable-5-1"
3357
3446
  },
3358
3447
  scoreBands: DEFAULT_SCORE_BANDS,
3359
3448
  sizingModel: "",
3360
3449
  reviewModel: "sonnet",
3450
+ deepReviewModel: "sonnet",
3361
3451
  maxTurns: 240,
3362
3452
  reviewMaxTurns: 60,
3363
3453
  additionalArgs: []
@@ -3472,6 +3562,302 @@ var init_types2 = __esm(() => {
3472
3562
  NOTICE_IDENTIFIER = `${DAEMON_IDENTIFIER_BASE}-notice`;
3473
3563
  });
3474
3564
 
3565
+ // src/repo-agent-config.ts
3566
+ import { readFileSync } from "node:fs";
3567
+ import { findLocalConfigPath } from "@gethmy/mcp/src/config.js";
3568
+ function isNeverSettable(path) {
3569
+ for (const banned of REPO_NEVER_SETTABLE) {
3570
+ if (path === banned || path.startsWith(`${banned}.`))
3571
+ return true;
3572
+ }
3573
+ return false;
3574
+ }
3575
+ function renderForWarning(value) {
3576
+ const json = JSON.stringify(clampForRender(value)) ?? String(value);
3577
+ const clean = json.replace(/\p{Cf}/gu, "").replace(/[\p{Cc}\u2028\u2029]/gu, " ");
3578
+ if (clean.length <= MAX_RENDERED_LENGTH)
3579
+ return clean;
3580
+ const head = clean.slice(0, MAX_RENDERED_LENGTH).replace(/\\u[0-9a-fA-F]{0,3}$/, "").replace(/\\+$/, "").replace(/[\uD800-\uDBFF]$/, "");
3581
+ const closer = clean.startsWith('"') ? '"' : "";
3582
+ return `${head}…${closer} (truncated from ${clean.length} characters)`;
3583
+ }
3584
+ function clampForRender(value, depth = 0) {
3585
+ if (Array.isArray(value)) {
3586
+ if (depth >= MAX_RENDER_DEPTH)
3587
+ return ELISION;
3588
+ const kept = value.slice(0, MAX_RENDER_ENTRIES).map((item) => clampForRender(item, depth + 1));
3589
+ if (value.length > MAX_RENDER_ENTRIES)
3590
+ kept.push(ELISION);
3591
+ return kept;
3592
+ }
3593
+ if (isPlainObject(value)) {
3594
+ if (depth >= MAX_RENDER_DEPTH)
3595
+ return ELISION;
3596
+ const keys = Object.keys(value);
3597
+ const out = Object.create(null);
3598
+ for (const key of keys.slice(0, MAX_RENDER_ENTRIES)) {
3599
+ out[key] = clampForRender(value[key], depth + 1);
3600
+ }
3601
+ return out;
3602
+ }
3603
+ return value;
3604
+ }
3605
+ function describeParent(drop) {
3606
+ return drop.parent ? ` inside ${renderForWarning(`agent.${drop.parent}`)}` : ` inside "agent"`;
3607
+ }
3608
+ function isOneSegment(key) {
3609
+ return key.length > 0 && !key.includes(".") && !UNPRINTABLE.test(key);
3610
+ }
3611
+ function refuseValue(value, depth = 0) {
3612
+ if (typeof value === "string") {
3613
+ if (UNPRINTABLE.test(value))
3614
+ return `A repo-supplied value may not hold a control or invisible character, and nothing in ${ALLOWED_PREFIX}* is spelled with one.`;
3615
+ if (value.length > MAX_VALUE_LENGTH)
3616
+ return `A repo-supplied value may be at most ${MAX_VALUE_LENGTH} characters, and this one is ${value.length}.`;
3617
+ return null;
3618
+ }
3619
+ if (depth >= MAX_VALUE_DEPTH)
3620
+ return `A repo-supplied value may not nest more than ${MAX_VALUE_DEPTH} levels deep.`;
3621
+ if (Array.isArray(value)) {
3622
+ for (const item of value) {
3623
+ const why = refuseValue(item, depth + 1);
3624
+ if (why !== null)
3625
+ return why;
3626
+ }
3627
+ return null;
3628
+ }
3629
+ if (isPlainObject(value)) {
3630
+ for (const [key, item] of Object.entries(value)) {
3631
+ const why = refuseValue(key, depth + 1) ?? refuseValue(item, depth + 1);
3632
+ if (why !== null)
3633
+ return why;
3634
+ }
3635
+ return null;
3636
+ }
3637
+ return null;
3638
+ }
3639
+ function flatten(value, prefix, out, dropped, depth = 0) {
3640
+ if (!isPlainObject(value)) {
3641
+ out.set(prefix, value);
3642
+ return;
3643
+ }
3644
+ if (depth >= MAX_BLOCK_DEPTH) {
3645
+ dropped.push({
3646
+ path: prefix,
3647
+ key: lastSegment(prefix),
3648
+ parent: parentPath(prefix),
3649
+ why: "depth"
3650
+ });
3651
+ return;
3652
+ }
3653
+ const keys = Object.keys(value);
3654
+ if (keys.length === 0)
3655
+ return;
3656
+ for (const key of keys) {
3657
+ const here = prefix ? `${prefix}.${key}` : key;
3658
+ if (FORBIDDEN_KEYS.has(key)) {
3659
+ dropped.push({ path: here, key, parent: prefix, why: "reserved" });
3660
+ continue;
3661
+ }
3662
+ if (!isOneSegment(key)) {
3663
+ dropped.push({
3664
+ path: here,
3665
+ key,
3666
+ parent: prefix,
3667
+ why: UNPRINTABLE.test(key) ? "unprintable" : "segment"
3668
+ });
3669
+ continue;
3670
+ }
3671
+ flatten(value[key], here, out, dropped, depth + 1);
3672
+ }
3673
+ }
3674
+ function lastSegment(path) {
3675
+ const at = path.lastIndexOf(".");
3676
+ return at === -1 ? path : path.slice(at + 1);
3677
+ }
3678
+ function parentPath(path) {
3679
+ const at = path.lastIndexOf(".");
3680
+ return at === -1 ? "" : path.slice(0, at);
3681
+ }
3682
+ function setPath(target, path, value) {
3683
+ const parts = path.split(".");
3684
+ if (parts.some((part) => FORBIDDEN_KEYS.has(part) || !isOneSegment(part)))
3685
+ return false;
3686
+ let node = target;
3687
+ for (const part of parts.slice(0, -1)) {
3688
+ const existing = own(node, part);
3689
+ if (!isPlainObject(existing))
3690
+ node[part] = {};
3691
+ node = node[part];
3692
+ }
3693
+ node[parts[parts.length - 1]] = value;
3694
+ return true;
3695
+ }
3696
+ function describeDrop(pinPath, drop) {
3697
+ switch (drop.why) {
3698
+ case "reserved":
3699
+ return `${pinPath}: ${renderForWarning(`agent.${drop.path}`)} uses a reserved JavaScript key name — ignoring it. Nothing in ${ALLOWED_PREFIX}* is spelled that way.`;
3700
+ case "unprintable":
3701
+ return `${pinPath}: ${renderForWarning(drop.key)} is not a setting name${describeParent(drop)} — ignoring it. A name may not hold a control or invisible character, and nothing in ${ALLOWED_PREFIX}* is spelled with one.`;
3702
+ case "depth":
3703
+ return `${pinPath}: ${renderForWarning(`agent.${drop.path}`)} nests more than ${MAX_BLOCK_DEPTH} blocks deep — ignoring it and everything under it. The deepest setting anything reads is ${ALLOWED_PREFIX}ciRepair.patch.enabled, which is 5 levels.`;
3704
+ default:
3705
+ return `${pinPath}: ${renderForWarning(drop.key)} is ONE key${describeParent(drop)}, not the nesting it looks like — ignoring it. Write a setting as real nesting: {"review": {"autoMerge": {"enabled": false}}}. A key must be a name, so it may not be empty and may not contain a ".".`;
3706
+ }
3707
+ }
3708
+ function describeValue(value) {
3709
+ if (value === null)
3710
+ return "null";
3711
+ if (Array.isArray(value))
3712
+ return "a list";
3713
+ if (isPlainObject(value))
3714
+ return "a block of settings";
3715
+ return `a ${typeof value}`;
3716
+ }
3717
+ function mismatchedPath(message) {
3718
+ return message.slice(0, message.indexOf(" is "));
3719
+ }
3720
+ function readRepoAgentConfig(args) {
3721
+ const nothing = {
3722
+ overrides: {},
3723
+ warnings: [],
3724
+ suppliedPaths: new Set
3725
+ };
3726
+ const pinPath = findLocalConfigPath(args.repoRoot);
3727
+ if (pinPath === null)
3728
+ return nothing;
3729
+ let parsed;
3730
+ try {
3731
+ parsed = JSON.parse(readFileSync(pinPath, "utf-8"));
3732
+ } catch {
3733
+ return nothing;
3734
+ }
3735
+ if (!isPlainObject(parsed))
3736
+ return nothing;
3737
+ const warnings = [];
3738
+ for (const key of Object.keys(parsed)) {
3739
+ if (KNOWN_PIN_KEYS.includes(key))
3740
+ continue;
3741
+ warnings.push(`${pinPath}: ${renderForWarning(key)} is not a key anything reads — ignoring it. Known keys: ${KNOWN_PIN_KEYS.join(", ")}`);
3742
+ }
3743
+ const block = own(parsed, "agent");
3744
+ if (block !== undefined && !isPlainObject(block)) {
3745
+ warnings.push(`${pinPath}: "agent" is ${describeValue(block)} — it must be a block of ${ALLOWED_PREFIX}* settings. Ignoring it.`);
3746
+ }
3747
+ if (!isPlainObject(block))
3748
+ return { overrides: {}, warnings, suppliedPaths: new Set };
3749
+ const leaves = new Map;
3750
+ const dropped = [];
3751
+ flatten(block, "", leaves, dropped);
3752
+ for (const drop of dropped) {
3753
+ warnings.push(describeDrop(pinPath, drop));
3754
+ }
3755
+ const inScope = [];
3756
+ const candidate = {};
3757
+ for (const [path, value] of leaves) {
3758
+ if (path === ALLOWED_BLOCK) {
3759
+ warnings.push(`${pinPath}: ${renderForWarning(`agent.${path}`)} is ${describeValue(value)} — it must be a block of ${ALLOWED_PREFIX}* settings, so there is no setting here to read. Ignoring it.`);
3760
+ continue;
3761
+ }
3762
+ if (!path.startsWith(ALLOWED_PREFIX)) {
3763
+ warnings.push(`${pinPath}: ${renderForWarning(`agent.${path}`)} is global config, not repo config — ignoring it. A repository may set ${ALLOWED_PREFIX}* and nothing else.`);
3764
+ continue;
3765
+ }
3766
+ if (isNeverSettable(path)) {
3767
+ warnings.push(`${pinPath}: ${renderForWarning(`agent.${path}`)} is an EXECUTION parameter, not merge policy — ignoring it. It decides what the daemon RUNS on this machine (the container image a CI repair is verified in, and how long it may run), so it stays with identity, credentials, "pool" and "claude.*" in ${args.globalConfigPath}. "allowRepoOverrides" does not reach it.`);
3768
+ continue;
3769
+ }
3770
+ const badValue = refuseValue(value);
3771
+ if (badValue !== null) {
3772
+ warnings.push(`${pinPath}: ${renderForWarning(`agent.${path}`)} = ${renderForWarning(value)} is not a value a repository may set — ignoring it. ${badValue}`);
3773
+ continue;
3774
+ }
3775
+ if (!setPath(candidate, path, value)) {
3776
+ warnings.push(`${pinPath}: ${renderForWarning(`agent.${path}`)} contains a reserved JavaScript key name — ignoring it. Nothing in ${ALLOWED_PREFIX}* is spelled that way.`);
3777
+ continue;
3778
+ }
3779
+ inScope.push([path, value]);
3780
+ }
3781
+ const malformed = [];
3782
+ for (const message of findConfigTypeMismatches(DEFAULT_AGENT_CONFIG, candidate)) {
3783
+ warnings.push(`${pinPath}: ${message}`);
3784
+ malformed.push(mismatchedPath(message).replace(/^agent\./, ""));
3785
+ }
3786
+ const kept = {};
3787
+ const suppliedPaths = new Set;
3788
+ for (const [path, value] of inScope) {
3789
+ if (malformed.some((bad) => path === bad || path.startsWith(`${bad}.`)))
3790
+ continue;
3791
+ if (!args.allowRepoOverrides && FREE_VALUE.get(path) !== value) {
3792
+ warnings.push(`${pinPath}: ${renderForWarning(`agent.${path}`)} = ${renderForWarning(value)} would LOOSEN this machine's merge policy — ignoring it. To honour it, set "allowRepoOverrides": true at the TOP LEVEL of ${args.globalConfigPath}, beside "apiKey" — inside the "agent" block it is read by nobody.`);
3793
+ continue;
3794
+ }
3795
+ if (!setPath(kept, path, value))
3796
+ continue;
3797
+ suppliedPaths.add(path);
3798
+ const named = NAMED_REVIEW_PATHS.get(path);
3799
+ if (named !== undefined) {
3800
+ warnings.push(`${pinPath}: ${renderForWarning(`agent.${path}`)} = ${renderForWarning(value)} — ${named.effect} Accepted because "allowRepoOverrides": true is set in ${args.globalConfigPath}.${named.extra ? ` ${named.extra}` : ""}`);
3801
+ }
3802
+ }
3803
+ return {
3804
+ overrides: kept,
3805
+ warnings,
3806
+ suppliedPaths
3807
+ };
3808
+ }
3809
+ var KNOWN_PIN_KEYS, ALLOWED_PREFIX = "review.autoMerge.", ALLOWED_BLOCK, FREE_VALUE, REPO_NEVER_SETTABLE, MAX_RENDERED_LENGTH = 120, ELISION = "…", MAX_RENDER_DEPTH = 4, MAX_RENDER_ENTRIES = 20, NAMED_REVIEW_PATHS, UNPRINTABLE, MAX_VALUE_LENGTH = 200, MAX_VALUE_DEPTH = 8, MAX_BLOCK_DEPTH = 8;
3810
+ var init_repo_agent_config = __esm(() => {
3811
+ init_config_merge();
3812
+ init_types2();
3813
+ KNOWN_PIN_KEYS = [
3814
+ "workspaceId",
3815
+ "projectId",
3816
+ "commands",
3817
+ "agent"
3818
+ ];
3819
+ ALLOWED_BLOCK = ALLOWED_PREFIX.slice(0, -1);
3820
+ FREE_VALUE = new Map([
3821
+ ["review.autoMerge.enabled", false],
3822
+ ["review.autoMerge.interactive", false],
3823
+ ["review.autoMerge.findingsLoop", false],
3824
+ ["review.autoMerge.ciRepair.enabled", false],
3825
+ ["review.autoMerge.ciRepair.patch.enabled", false],
3826
+ ["review.autoMerge.requireGreenCi", true],
3827
+ ["review.autoMerge.reReviewOnBranchChange", true],
3828
+ ["review.autoMerge.independentReview.enabled", true],
3829
+ ["review.autoMerge.independentReview.verdict.enabled", true]
3830
+ ]);
3831
+ REPO_NEVER_SETTABLE = new Set([
3832
+ "review.autoMerge.ciRepair.patch.sandboxImage",
3833
+ "review.autoMerge.ciRepair.patch.sandboxTimeoutMs"
3834
+ ]);
3835
+ NAMED_REVIEW_PATHS = new Map([
3836
+ [
3837
+ "review.autoMerge.independentReview.checkName",
3838
+ {
3839
+ effect: "this repository now decides which CI check stands for the independent review, and a name that resolves to an unrelated green check satisfies the merge gate with nothing having read the diff.",
3840
+ extra: `While ${ALLOWED_PREFIX}interactive or ${ALLOWED_PREFIX}enabled is on, a repo-supplied check name is refused at startup.`
3841
+ }
3842
+ ],
3843
+ [
3844
+ "review.autoMerge.independentReview.verdict.checkName",
3845
+ {
3846
+ effect: "this repository now decides which CI check carries the independent review's VERDICT, and a name that resolves to an unrelated green check satisfies the merge gate with nothing having approved the diff.",
3847
+ extra: `While ${ALLOWED_PREFIX}interactive or ${ALLOWED_PREFIX}enabled is on, a repo-supplied check name is refused at startup.`
3848
+ }
3849
+ ],
3850
+ [
3851
+ "review.autoMerge.independentReview.label",
3852
+ {
3853
+ effect: "this repository now decides which PR label asks CI for the independent review. It names no check and cannot stand in for one: a label CI does not act on means the review never arrives, so the merge gate HOLDS rather than opening.",
3854
+ extra: "Nothing refuses it at startup, for that reason — unlike a repo-supplied check name."
3855
+ }
3856
+ ]
3857
+ ]);
3858
+ UNPRINTABLE = /[\p{Cc}\p{Cf}]/u;
3859
+ });
3860
+
3475
3861
  // src/config.ts
3476
3862
  var exports_config = {};
3477
3863
  __export(exports_config, {
@@ -3483,11 +3869,11 @@ __export(exports_config, {
3483
3869
  createApiClient: () => createApiClient
3484
3870
  });
3485
3871
  import { execSync } from "node:child_process";
3486
- import { readFileSync } from "node:fs";
3872
+ import { readFileSync as readFileSync2 } from "node:fs";
3487
3873
  import { log as log5 } from "@gethmy/harness";
3488
3874
  import { HarmonyApiClient } from "@gethmy/mcp/src/api-client.js";
3489
3875
  import {
3490
- findLocalConfigPath,
3876
+ findLocalConfigPath as findLocalConfigPath2,
3491
3877
  getActiveProjectId,
3492
3878
  getActiveWorkspaceId,
3493
3879
  getApiKey,
@@ -3499,9 +3885,9 @@ import {
3499
3885
  import { refreshOAuthToken } from "@gethmy/mcp/src/oauth-refresh.js";
3500
3886
  function readConfigFile() {
3501
3887
  try {
3502
- return readFileSync(configPath(), "utf-8");
3888
+ return readFileSync2(configPath(), "utf-8");
3503
3889
  } catch {
3504
- const raw = readFileSync(legacyConfigPath(), "utf-8");
3890
+ const raw = readFileSync2(legacyConfigPath(), "utf-8");
3505
3891
  if (!warnedLegacyConfigDir) {
3506
3892
  warnedLegacyConfigDir = true;
3507
3893
  log5.warn(TAG5, `Read the pre-#1082 config at ${legacyConfigPath()}. The current ` + `location is ${configPath()} — the daemon migrates it on its next ` + `\`run\`, and this fallback is temporary.`);
@@ -3555,22 +3941,37 @@ function loadDaemonConfig() {
3555
3941
  let agentName = "Harmony Agent";
3556
3942
  let agentIdentifier2 = "harmony-daemon";
3557
3943
  let agentColor = "#57b8a5";
3944
+ let allowRepoOverrides = false;
3558
3945
  const configWarnings = [...contextWarnings];
3559
3946
  try {
3560
3947
  const raw = readConfigFile();
3561
3948
  const parsed = JSON.parse(raw);
3562
3949
  configWarnings.push(...findRemovedConfigKeys(parsed));
3563
- if (parsed.agent) {
3564
- agentOverrides = parsed.agent;
3565
- }
3566
- if (typeof parsed.agentName === "string" && parsed.agentName.trim())
3567
- agentName = parsed.agentName.trim();
3568
- if (typeof parsed.agentIdentifier === "string" && parsed.agentIdentifier.trim())
3569
- agentIdentifier2 = parsed.agentIdentifier.trim();
3570
- if (typeof parsed.agentColor === "string" && parsed.agentColor.trim())
3571
- agentColor = parsed.agentColor.trim();
3950
+ const block = own(parsed, "agent");
3951
+ if (block) {
3952
+ agentOverrides = block;
3953
+ }
3954
+ const rawName = own(parsed, "agentName");
3955
+ if (typeof rawName === "string" && rawName.trim())
3956
+ agentName = rawName.trim();
3957
+ const rawIdentifier = own(parsed, "agentIdentifier");
3958
+ if (typeof rawIdentifier === "string" && rawIdentifier.trim())
3959
+ agentIdentifier2 = rawIdentifier.trim();
3960
+ const rawColor = own(parsed, "agentColor");
3961
+ if (typeof rawColor === "string" && rawColor.trim())
3962
+ agentColor = rawColor.trim();
3963
+ if (own(parsed, "allowRepoOverrides") === true)
3964
+ allowRepoOverrides = true;
3572
3965
  } catch {}
3573
- const agent = mergeAgentConfig(DEFAULT_AGENT_CONFIG, agentOverrides);
3966
+ const repo = readRepoAgentConfig({
3967
+ repoRoot,
3968
+ globalConfigPath: contextSource.globalPath,
3969
+ allowRepoOverrides
3970
+ });
3971
+ configWarnings.push(...repo.warnings);
3972
+ const operatorAgent = mergeAgentConfig(DEFAULT_AGENT_CONFIG, agentOverrides);
3973
+ const agent = mergeAgentConfig(operatorAgent, repo.overrides);
3974
+ const repoArmedGates = repoArmedGatesOf(repo.suppliedPaths, operatorAgent, agent);
3574
3975
  configWarnings.push(...findConfigTypeMismatches(DEFAULT_AGENT_CONFIG, agentOverrides));
3575
3976
  if (agent.runner !== "cli" && agent.runner !== "sdk") {
3576
3977
  agent.runner = DEFAULT_AGENT_CONFIG.runner;
@@ -3587,13 +3988,16 @@ function loadDaemonConfig() {
3587
3988
  agentColor,
3588
3989
  agent,
3589
3990
  agentOverrides,
3991
+ repoAgentOverrides: repo.overrides,
3992
+ repoAgentPaths: repo.suppliedPaths,
3993
+ repoArmedGates,
3590
3994
  contextSource,
3591
3995
  configWarnings
3592
3996
  };
3593
3997
  }
3594
3998
  function resolveContextSource(repoRoot) {
3595
3999
  const globalPath = getConfigPath();
3596
- const localPath = findLocalConfigPath(repoRoot);
4000
+ const localPath = findLocalConfigPath2(repoRoot);
3597
4001
  if (localPath === null) {
3598
4002
  return {
3599
4003
  source: {
@@ -3647,6 +4051,7 @@ var init_config = __esm(() => {
3647
4051
  init_config_merge();
3648
4052
  init_config_validation();
3649
4053
  init_paths();
4054
+ init_repo_agent_config();
3650
4055
  init_types2();
3651
4056
  });
3652
4057
 
@@ -4156,7 +4561,7 @@ import {
4156
4561
  mkdirSync as mkdirSync2,
4157
4562
  mkdtempSync,
4158
4563
  readdirSync as readdirSync2,
4159
- readFileSync as readFileSync2,
4564
+ readFileSync as readFileSync3,
4160
4565
  rmSync,
4161
4566
  statSync as statSync2,
4162
4567
  unlinkSync,
@@ -4233,7 +4638,7 @@ function sameBytes(a, b) {
4233
4638
  return false;
4234
4639
  if (sa.size > MAX_FILE_BYTES)
4235
4640
  return false;
4236
- return readFileSync2(a).equals(readFileSync2(b));
4641
+ return readFileSync3(a).equals(readFileSync3(b));
4237
4642
  } catch {
4238
4643
  return false;
4239
4644
  }
@@ -4257,7 +4662,7 @@ function applyChanges(args) {
4257
4662
  for (const path of [...changes.modified, ...changes.added]) {
4258
4663
  const from = join3(scratch, path);
4259
4664
  const to = safeDestination(clean, path);
4260
- const bytes = readFileSync2(from);
4665
+ const bytes = readFileSync3(from);
4261
4666
  if (bytes.byteLength > MAX_FILE_BYTES) {
4262
4667
  throw new Error(`the repair wrote ${path} at ${bytes.byteLength} bytes, over the ${MAX_FILE_BYTES} cap`);
4263
4668
  }
@@ -4314,7 +4719,8 @@ import {
4314
4719
  isPrUrlOnOrigin,
4315
4720
  log as log9,
4316
4721
  removeWorktreeHoldingBranch,
4317
- resolvePrHeadBranch
4722
+ resolvePrHeadBranch,
4723
+ shouldInstall
4318
4724
  } from "@gethmy/harness";
4319
4725
  function gitErrorDetail(err) {
4320
4726
  const e = err;
@@ -4371,6 +4777,8 @@ function checkoutExistingBranch(basePath, branchName, opts = {}) {
4371
4777
  } catch (err) {
4372
4778
  throw new Error(`Failed to create review worktree for ${branchName}: ${gitErrorDetail(err)}`);
4373
4779
  }
4780
+ if (!shouldInstall(worktreeDir, opts.commands))
4781
+ return worktreeDir;
4374
4782
  log9.info(TAG9, "Installing dependencies in review worktree...");
4375
4783
  try {
4376
4784
  execSync2(installCommand(opts.ignoreScripts !== false), {
@@ -4457,7 +4865,7 @@ var init_review_worktree = __esm(() => {
4457
4865
  // src/ci-patch.ts
4458
4866
  import { execFileSync as execFileSync4 } from "node:child_process";
4459
4867
  import { randomUUID } from "node:crypto";
4460
- import { readFileSync as readFileSync3 } from "node:fs";
4868
+ import { readFileSync as readFileSync4 } from "node:fs";
4461
4869
  import { join as join4 } from "node:path";
4462
4870
  import {
4463
4871
  buildCommand,
@@ -4467,6 +4875,7 @@ import {
4467
4875
  GIT_NO_HOOKS as GIT_NO_HOOKS4,
4468
4876
  HARMONY_CREDENTIAL_KEYS,
4469
4877
  log as log10,
4878
+ REPO_CONFIG_FILENAMES,
4470
4879
  runInSandbox,
4471
4880
  SdkAgentRunner,
4472
4881
  sandboxAvailable,
@@ -4558,7 +4967,10 @@ async function verifyInSandbox(worktree, config) {
4558
4967
  return null;
4559
4968
  }
4560
4969
  let ran = null;
4561
- for (const command of [buildCommand(worktree), testCommand(worktree)]) {
4970
+ for (const command of [
4971
+ buildCommand(worktree, config.commands),
4972
+ testCommand(worktree, config.commands)
4973
+ ]) {
4562
4974
  if (!command)
4563
4975
  continue;
4564
4976
  ran = command;
@@ -4592,7 +5004,7 @@ function commitRepair(worktree, card) {
4592
5004
  }
4593
5005
  function readGitPointer(worktree) {
4594
5006
  try {
4595
- return readFileSync3(join4(worktree, ".git"), "utf-8");
5007
+ return readFileSync4(join4(worktree, ".git"), "utf-8");
4596
5008
  } catch {
4597
5009
  return null;
4598
5010
  }
@@ -4619,7 +5031,8 @@ async function repairBranch(deps) {
4619
5031
  let trees = null;
4620
5032
  try {
4621
5033
  worktree = checkoutExistingBranch(config.worktree.basePath, branch, {
4622
- ignoreScripts: true
5034
+ ignoreScripts: true,
5035
+ commands: config.commands
4623
5036
  });
4624
5037
  const baseSha = remoteSha(branch, worktree);
4625
5038
  if (!baseSha) {
@@ -4761,7 +5174,8 @@ var init_ci_patch = __esm(() => {
4761
5174
  /(^|\/)\.yarnrc\.ya?ml$/i,
4762
5175
  /(^|\/)\.tool-versions$/i,
4763
5176
  /(^|\/)\.gitattributes$/i,
4764
- /(^|\/)\.gitconfig$/i
5177
+ /(^|\/)\.gitconfig$/i,
5178
+ ...REPO_CONFIG_FILENAMES.map((name) => new RegExp(`(^|/)${name.replace(/\./g, "\\.")}$`, "i"))
4765
5179
  ];
4766
5180
  });
4767
5181
 
@@ -6477,7 +6891,7 @@ async function runCompletion(client, card, branchName, worktreePath, config, wor
6477
6891
  revertWarnings: []
6478
6892
  };
6479
6893
  if (config.verification.enabled && config.verification.lint) {
6480
- await runFormatFix(worktreePath, config.verification.timeout, workerId, verificationSandbox(config));
6894
+ await runFormatFix(worktreePath, config.verification.timeout, workerId, verificationSandbox(config), config.commands);
6481
6895
  }
6482
6896
  commitUncommittedChanges(worktreePath, card);
6483
6897
  const hasCommits = checkHasCommits(worktreePath, config.worktree.baseBranch, runBaselineSha);
@@ -7446,7 +7860,7 @@ var init_prompt = __esm(() => {
7446
7860
  });
7447
7861
 
7448
7862
  // src/review-completion.ts
7449
- import { readFileSync as readFileSync4, statSync as statSync3 } from "node:fs";
7863
+ import { readFileSync as readFileSync5, statSync as statSync3 } from "node:fs";
7450
7864
  import {
7451
7865
  cleanupWorktree as cleanupWorktree3,
7452
7866
  createPullRequest as createPullRequest2,
@@ -7528,7 +7942,7 @@ function tailRunLog(path, bytes = RUN_LOG_TAIL_BYTES) {
7528
7942
  if (size === 0)
7529
7943
  return null;
7530
7944
  const start = Math.max(0, size - bytes);
7531
- const buf = readFileSync4(path);
7945
+ const buf = readFileSync5(path);
7532
7946
  return buf.subarray(start).toString("utf-8");
7533
7947
  } catch {
7534
7948
  return null;
@@ -8252,7 +8666,7 @@ __export(exports_state_store, {
8252
8666
  import {
8253
8667
  existsSync as existsSync3,
8254
8668
  mkdirSync as mkdirSync4,
8255
- readFileSync as readFileSync5,
8669
+ readFileSync as readFileSync6,
8256
8670
  renameSync as renameSync2,
8257
8671
  writeFileSync as writeFileSync2
8258
8672
  } from "node:fs";
@@ -8389,7 +8803,7 @@ class StateStore {
8389
8803
  if (!existsSync3(this.path))
8390
8804
  return emptyState();
8391
8805
  try {
8392
- const raw = readFileSync5(this.path, "utf-8");
8806
+ const raw = readFileSync6(this.path, "utf-8");
8393
8807
  const parsed = JSON.parse(raw);
8394
8808
  if (parsed?.version !== SCHEMA_VERSION) {
8395
8809
  log26.warn(TAG23, `state file has version ${parsed?.version}, expected ${SCHEMA_VERSION} — migrating (preserving card budget/attempts, dropping in-flight runs)`);
@@ -9058,6 +9472,7 @@ import {
9058
9472
  buildGateCollectorRegistry,
9059
9473
  cleanupWorktree as cleanupWorktree4,
9060
9474
  collectGateEvidence,
9475
+ DEV_SERVER_READY_TIMEOUT_MS,
9061
9476
  DevServerReadinessError,
9062
9477
  devServerLaunch,
9063
9478
  devServerSkipReason,
@@ -9321,7 +9736,7 @@ class ReviewWorker {
9321
9736
  }
9322
9737
  this.sessionId = reviewSession && typeof reviewSession === "object" && "id" in reviewSession ? reviewSession.id ?? null : null;
9323
9738
  const labelPromise = addLabelByName(this.client, card, "agent", "#8b5cf6");
9324
- this.worktreePath = checkoutExistingBranch(this.config.worktree.basePath, this.branchName);
9739
+ this.worktreePath = checkoutExistingBranch(this.config.worktree.basePath, this.branchName, { commands: this.config.commands });
9325
9740
  await labelPromise;
9326
9741
  }
9327
9742
  if (this.aborted)
@@ -9844,14 +10259,15 @@ No verdict was produced, so this card has not been reviewed. The run's log is at
9844
10259
  }
9845
10260
  }
9846
10261
  async startDevServer(card, cwd, port) {
9847
- const declined = devServerSkipReason(cwd);
10262
+ const declined = devServerSkipReason(cwd, this.config.commands);
9848
10263
  if (declined)
9849
10264
  return { previewUrl: null, skipReason: declined };
9850
10265
  log29.info(this.tag, `Starting dev server on port ${port}...`);
9851
10266
  const launch = devServerLaunch({
9852
10267
  worktreePath: cwd,
9853
10268
  port,
9854
- sandbox: verificationSandbox2(this.config)
10269
+ sandbox: verificationSandbox2(this.config),
10270
+ operator: this.config.commands
9855
10271
  });
9856
10272
  this.devServerContainer = launch.containerName ?? null;
9857
10273
  if (launch.containerName) {
@@ -9877,13 +10293,14 @@ No verdict was produced, so this card has not been reviewed. The run's log is at
9877
10293
  if (devServerSpawnError) {
9878
10294
  throw new DevServerReadinessError(`dev server failed to start (${launch.cmd}): ${devServerSpawnError.message}`);
9879
10295
  }
9880
- await waitForDevServer(this.devServerProcess, 30000);
10296
+ await waitForDevServer(this.devServerProcess, DEV_SERVER_READY_TIMEOUT_MS);
9881
10297
  await probeDevServer(port);
9882
10298
  } catch (err) {
9883
10299
  this.killDevServer();
10300
+ const failure = sanitizeCiText3(err instanceof Error ? err.message : String(err), REVIEW_FAILURE_REASON_MAX);
9884
10301
  return {
9885
10302
  previewUrl: null,
9886
- skipReason: sanitizeCiText3(err instanceof Error ? err.message : String(err), REVIEW_FAILURE_REASON_MAX)
10303
+ skipReason: launch.origin ? `${launch.origin}, and ${failure}` : failure
9887
10304
  };
9888
10305
  }
9889
10306
  log29.info(this.tag, `Dev server ready on port ${port}`);
@@ -11256,7 +11673,7 @@ function workspaceConfigToPartial(ws) {
11256
11673
  }
11257
11674
  function resolveEffectiveAgentConfig(cfg, ws) {
11258
11675
  const withWorkspace = ws ? mergeAgentConfig(DEFAULT_AGENT_CONFIG, workspaceConfigToPartial(ws)) : DEFAULT_AGENT_CONFIG;
11259
- return mergeAgentConfig(withWorkspace, cfg.agentOverrides);
11676
+ return mergeAgentConfig(mergeAgentConfig(withWorkspace, cfg.agentOverrides), cfg.repoAgentOverrides);
11260
11677
  }
11261
11678
  var init_workspace_model_config = __esm(() => {
11262
11679
  init_config_merge();
@@ -11749,7 +12166,8 @@ class Worker {
11749
12166
  }
11750
12167
  this.worktreePath = createWorktree(this.config.worktree.basePath, this.config.worktree.baseBranch, this.branchName, {
11751
12168
  continueExisting: target.continueExisting,
11752
- branchExistsOnOrigin: target.existsOnOrigin
12169
+ branchExistsOnOrigin: target.existsOnOrigin,
12170
+ commands: this.config.commands
11753
12171
  });
11754
12172
  this.runBaselineSha = readWorktreeHead(this.worktreePath);
11755
12173
  }
@@ -14423,7 +14841,7 @@ __export(exports_port_registry, {
14423
14841
  import {
14424
14842
  existsSync as existsSync4,
14425
14843
  mkdirSync as mkdirSync5,
14426
- readFileSync as readFileSync6,
14844
+ readFileSync as readFileSync7,
14427
14845
  renameSync as renameSync3,
14428
14846
  writeFileSync as writeFileSync4
14429
14847
  } from "node:fs";
@@ -14436,7 +14854,7 @@ function load(path) {
14436
14854
  if (!existsSync4(path))
14437
14855
  return {};
14438
14856
  try {
14439
- const raw = readFileSync6(path, "utf-8");
14857
+ const raw = readFileSync7(path, "utf-8");
14440
14858
  const parsed = JSON.parse(raw);
14441
14859
  if (parsed && typeof parsed === "object")
14442
14860
  return parsed;
@@ -15888,7 +16306,11 @@ function contextSourceLine(config) {
15888
16306
  if (unreadablePin !== null) {
15889
16307
  return `all of it ${globalPath} (global) — ${unreadablePin} is present but UNREADABLE, so its pin was ignored`;
15890
16308
  }
15891
- return scope === "repo" ? `project+workspace ${path} (repo pin) · all else ${globalPath}` : `all of it ${globalPath} (global; no repo pin found)`;
16309
+ if (scope !== "repo") {
16310
+ return `all of it ${globalPath} (global; no repo pin found)`;
16311
+ }
16312
+ const pinLabel = config.repoAgentPaths.size > 0 ? "repo pin, +auto-merge" : "repo pin";
16313
+ return `project+workspace ${path} (${pinLabel}) · rest ${globalPath}`;
15892
16314
  }
15893
16315
  function titleRule(title) {
15894
16316
  const prefix = "─── ";
@@ -15929,7 +16351,7 @@ import {
15929
16351
  copyFileSync,
15930
16352
  existsSync as existsSync5,
15931
16353
  mkdirSync as mkdirSync6,
15932
- readFileSync as readFileSync7,
16354
+ readFileSync as readFileSync8,
15933
16355
  renameSync as renameSync4,
15934
16356
  writeFileSync as writeFileSync5
15935
16357
  } from "node:fs";
@@ -15951,7 +16373,7 @@ function atomicWriteJson(path, value) {
15951
16373
  }
15952
16374
  function readTargetFile(path) {
15953
16375
  try {
15954
- const parsed = JSON.parse(readFileSync7(path, "utf-8"));
16376
+ const parsed = JSON.parse(readFileSync8(path, "utf-8"));
15955
16377
  if (!parsed || typeof parsed !== "object")
15956
16378
  return null;
15957
16379
  return parsed;
@@ -16005,7 +16427,7 @@ function migrateLegacyStateFile(opts) {
16005
16427
  return NO_MIGRATION;
16006
16428
  let legacy;
16007
16429
  try {
16008
- const parsed = JSON.parse(readFileSync7(legacyPath, "utf-8"));
16430
+ const parsed = JSON.parse(readFileSync8(legacyPath, "utf-8"));
16009
16431
  if (!parsed || typeof parsed !== "object")
16010
16432
  throw new Error("not an object");
16011
16433
  legacy = parsed;
@@ -16534,7 +16956,7 @@ __export(exports_run_stats, {
16534
16956
  computeRunStats: () => computeRunStats,
16535
16957
  buildRunListRows: () => buildRunListRows
16536
16958
  });
16537
- import { readdirSync as readdirSync4, readFileSync as readFileSync8, statSync as statSync4 } from "node:fs";
16959
+ import { readdirSync as readdirSync4, readFileSync as readFileSync9, statSync as statSync4 } from "node:fs";
16538
16960
  import { join as join8 } from "node:path";
16539
16961
  function runsDir2(base) {
16540
16962
  return runsDir(base);
@@ -16617,7 +17039,7 @@ function tailText(path, bytes = 4096) {
16617
17039
  const size = statSync4(path).size;
16618
17040
  if (size === 0)
16619
17041
  return "";
16620
- const buf = readFileSync8(path);
17042
+ const buf = readFileSync9(path);
16621
17043
  const start = Math.max(0, size - bytes);
16622
17044
  let text = buf.subarray(start).toString("utf-8");
16623
17045
  if (start > 0) {
@@ -16639,7 +17061,7 @@ function grepRunLogs(files, pattern, opts = {}) {
16639
17061
  continue;
16640
17062
  let content;
16641
17063
  try {
16642
- content = readFileSync8(file.path, "utf-8");
17064
+ content = readFileSync9(file.path, "utf-8");
16643
17065
  } catch {
16644
17066
  continue;
16645
17067
  }
@@ -17239,7 +17661,9 @@ async function main() {
17239
17661
  throw err;
17240
17662
  }
17241
17663
  try {
17242
- validateAutoMergeConfig(config.agent);
17664
+ for (const warning of validateAutoMergeConfig(config.agent, config.repoAgentPaths, config.repoArmedGates)) {
17665
+ banner.warn(warning);
17666
+ }
17243
17667
  validateBudgetConfig(config.agent);
17244
17668
  validateSweepConfig(config.agent);
17245
17669
  validateRankingConfig(config.agent);