@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/cli.js CHANGED
@@ -852,6 +852,9 @@ function buildGaveUpComment(maxAttempts, failures, pauseEnabled) {
852
852
  function mergeAgentConfig(defaults, overrides) {
853
853
  return mergeObjects(defaults, overrides);
854
854
  }
855
+ function own(source, key) {
856
+ return Object.hasOwn(source, key) ? source[key] : undefined;
857
+ }
855
858
  function isPlainObject(value) {
856
859
  if (typeof value !== "object" || value === null || Array.isArray(value)) {
857
860
  return false;
@@ -864,7 +867,7 @@ function mergeObjects(base, override) {
864
867
  for (const key of Object.keys(base)) {
865
868
  if (FORBIDDEN_KEYS.has(key))
866
869
  continue;
867
- result[key] = mergeValue(base[key], override[key]);
870
+ result[key] = mergeValue(base[key], own(override, key));
868
871
  }
869
872
  for (const key of Object.keys(override)) {
870
873
  if (FORBIDDEN_KEYS.has(key))
@@ -903,7 +906,7 @@ function walk(base, override, path, found) {
903
906
  if (FORBIDDEN_KEYS.has(key))
904
907
  continue;
905
908
  const baseValue = base[key];
906
- const overrideValue = override[key];
909
+ const overrideValue = own(override, key);
907
910
  if (overrideValue === undefined)
908
911
  continue;
909
912
  const here = `${path}.${key}`;
@@ -948,7 +951,11 @@ function copy(value) {
948
951
  }
949
952
  var FORBIDDEN_KEYS;
950
953
  var init_config_merge = __esm(() => {
951
- FORBIDDEN_KEYS = new Set(["__proto__", "constructor", "prototype"]);
954
+ FORBIDDEN_KEYS = new Set([
955
+ "__proto__",
956
+ "constructor",
957
+ "prototype"
958
+ ]);
952
959
  });
953
960
 
954
961
  // src/config-validation.ts
@@ -963,8 +970,10 @@ __export(exports_config_validation, {
963
970
  validateBudgetConfig: () => validateBudgetConfig,
964
971
  validateAutoMergeConfig: () => validateAutoMergeConfig,
965
972
  validateAndListColumns: () => validateAndListColumns,
973
+ repoArmedGatesOf: () => repoArmedGatesOf,
966
974
  isLoopbackBindAddr: () => isLoopbackBindAddr,
967
975
  findRemovedConfigKeys: () => findRemovedConfigKeys,
976
+ REPO_ARMABLE_GATES: () => REPO_ARMABLE_GATES,
968
977
  REMOVED_CONFIG_KEYS: () => REMOVED_CONFIG_KEYS,
969
978
  ConfigValidationError: () => ConfigValidationError
970
979
  });
@@ -979,7 +988,7 @@ function findRemovedConfigKeys(rawConfig) {
979
988
  cursor = undefined;
980
989
  break;
981
990
  }
982
- cursor = cursor[segment];
991
+ cursor = own(cursor, segment);
983
992
  }
984
993
  if (cursor !== undefined) {
985
994
  found.push(`${path} is no longer used (#988) — ${note}. Remove it.`);
@@ -1012,9 +1021,49 @@ function validateVerificationConfig(config) {
1012
1021
  - `)}`, issues);
1013
1022
  }
1014
1023
  }
1015
- function validateAutoMergeConfig(config) {
1024
+ function repoArmedGatesOf(suppliedPaths, operatorAgent, merged) {
1025
+ const armed = new Set;
1026
+ for (const gate of Object.keys(REPO_ARMABLE_GATES)) {
1027
+ if (!suppliedPaths.has(gate))
1028
+ continue;
1029
+ if (ownValueAt(operatorAgent, gate))
1030
+ continue;
1031
+ if (ownValueAt(merged, gate) !== true)
1032
+ continue;
1033
+ armed.add(gate);
1034
+ }
1035
+ return armed;
1036
+ }
1037
+ function ownValueAt(root, dotted) {
1038
+ let cursor = root;
1039
+ for (const segment of dotted.split(".")) {
1040
+ if (cursor === null || typeof cursor !== "object" || !Object.hasOwn(cursor, segment)) {
1041
+ return;
1042
+ }
1043
+ cursor = cursor[segment];
1044
+ }
1045
+ return cursor;
1046
+ }
1047
+ function armedByRepoPin(gate) {
1048
+ if (REPO_ARMABLE_GATES[gate] === "tightening") {
1049
+ 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).`;
1050
+ }
1051
+ 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.`;
1052
+ }
1053
+ function validateAutoMergeConfig(config, repoSuppliedPaths = NO_REPO_PATHS, repoArmedGates = NO_REPO_PATHS) {
1016
1054
  const autoMerge = config.review.autoMerge;
1017
1055
  const issues = [];
1056
+ const warnings = [];
1057
+ const armedRefusal = (gate, issue) => {
1058
+ issues.push(repoArmedGates.has(gate) ? `${issue}. ${armedByRepoPin(gate)}` : issue);
1059
+ };
1060
+ const inertCombination = (args) => {
1061
+ if (repoSuppliedPaths.has(args.gate)) {
1062
+ 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.`);
1063
+ return;
1064
+ }
1065
+ armedRefusal(args.pin, args.issue);
1066
+ };
1018
1067
  const valid = ["squash", "merge", "rebase"];
1019
1068
  const s = autoMerge.strategy;
1020
1069
  if (!valid.includes(s)) {
@@ -1023,34 +1072,44 @@ function validateAutoMergeConfig(config) {
1023
1072
  const repair = autoMerge.ciRepair;
1024
1073
  if (repair.enabled) {
1025
1074
  if (!autoMerge.enabled) {
1026
- issues.push("review.autoMerge.ciRepair.enabled: needs review.autoMerge.enabled — a repair only runs on a card the daemon would merge itself");
1075
+ inertCombination({
1076
+ pin: "review.autoMerge.ciRepair.enabled",
1077
+ gate: "review.autoMerge.enabled",
1078
+ issue: "review.autoMerge.ciRepair.enabled: needs review.autoMerge.enabled — a repair only runs on a card the daemon would merge itself"
1079
+ });
1027
1080
  }
1028
1081
  if (!autoMerge.requireGreenCi) {
1029
- 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");
1082
+ 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");
1030
1083
  }
1031
1084
  if (!Number.isInteger(repair.maxAttempts) || repair.maxAttempts < 1) {
1032
- 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`);
1085
+ 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`);
1033
1086
  }
1034
1087
  }
1035
1088
  const patch = repair.patch;
1036
1089
  if (patch?.enabled) {
1037
1090
  if (!repair.enabled) {
1038
- 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");
1091
+ inertCombination({
1092
+ pin: "review.autoMerge.ciRepair.patch.enabled",
1093
+ gate: "review.autoMerge.ciRepair.enabled",
1094
+ 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"
1095
+ });
1039
1096
  }
1040
1097
  if (!autoMerge.reReviewOnBranchChange) {
1041
- 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");
1098
+ 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");
1042
1099
  }
1043
- issues.push(...sandboxConfigIssues({
1100
+ for (const issue of sandboxConfigIssues({
1044
1101
  image: patch.sandboxImage,
1045
1102
  timeoutMs: patch.sandboxTimeoutMs,
1046
1103
  imagePath: "review.autoMerge.ciRepair.patch.sandboxImage",
1047
1104
  timeoutPath: "review.autoMerge.ciRepair.patch.sandboxTimeoutMs"
1048
- }));
1105
+ })) {
1106
+ armedRefusal("review.autoMerge.ciRepair.patch.enabled", issue);
1107
+ }
1049
1108
  if (!Number.isInteger(patch.maxTurns) || patch.maxTurns < 1) {
1050
- issues.push(`review.autoMerge.ciRepair.patch.maxTurns: must be an integer >= 1 (got ${patch.maxTurns})`);
1109
+ armedRefusal("review.autoMerge.ciRepair.patch.enabled", `review.autoMerge.ciRepair.patch.maxTurns: must be an integer >= 1 (got ${patch.maxTurns})`);
1051
1110
  }
1052
1111
  if (!(patch.maxBudgetUsd > 0)) {
1053
- issues.push(`review.autoMerge.ciRepair.patch.maxBudgetUsd: must be greater than 0 (got ${patch.maxBudgetUsd})`);
1112
+ armedRefusal("review.autoMerge.ciRepair.patch.enabled", `review.autoMerge.ciRepair.patch.maxBudgetUsd: must be greater than 0 (got ${patch.maxBudgetUsd})`);
1054
1113
  }
1055
1114
  }
1056
1115
  const independent = autoMerge.independentReview;
@@ -1059,38 +1118,46 @@ function validateAutoMergeConfig(config) {
1059
1118
  }
1060
1119
  if (independent?.enabled) {
1061
1120
  if (typeof independent.checkName !== "string" || !independent.checkName.trim()) {
1062
- 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");
1121
+ 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");
1063
1122
  }
1064
1123
  if (typeof independent.label !== "string" || !independent.label.trim()) {
1065
- issues.push("review.autoMerge.independentReview.label: must name the PR label that requests a CI review");
1124
+ armedRefusal("review.autoMerge.independentReview.enabled", "review.autoMerge.independentReview.label: must name the PR label that requests a CI review");
1066
1125
  }
1067
1126
  const verdict = independent.verdict;
1068
1127
  if (typeof verdict?.enabled !== "boolean") {
1069
- 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`);
1128
+ 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`);
1070
1129
  }
1071
1130
  if (typeof verdict?.checkName !== "string" || !verdict.checkName.trim()) {
1072
- 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");
1131
+ 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");
1073
1132
  }
1074
1133
  if (typeof independent.checkName === "string" && typeof verdict?.checkName === "string" && independent.checkName.trim().toLowerCase() === verdict.checkName.trim().toLowerCase() && independent.checkName.trim()) {
1075
- 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`);
1134
+ 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`);
1076
1135
  }
1077
1136
  }
1078
1137
  if (typeof autoMerge.interactive !== "boolean") {
1079
1138
  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`);
1080
1139
  }
1140
+ if (autoMerge.interactive || autoMerge.enabled) {
1141
+ const driver = autoMerge.interactive ? "review.autoMerge.interactive" : "review.autoMerge.enabled";
1142
+ for (const named of REPO_UNSETTABLE_CHECK_NAMES) {
1143
+ if (!repoSuppliedPaths.has(named))
1144
+ continue;
1145
+ 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.`);
1146
+ }
1147
+ }
1081
1148
  if (autoMerge.interactive) {
1082
1149
  if (independent?.enabled === false) {
1083
- 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");
1150
+ 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");
1084
1151
  }
1085
1152
  if (!autoMerge.reReviewOnBranchChange) {
1086
- 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");
1153
+ 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");
1087
1154
  }
1088
1155
  if (independent?.enabled && independent.verdict?.enabled === false) {
1089
- 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)");
1156
+ 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)");
1090
1157
  }
1091
1158
  const w = autoMerge.waitMinutes;
1092
1159
  if (!Number.isInteger(w) || w < 1 || w > 55) {
1093
- 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`);
1160
+ 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`);
1094
1161
  }
1095
1162
  }
1096
1163
  if (typeof autoMerge.findingsLoop !== "boolean") {
@@ -1102,16 +1169,21 @@ function validateAutoMergeConfig(config) {
1102
1169
  }
1103
1170
  if (autoMerge.findingsLoop) {
1104
1171
  if (!autoMerge.interactive) {
1105
- 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");
1172
+ inertCombination({
1173
+ pin: "review.autoMerge.findingsLoop",
1174
+ gate: "review.autoMerge.interactive",
1175
+ 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"
1176
+ });
1106
1177
  }
1107
1178
  const findingAuthors = independent?.findingAuthors;
1108
1179
  if (!Array.isArray(findingAuthors) || findingAuthors.filter((a) => typeof a === "string" && a.trim()).length === 0) {
1109
- 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");
1180
+ 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");
1110
1181
  }
1111
1182
  }
1112
1183
  if (issues.length > 0) {
1113
1184
  throw new ConfigValidationError(`Invalid agent config — ${issues.join("; ")}`, issues);
1114
1185
  }
1186
+ return warnings;
1115
1187
  }
1116
1188
  function isLoopbackBindAddr(addr) {
1117
1189
  const value = addr.toLowerCase();
@@ -1333,8 +1405,9 @@ async function validateAndListColumns(client, projectId, config) {
1333
1405
  }
1334
1406
  return Array.from(new Set(names.filter(Boolean)));
1335
1407
  }
1336
- var ConfigValidationError, REMOVED_CONFIG_KEYS;
1408
+ var ConfigValidationError, REMOVED_CONFIG_KEYS, NO_REPO_PATHS, REPO_UNSETTABLE_CHECK_NAMES, REPO_ARMABLE_GATES;
1337
1409
  var init_config_validation = __esm(() => {
1410
+ init_config_merge();
1338
1411
  ConfigValidationError = class ConfigValidationError extends Error {
1339
1412
  issues;
1340
1413
  constructor(message, issues) {
@@ -1352,6 +1425,18 @@ var init_config_validation = __esm(() => {
1352
1425
  note: "review, auto-fix and deep-review are contained and pin their own sources"
1353
1426
  }
1354
1427
  ];
1428
+ NO_REPO_PATHS = new Set;
1429
+ REPO_UNSETTABLE_CHECK_NAMES = [
1430
+ "review.autoMerge.independentReview.checkName",
1431
+ "review.autoMerge.independentReview.verdict.checkName"
1432
+ ];
1433
+ REPO_ARMABLE_GATES = {
1434
+ "review.autoMerge.independentReview.enabled": "tightening",
1435
+ "review.autoMerge.interactive": "handshake",
1436
+ "review.autoMerge.ciRepair.enabled": "handshake",
1437
+ "review.autoMerge.ciRepair.patch.enabled": "handshake",
1438
+ "review.autoMerge.findingsLoop": "handshake"
1439
+ };
1355
1440
  });
1356
1441
 
1357
1442
  // src/paths.ts
@@ -1498,6 +1583,9 @@ var init_branchRef = __esm(() => {
1498
1583
  PR_LINK_PATTERN = /PR:\s*(https?:\/\/[^\s)]+)/;
1499
1584
  });
1500
1585
 
1586
+ // ../harmony-shared/dist/cardLifecycle.js
1587
+ var init_cardLifecycle = () => {};
1588
+
1501
1589
  // ../harmony-shared/dist/cardLinks.js
1502
1590
  var init_cardLinks = () => {};
1503
1591
 
@@ -2986,6 +3074,7 @@ function untrustedDataBlock(text, options) {
2986
3074
  var init_dist = __esm(() => {
2987
3075
  init_agentStaleness();
2988
3076
  init_branchRef();
3077
+ init_cardLifecycle();
2989
3078
  init_cardLinks();
2990
3079
  init_cardModelStat();
2991
3080
  init_classification();
@@ -3349,16 +3438,17 @@ var init_types2 = __esm(() => {
3349
3438
  },
3350
3439
  claude: {
3351
3440
  model: "claude-opus-5",
3352
- escalateModel: "claude-fable-5",
3441
+ escalateModel: "claude-fable-5-1",
3353
3442
  escalateAfterAttempts: 2,
3354
3443
  tiers: {
3355
3444
  simple: "claude-sonnet-5",
3356
3445
  advanced: "claude-opus-5",
3357
- research: "claude-fable-5"
3446
+ research: "claude-fable-5-1"
3358
3447
  },
3359
3448
  scoreBands: DEFAULT_SCORE_BANDS,
3360
3449
  sizingModel: "",
3361
3450
  reviewModel: "sonnet",
3451
+ deepReviewModel: "sonnet",
3362
3452
  maxTurns: 240,
3363
3453
  reviewMaxTurns: 60,
3364
3454
  additionalArgs: []
@@ -3473,6 +3563,302 @@ var init_types2 = __esm(() => {
3473
3563
  NOTICE_IDENTIFIER = `${DAEMON_IDENTIFIER_BASE}-notice`;
3474
3564
  });
3475
3565
 
3566
+ // src/repo-agent-config.ts
3567
+ import { readFileSync } from "node:fs";
3568
+ import { findLocalConfigPath } from "@gethmy/mcp/src/config.js";
3569
+ function isNeverSettable(path) {
3570
+ for (const banned of REPO_NEVER_SETTABLE) {
3571
+ if (path === banned || path.startsWith(`${banned}.`))
3572
+ return true;
3573
+ }
3574
+ return false;
3575
+ }
3576
+ function renderForWarning(value) {
3577
+ const json = JSON.stringify(clampForRender(value)) ?? String(value);
3578
+ const clean = json.replace(/\p{Cf}/gu, "").replace(/[\p{Cc}\u2028\u2029]/gu, " ");
3579
+ if (clean.length <= MAX_RENDERED_LENGTH)
3580
+ return clean;
3581
+ const head = clean.slice(0, MAX_RENDERED_LENGTH).replace(/\\u[0-9a-fA-F]{0,3}$/, "").replace(/\\+$/, "").replace(/[\uD800-\uDBFF]$/, "");
3582
+ const closer = clean.startsWith('"') ? '"' : "";
3583
+ return `${head}…${closer} (truncated from ${clean.length} characters)`;
3584
+ }
3585
+ function clampForRender(value, depth = 0) {
3586
+ if (Array.isArray(value)) {
3587
+ if (depth >= MAX_RENDER_DEPTH)
3588
+ return ELISION;
3589
+ const kept = value.slice(0, MAX_RENDER_ENTRIES).map((item) => clampForRender(item, depth + 1));
3590
+ if (value.length > MAX_RENDER_ENTRIES)
3591
+ kept.push(ELISION);
3592
+ return kept;
3593
+ }
3594
+ if (isPlainObject(value)) {
3595
+ if (depth >= MAX_RENDER_DEPTH)
3596
+ return ELISION;
3597
+ const keys = Object.keys(value);
3598
+ const out = Object.create(null);
3599
+ for (const key of keys.slice(0, MAX_RENDER_ENTRIES)) {
3600
+ out[key] = clampForRender(value[key], depth + 1);
3601
+ }
3602
+ return out;
3603
+ }
3604
+ return value;
3605
+ }
3606
+ function describeParent(drop) {
3607
+ return drop.parent ? ` inside ${renderForWarning(`agent.${drop.parent}`)}` : ` inside "agent"`;
3608
+ }
3609
+ function isOneSegment(key) {
3610
+ return key.length > 0 && !key.includes(".") && !UNPRINTABLE.test(key);
3611
+ }
3612
+ function refuseValue(value, depth = 0) {
3613
+ if (typeof value === "string") {
3614
+ if (UNPRINTABLE.test(value))
3615
+ return `A repo-supplied value may not hold a control or invisible character, and nothing in ${ALLOWED_PREFIX}* is spelled with one.`;
3616
+ if (value.length > MAX_VALUE_LENGTH)
3617
+ return `A repo-supplied value may be at most ${MAX_VALUE_LENGTH} characters, and this one is ${value.length}.`;
3618
+ return null;
3619
+ }
3620
+ if (depth >= MAX_VALUE_DEPTH)
3621
+ return `A repo-supplied value may not nest more than ${MAX_VALUE_DEPTH} levels deep.`;
3622
+ if (Array.isArray(value)) {
3623
+ for (const item of value) {
3624
+ const why = refuseValue(item, depth + 1);
3625
+ if (why !== null)
3626
+ return why;
3627
+ }
3628
+ return null;
3629
+ }
3630
+ if (isPlainObject(value)) {
3631
+ for (const [key, item] of Object.entries(value)) {
3632
+ const why = refuseValue(key, depth + 1) ?? refuseValue(item, depth + 1);
3633
+ if (why !== null)
3634
+ return why;
3635
+ }
3636
+ return null;
3637
+ }
3638
+ return null;
3639
+ }
3640
+ function flatten(value, prefix, out, dropped, depth = 0) {
3641
+ if (!isPlainObject(value)) {
3642
+ out.set(prefix, value);
3643
+ return;
3644
+ }
3645
+ if (depth >= MAX_BLOCK_DEPTH) {
3646
+ dropped.push({
3647
+ path: prefix,
3648
+ key: lastSegment(prefix),
3649
+ parent: parentPath(prefix),
3650
+ why: "depth"
3651
+ });
3652
+ return;
3653
+ }
3654
+ const keys = Object.keys(value);
3655
+ if (keys.length === 0)
3656
+ return;
3657
+ for (const key of keys) {
3658
+ const here = prefix ? `${prefix}.${key}` : key;
3659
+ if (FORBIDDEN_KEYS.has(key)) {
3660
+ dropped.push({ path: here, key, parent: prefix, why: "reserved" });
3661
+ continue;
3662
+ }
3663
+ if (!isOneSegment(key)) {
3664
+ dropped.push({
3665
+ path: here,
3666
+ key,
3667
+ parent: prefix,
3668
+ why: UNPRINTABLE.test(key) ? "unprintable" : "segment"
3669
+ });
3670
+ continue;
3671
+ }
3672
+ flatten(value[key], here, out, dropped, depth + 1);
3673
+ }
3674
+ }
3675
+ function lastSegment(path) {
3676
+ const at = path.lastIndexOf(".");
3677
+ return at === -1 ? path : path.slice(at + 1);
3678
+ }
3679
+ function parentPath(path) {
3680
+ const at = path.lastIndexOf(".");
3681
+ return at === -1 ? "" : path.slice(0, at);
3682
+ }
3683
+ function setPath(target, path, value) {
3684
+ const parts = path.split(".");
3685
+ if (parts.some((part) => FORBIDDEN_KEYS.has(part) || !isOneSegment(part)))
3686
+ return false;
3687
+ let node = target;
3688
+ for (const part of parts.slice(0, -1)) {
3689
+ const existing = own(node, part);
3690
+ if (!isPlainObject(existing))
3691
+ node[part] = {};
3692
+ node = node[part];
3693
+ }
3694
+ node[parts[parts.length - 1]] = value;
3695
+ return true;
3696
+ }
3697
+ function describeDrop(pinPath, drop) {
3698
+ switch (drop.why) {
3699
+ case "reserved":
3700
+ return `${pinPath}: ${renderForWarning(`agent.${drop.path}`)} uses a reserved JavaScript key name — ignoring it. Nothing in ${ALLOWED_PREFIX}* is spelled that way.`;
3701
+ case "unprintable":
3702
+ 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.`;
3703
+ case "depth":
3704
+ 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.`;
3705
+ default:
3706
+ 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 ".".`;
3707
+ }
3708
+ }
3709
+ function describeValue(value) {
3710
+ if (value === null)
3711
+ return "null";
3712
+ if (Array.isArray(value))
3713
+ return "a list";
3714
+ if (isPlainObject(value))
3715
+ return "a block of settings";
3716
+ return `a ${typeof value}`;
3717
+ }
3718
+ function mismatchedPath(message) {
3719
+ return message.slice(0, message.indexOf(" is "));
3720
+ }
3721
+ function readRepoAgentConfig(args) {
3722
+ const nothing = {
3723
+ overrides: {},
3724
+ warnings: [],
3725
+ suppliedPaths: new Set
3726
+ };
3727
+ const pinPath = findLocalConfigPath(args.repoRoot);
3728
+ if (pinPath === null)
3729
+ return nothing;
3730
+ let parsed;
3731
+ try {
3732
+ parsed = JSON.parse(readFileSync(pinPath, "utf-8"));
3733
+ } catch {
3734
+ return nothing;
3735
+ }
3736
+ if (!isPlainObject(parsed))
3737
+ return nothing;
3738
+ const warnings = [];
3739
+ for (const key of Object.keys(parsed)) {
3740
+ if (KNOWN_PIN_KEYS.includes(key))
3741
+ continue;
3742
+ warnings.push(`${pinPath}: ${renderForWarning(key)} is not a key anything reads — ignoring it. Known keys: ${KNOWN_PIN_KEYS.join(", ")}`);
3743
+ }
3744
+ const block = own(parsed, "agent");
3745
+ if (block !== undefined && !isPlainObject(block)) {
3746
+ warnings.push(`${pinPath}: "agent" is ${describeValue(block)} — it must be a block of ${ALLOWED_PREFIX}* settings. Ignoring it.`);
3747
+ }
3748
+ if (!isPlainObject(block))
3749
+ return { overrides: {}, warnings, suppliedPaths: new Set };
3750
+ const leaves = new Map;
3751
+ const dropped = [];
3752
+ flatten(block, "", leaves, dropped);
3753
+ for (const drop of dropped) {
3754
+ warnings.push(describeDrop(pinPath, drop));
3755
+ }
3756
+ const inScope = [];
3757
+ const candidate = {};
3758
+ for (const [path, value] of leaves) {
3759
+ if (path === ALLOWED_BLOCK) {
3760
+ 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.`);
3761
+ continue;
3762
+ }
3763
+ if (!path.startsWith(ALLOWED_PREFIX)) {
3764
+ warnings.push(`${pinPath}: ${renderForWarning(`agent.${path}`)} is global config, not repo config — ignoring it. A repository may set ${ALLOWED_PREFIX}* and nothing else.`);
3765
+ continue;
3766
+ }
3767
+ if (isNeverSettable(path)) {
3768
+ 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.`);
3769
+ continue;
3770
+ }
3771
+ const badValue = refuseValue(value);
3772
+ if (badValue !== null) {
3773
+ warnings.push(`${pinPath}: ${renderForWarning(`agent.${path}`)} = ${renderForWarning(value)} is not a value a repository may set — ignoring it. ${badValue}`);
3774
+ continue;
3775
+ }
3776
+ if (!setPath(candidate, path, value)) {
3777
+ warnings.push(`${pinPath}: ${renderForWarning(`agent.${path}`)} contains a reserved JavaScript key name — ignoring it. Nothing in ${ALLOWED_PREFIX}* is spelled that way.`);
3778
+ continue;
3779
+ }
3780
+ inScope.push([path, value]);
3781
+ }
3782
+ const malformed = [];
3783
+ for (const message of findConfigTypeMismatches(DEFAULT_AGENT_CONFIG, candidate)) {
3784
+ warnings.push(`${pinPath}: ${message}`);
3785
+ malformed.push(mismatchedPath(message).replace(/^agent\./, ""));
3786
+ }
3787
+ const kept = {};
3788
+ const suppliedPaths = new Set;
3789
+ for (const [path, value] of inScope) {
3790
+ if (malformed.some((bad) => path === bad || path.startsWith(`${bad}.`)))
3791
+ continue;
3792
+ if (!args.allowRepoOverrides && FREE_VALUE.get(path) !== value) {
3793
+ 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.`);
3794
+ continue;
3795
+ }
3796
+ if (!setPath(kept, path, value))
3797
+ continue;
3798
+ suppliedPaths.add(path);
3799
+ const named = NAMED_REVIEW_PATHS.get(path);
3800
+ if (named !== undefined) {
3801
+ warnings.push(`${pinPath}: ${renderForWarning(`agent.${path}`)} = ${renderForWarning(value)} — ${named.effect} Accepted because "allowRepoOverrides": true is set in ${args.globalConfigPath}.${named.extra ? ` ${named.extra}` : ""}`);
3802
+ }
3803
+ }
3804
+ return {
3805
+ overrides: kept,
3806
+ warnings,
3807
+ suppliedPaths
3808
+ };
3809
+ }
3810
+ 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;
3811
+ var init_repo_agent_config = __esm(() => {
3812
+ init_config_merge();
3813
+ init_types2();
3814
+ KNOWN_PIN_KEYS = [
3815
+ "workspaceId",
3816
+ "projectId",
3817
+ "commands",
3818
+ "agent"
3819
+ ];
3820
+ ALLOWED_BLOCK = ALLOWED_PREFIX.slice(0, -1);
3821
+ FREE_VALUE = new Map([
3822
+ ["review.autoMerge.enabled", false],
3823
+ ["review.autoMerge.interactive", false],
3824
+ ["review.autoMerge.findingsLoop", false],
3825
+ ["review.autoMerge.ciRepair.enabled", false],
3826
+ ["review.autoMerge.ciRepair.patch.enabled", false],
3827
+ ["review.autoMerge.requireGreenCi", true],
3828
+ ["review.autoMerge.reReviewOnBranchChange", true],
3829
+ ["review.autoMerge.independentReview.enabled", true],
3830
+ ["review.autoMerge.independentReview.verdict.enabled", true]
3831
+ ]);
3832
+ REPO_NEVER_SETTABLE = new Set([
3833
+ "review.autoMerge.ciRepair.patch.sandboxImage",
3834
+ "review.autoMerge.ciRepair.patch.sandboxTimeoutMs"
3835
+ ]);
3836
+ NAMED_REVIEW_PATHS = new Map([
3837
+ [
3838
+ "review.autoMerge.independentReview.checkName",
3839
+ {
3840
+ 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.",
3841
+ extra: `While ${ALLOWED_PREFIX}interactive or ${ALLOWED_PREFIX}enabled is on, a repo-supplied check name is refused at startup.`
3842
+ }
3843
+ ],
3844
+ [
3845
+ "review.autoMerge.independentReview.verdict.checkName",
3846
+ {
3847
+ 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.",
3848
+ extra: `While ${ALLOWED_PREFIX}interactive or ${ALLOWED_PREFIX}enabled is on, a repo-supplied check name is refused at startup.`
3849
+ }
3850
+ ],
3851
+ [
3852
+ "review.autoMerge.independentReview.label",
3853
+ {
3854
+ 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.",
3855
+ extra: "Nothing refuses it at startup, for that reason — unlike a repo-supplied check name."
3856
+ }
3857
+ ]
3858
+ ]);
3859
+ UNPRINTABLE = /[\p{Cc}\p{Cf}]/u;
3860
+ });
3861
+
3476
3862
  // src/config.ts
3477
3863
  var exports_config = {};
3478
3864
  __export(exports_config, {
@@ -3484,11 +3870,11 @@ __export(exports_config, {
3484
3870
  createApiClient: () => createApiClient
3485
3871
  });
3486
3872
  import { execSync } from "node:child_process";
3487
- import { readFileSync } from "node:fs";
3873
+ import { readFileSync as readFileSync2 } from "node:fs";
3488
3874
  import { log as log5 } from "@gethmy/harness";
3489
3875
  import { HarmonyApiClient } from "@gethmy/mcp/src/api-client.js";
3490
3876
  import {
3491
- findLocalConfigPath,
3877
+ findLocalConfigPath as findLocalConfigPath2,
3492
3878
  getActiveProjectId,
3493
3879
  getActiveWorkspaceId,
3494
3880
  getApiKey,
@@ -3500,9 +3886,9 @@ import {
3500
3886
  import { refreshOAuthToken } from "@gethmy/mcp/src/oauth-refresh.js";
3501
3887
  function readConfigFile() {
3502
3888
  try {
3503
- return readFileSync(configPath(), "utf-8");
3889
+ return readFileSync2(configPath(), "utf-8");
3504
3890
  } catch {
3505
- const raw = readFileSync(legacyConfigPath(), "utf-8");
3891
+ const raw = readFileSync2(legacyConfigPath(), "utf-8");
3506
3892
  if (!warnedLegacyConfigDir) {
3507
3893
  warnedLegacyConfigDir = true;
3508
3894
  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.`);
@@ -3556,22 +3942,37 @@ function loadDaemonConfig() {
3556
3942
  let agentName = "Harmony Agent";
3557
3943
  let agentIdentifier2 = "harmony-daemon";
3558
3944
  let agentColor = "#57b8a5";
3945
+ let allowRepoOverrides = false;
3559
3946
  const configWarnings = [...contextWarnings];
3560
3947
  try {
3561
3948
  const raw = readConfigFile();
3562
3949
  const parsed = JSON.parse(raw);
3563
3950
  configWarnings.push(...findRemovedConfigKeys(parsed));
3564
- if (parsed.agent) {
3565
- agentOverrides = parsed.agent;
3566
- }
3567
- if (typeof parsed.agentName === "string" && parsed.agentName.trim())
3568
- agentName = parsed.agentName.trim();
3569
- if (typeof parsed.agentIdentifier === "string" && parsed.agentIdentifier.trim())
3570
- agentIdentifier2 = parsed.agentIdentifier.trim();
3571
- if (typeof parsed.agentColor === "string" && parsed.agentColor.trim())
3572
- agentColor = parsed.agentColor.trim();
3951
+ const block = own(parsed, "agent");
3952
+ if (block) {
3953
+ agentOverrides = block;
3954
+ }
3955
+ const rawName = own(parsed, "agentName");
3956
+ if (typeof rawName === "string" && rawName.trim())
3957
+ agentName = rawName.trim();
3958
+ const rawIdentifier = own(parsed, "agentIdentifier");
3959
+ if (typeof rawIdentifier === "string" && rawIdentifier.trim())
3960
+ agentIdentifier2 = rawIdentifier.trim();
3961
+ const rawColor = own(parsed, "agentColor");
3962
+ if (typeof rawColor === "string" && rawColor.trim())
3963
+ agentColor = rawColor.trim();
3964
+ if (own(parsed, "allowRepoOverrides") === true)
3965
+ allowRepoOverrides = true;
3573
3966
  } catch {}
3574
- const agent = mergeAgentConfig(DEFAULT_AGENT_CONFIG, agentOverrides);
3967
+ const repo = readRepoAgentConfig({
3968
+ repoRoot,
3969
+ globalConfigPath: contextSource.globalPath,
3970
+ allowRepoOverrides
3971
+ });
3972
+ configWarnings.push(...repo.warnings);
3973
+ const operatorAgent = mergeAgentConfig(DEFAULT_AGENT_CONFIG, agentOverrides);
3974
+ const agent = mergeAgentConfig(operatorAgent, repo.overrides);
3975
+ const repoArmedGates = repoArmedGatesOf(repo.suppliedPaths, operatorAgent, agent);
3575
3976
  configWarnings.push(...findConfigTypeMismatches(DEFAULT_AGENT_CONFIG, agentOverrides));
3576
3977
  if (agent.runner !== "cli" && agent.runner !== "sdk") {
3577
3978
  agent.runner = DEFAULT_AGENT_CONFIG.runner;
@@ -3588,13 +3989,16 @@ function loadDaemonConfig() {
3588
3989
  agentColor,
3589
3990
  agent,
3590
3991
  agentOverrides,
3992
+ repoAgentOverrides: repo.overrides,
3993
+ repoAgentPaths: repo.suppliedPaths,
3994
+ repoArmedGates,
3591
3995
  contextSource,
3592
3996
  configWarnings
3593
3997
  };
3594
3998
  }
3595
3999
  function resolveContextSource(repoRoot) {
3596
4000
  const globalPath = getConfigPath();
3597
- const localPath = findLocalConfigPath(repoRoot);
4001
+ const localPath = findLocalConfigPath2(repoRoot);
3598
4002
  if (localPath === null) {
3599
4003
  return {
3600
4004
  source: {
@@ -3648,6 +4052,7 @@ var init_config = __esm(() => {
3648
4052
  init_config_merge();
3649
4053
  init_config_validation();
3650
4054
  init_paths();
4055
+ init_repo_agent_config();
3651
4056
  init_types2();
3652
4057
  });
3653
4058
 
@@ -4157,7 +4562,7 @@ import {
4157
4562
  mkdirSync as mkdirSync2,
4158
4563
  mkdtempSync,
4159
4564
  readdirSync as readdirSync2,
4160
- readFileSync as readFileSync2,
4565
+ readFileSync as readFileSync3,
4161
4566
  rmSync,
4162
4567
  statSync as statSync2,
4163
4568
  unlinkSync,
@@ -4234,7 +4639,7 @@ function sameBytes(a, b) {
4234
4639
  return false;
4235
4640
  if (sa.size > MAX_FILE_BYTES)
4236
4641
  return false;
4237
- return readFileSync2(a).equals(readFileSync2(b));
4642
+ return readFileSync3(a).equals(readFileSync3(b));
4238
4643
  } catch {
4239
4644
  return false;
4240
4645
  }
@@ -4258,7 +4663,7 @@ function applyChanges(args) {
4258
4663
  for (const path of [...changes.modified, ...changes.added]) {
4259
4664
  const from = join3(scratch, path);
4260
4665
  const to = safeDestination(clean, path);
4261
- const bytes = readFileSync2(from);
4666
+ const bytes = readFileSync3(from);
4262
4667
  if (bytes.byteLength > MAX_FILE_BYTES) {
4263
4668
  throw new Error(`the repair wrote ${path} at ${bytes.byteLength} bytes, over the ${MAX_FILE_BYTES} cap`);
4264
4669
  }
@@ -4315,7 +4720,8 @@ import {
4315
4720
  isPrUrlOnOrigin,
4316
4721
  log as log9,
4317
4722
  removeWorktreeHoldingBranch,
4318
- resolvePrHeadBranch
4723
+ resolvePrHeadBranch,
4724
+ shouldInstall
4319
4725
  } from "@gethmy/harness";
4320
4726
  function gitErrorDetail(err) {
4321
4727
  const e = err;
@@ -4372,6 +4778,8 @@ function checkoutExistingBranch(basePath, branchName, opts = {}) {
4372
4778
  } catch (err) {
4373
4779
  throw new Error(`Failed to create review worktree for ${branchName}: ${gitErrorDetail(err)}`);
4374
4780
  }
4781
+ if (!shouldInstall(worktreeDir, opts.commands))
4782
+ return worktreeDir;
4375
4783
  log9.info(TAG9, "Installing dependencies in review worktree...");
4376
4784
  try {
4377
4785
  execSync2(installCommand(opts.ignoreScripts !== false), {
@@ -4458,7 +4866,7 @@ var init_review_worktree = __esm(() => {
4458
4866
  // src/ci-patch.ts
4459
4867
  import { execFileSync as execFileSync4 } from "node:child_process";
4460
4868
  import { randomUUID } from "node:crypto";
4461
- import { readFileSync as readFileSync3 } from "node:fs";
4869
+ import { readFileSync as readFileSync4 } from "node:fs";
4462
4870
  import { join as join4 } from "node:path";
4463
4871
  import {
4464
4872
  buildCommand,
@@ -4468,6 +4876,7 @@ import {
4468
4876
  GIT_NO_HOOKS as GIT_NO_HOOKS4,
4469
4877
  HARMONY_CREDENTIAL_KEYS,
4470
4878
  log as log10,
4879
+ REPO_CONFIG_FILENAMES,
4471
4880
  runInSandbox,
4472
4881
  SdkAgentRunner,
4473
4882
  sandboxAvailable,
@@ -4559,7 +4968,10 @@ async function verifyInSandbox(worktree, config) {
4559
4968
  return null;
4560
4969
  }
4561
4970
  let ran = null;
4562
- for (const command of [buildCommand(worktree), testCommand(worktree)]) {
4971
+ for (const command of [
4972
+ buildCommand(worktree, config.commands),
4973
+ testCommand(worktree, config.commands)
4974
+ ]) {
4563
4975
  if (!command)
4564
4976
  continue;
4565
4977
  ran = command;
@@ -4593,7 +5005,7 @@ function commitRepair(worktree, card) {
4593
5005
  }
4594
5006
  function readGitPointer(worktree) {
4595
5007
  try {
4596
- return readFileSync3(join4(worktree, ".git"), "utf-8");
5008
+ return readFileSync4(join4(worktree, ".git"), "utf-8");
4597
5009
  } catch {
4598
5010
  return null;
4599
5011
  }
@@ -4620,7 +5032,8 @@ async function repairBranch(deps) {
4620
5032
  let trees = null;
4621
5033
  try {
4622
5034
  worktree = checkoutExistingBranch(config.worktree.basePath, branch, {
4623
- ignoreScripts: true
5035
+ ignoreScripts: true,
5036
+ commands: config.commands
4624
5037
  });
4625
5038
  const baseSha = remoteSha(branch, worktree);
4626
5039
  if (!baseSha) {
@@ -4762,7 +5175,8 @@ var init_ci_patch = __esm(() => {
4762
5175
  /(^|\/)\.yarnrc\.ya?ml$/i,
4763
5176
  /(^|\/)\.tool-versions$/i,
4764
5177
  /(^|\/)\.gitattributes$/i,
4765
- /(^|\/)\.gitconfig$/i
5178
+ /(^|\/)\.gitconfig$/i,
5179
+ ...REPO_CONFIG_FILENAMES.map((name) => new RegExp(`(^|/)${name.replace(/\./g, "\\.")}$`, "i"))
4766
5180
  ];
4767
5181
  });
4768
5182
 
@@ -6478,7 +6892,7 @@ async function runCompletion(client, card, branchName, worktreePath, config, wor
6478
6892
  revertWarnings: []
6479
6893
  };
6480
6894
  if (config.verification.enabled && config.verification.lint) {
6481
- await runFormatFix(worktreePath, config.verification.timeout, workerId, verificationSandbox(config));
6895
+ await runFormatFix(worktreePath, config.verification.timeout, workerId, verificationSandbox(config), config.commands);
6482
6896
  }
6483
6897
  commitUncommittedChanges(worktreePath, card);
6484
6898
  const hasCommits = checkHasCommits(worktreePath, config.worktree.baseBranch, runBaselineSha);
@@ -7447,7 +7861,7 @@ var init_prompt = __esm(() => {
7447
7861
  });
7448
7862
 
7449
7863
  // src/review-completion.ts
7450
- import { readFileSync as readFileSync4, statSync as statSync3 } from "node:fs";
7864
+ import { readFileSync as readFileSync5, statSync as statSync3 } from "node:fs";
7451
7865
  import {
7452
7866
  cleanupWorktree as cleanupWorktree3,
7453
7867
  createPullRequest as createPullRequest2,
@@ -7529,7 +7943,7 @@ function tailRunLog(path, bytes = RUN_LOG_TAIL_BYTES) {
7529
7943
  if (size === 0)
7530
7944
  return null;
7531
7945
  const start = Math.max(0, size - bytes);
7532
- const buf = readFileSync4(path);
7946
+ const buf = readFileSync5(path);
7533
7947
  return buf.subarray(start).toString("utf-8");
7534
7948
  } catch {
7535
7949
  return null;
@@ -8253,7 +8667,7 @@ __export(exports_state_store, {
8253
8667
  import {
8254
8668
  existsSync as existsSync3,
8255
8669
  mkdirSync as mkdirSync4,
8256
- readFileSync as readFileSync5,
8670
+ readFileSync as readFileSync6,
8257
8671
  renameSync as renameSync2,
8258
8672
  writeFileSync as writeFileSync2
8259
8673
  } from "node:fs";
@@ -8390,7 +8804,7 @@ class StateStore {
8390
8804
  if (!existsSync3(this.path))
8391
8805
  return emptyState();
8392
8806
  try {
8393
- const raw = readFileSync5(this.path, "utf-8");
8807
+ const raw = readFileSync6(this.path, "utf-8");
8394
8808
  const parsed = JSON.parse(raw);
8395
8809
  if (parsed?.version !== SCHEMA_VERSION) {
8396
8810
  log26.warn(TAG23, `state file has version ${parsed?.version}, expected ${SCHEMA_VERSION} — migrating (preserving card budget/attempts, dropping in-flight runs)`);
@@ -9059,6 +9473,7 @@ import {
9059
9473
  buildGateCollectorRegistry,
9060
9474
  cleanupWorktree as cleanupWorktree4,
9061
9475
  collectGateEvidence,
9476
+ DEV_SERVER_READY_TIMEOUT_MS,
9062
9477
  DevServerReadinessError,
9063
9478
  devServerLaunch,
9064
9479
  devServerSkipReason,
@@ -9322,7 +9737,7 @@ class ReviewWorker {
9322
9737
  }
9323
9738
  this.sessionId = reviewSession && typeof reviewSession === "object" && "id" in reviewSession ? reviewSession.id ?? null : null;
9324
9739
  const labelPromise = addLabelByName(this.client, card, "agent", "#8b5cf6");
9325
- this.worktreePath = checkoutExistingBranch(this.config.worktree.basePath, this.branchName);
9740
+ this.worktreePath = checkoutExistingBranch(this.config.worktree.basePath, this.branchName, { commands: this.config.commands });
9326
9741
  await labelPromise;
9327
9742
  }
9328
9743
  if (this.aborted)
@@ -9845,14 +10260,15 @@ No verdict was produced, so this card has not been reviewed. The run's log is at
9845
10260
  }
9846
10261
  }
9847
10262
  async startDevServer(card, cwd, port) {
9848
- const declined = devServerSkipReason(cwd);
10263
+ const declined = devServerSkipReason(cwd, this.config.commands);
9849
10264
  if (declined)
9850
10265
  return { previewUrl: null, skipReason: declined };
9851
10266
  log29.info(this.tag, `Starting dev server on port ${port}...`);
9852
10267
  const launch = devServerLaunch({
9853
10268
  worktreePath: cwd,
9854
10269
  port,
9855
- sandbox: verificationSandbox2(this.config)
10270
+ sandbox: verificationSandbox2(this.config),
10271
+ operator: this.config.commands
9856
10272
  });
9857
10273
  this.devServerContainer = launch.containerName ?? null;
9858
10274
  if (launch.containerName) {
@@ -9878,13 +10294,14 @@ No verdict was produced, so this card has not been reviewed. The run's log is at
9878
10294
  if (devServerSpawnError) {
9879
10295
  throw new DevServerReadinessError(`dev server failed to start (${launch.cmd}): ${devServerSpawnError.message}`);
9880
10296
  }
9881
- await waitForDevServer(this.devServerProcess, 30000);
10297
+ await waitForDevServer(this.devServerProcess, DEV_SERVER_READY_TIMEOUT_MS);
9882
10298
  await probeDevServer(port);
9883
10299
  } catch (err) {
9884
10300
  this.killDevServer();
10301
+ const failure = sanitizeCiText3(err instanceof Error ? err.message : String(err), REVIEW_FAILURE_REASON_MAX);
9885
10302
  return {
9886
10303
  previewUrl: null,
9887
- skipReason: sanitizeCiText3(err instanceof Error ? err.message : String(err), REVIEW_FAILURE_REASON_MAX)
10304
+ skipReason: launch.origin ? `${launch.origin}, and ${failure}` : failure
9888
10305
  };
9889
10306
  }
9890
10307
  log29.info(this.tag, `Dev server ready on port ${port}`);
@@ -11257,7 +11674,7 @@ function workspaceConfigToPartial(ws) {
11257
11674
  }
11258
11675
  function resolveEffectiveAgentConfig(cfg, ws) {
11259
11676
  const withWorkspace = ws ? mergeAgentConfig(DEFAULT_AGENT_CONFIG, workspaceConfigToPartial(ws)) : DEFAULT_AGENT_CONFIG;
11260
- return mergeAgentConfig(withWorkspace, cfg.agentOverrides);
11677
+ return mergeAgentConfig(mergeAgentConfig(withWorkspace, cfg.agentOverrides), cfg.repoAgentOverrides);
11261
11678
  }
11262
11679
  var init_workspace_model_config = __esm(() => {
11263
11680
  init_config_merge();
@@ -11750,7 +12167,8 @@ class Worker {
11750
12167
  }
11751
12168
  this.worktreePath = createWorktree(this.config.worktree.basePath, this.config.worktree.baseBranch, this.branchName, {
11752
12169
  continueExisting: target.continueExisting,
11753
- branchExistsOnOrigin: target.existsOnOrigin
12170
+ branchExistsOnOrigin: target.existsOnOrigin,
12171
+ commands: this.config.commands
11754
12172
  });
11755
12173
  this.runBaselineSha = readWorktreeHead(this.worktreePath);
11756
12174
  }
@@ -14424,7 +14842,7 @@ __export(exports_port_registry, {
14424
14842
  import {
14425
14843
  existsSync as existsSync4,
14426
14844
  mkdirSync as mkdirSync5,
14427
- readFileSync as readFileSync6,
14845
+ readFileSync as readFileSync7,
14428
14846
  renameSync as renameSync3,
14429
14847
  writeFileSync as writeFileSync4
14430
14848
  } from "node:fs";
@@ -14437,7 +14855,7 @@ function load(path) {
14437
14855
  if (!existsSync4(path))
14438
14856
  return {};
14439
14857
  try {
14440
- const raw = readFileSync6(path, "utf-8");
14858
+ const raw = readFileSync7(path, "utf-8");
14441
14859
  const parsed = JSON.parse(raw);
14442
14860
  if (parsed && typeof parsed === "object")
14443
14861
  return parsed;
@@ -15889,7 +16307,11 @@ function contextSourceLine(config) {
15889
16307
  if (unreadablePin !== null) {
15890
16308
  return `all of it ${globalPath} (global) — ${unreadablePin} is present but UNREADABLE, so its pin was ignored`;
15891
16309
  }
15892
- return scope === "repo" ? `project+workspace ${path} (repo pin) · all else ${globalPath}` : `all of it ${globalPath} (global; no repo pin found)`;
16310
+ if (scope !== "repo") {
16311
+ return `all of it ${globalPath} (global; no repo pin found)`;
16312
+ }
16313
+ const pinLabel = config.repoAgentPaths.size > 0 ? "repo pin, +auto-merge" : "repo pin";
16314
+ return `project+workspace ${path} (${pinLabel}) · rest ${globalPath}`;
15893
16315
  }
15894
16316
  function titleRule(title) {
15895
16317
  const prefix = "─── ";
@@ -15930,7 +16352,7 @@ import {
15930
16352
  copyFileSync,
15931
16353
  existsSync as existsSync5,
15932
16354
  mkdirSync as mkdirSync6,
15933
- readFileSync as readFileSync7,
16355
+ readFileSync as readFileSync8,
15934
16356
  renameSync as renameSync4,
15935
16357
  writeFileSync as writeFileSync5
15936
16358
  } from "node:fs";
@@ -15952,7 +16374,7 @@ function atomicWriteJson(path, value) {
15952
16374
  }
15953
16375
  function readTargetFile(path) {
15954
16376
  try {
15955
- const parsed = JSON.parse(readFileSync7(path, "utf-8"));
16377
+ const parsed = JSON.parse(readFileSync8(path, "utf-8"));
15956
16378
  if (!parsed || typeof parsed !== "object")
15957
16379
  return null;
15958
16380
  return parsed;
@@ -16006,7 +16428,7 @@ function migrateLegacyStateFile(opts) {
16006
16428
  return NO_MIGRATION;
16007
16429
  let legacy;
16008
16430
  try {
16009
- const parsed = JSON.parse(readFileSync7(legacyPath, "utf-8"));
16431
+ const parsed = JSON.parse(readFileSync8(legacyPath, "utf-8"));
16010
16432
  if (!parsed || typeof parsed !== "object")
16011
16433
  throw new Error("not an object");
16012
16434
  legacy = parsed;
@@ -16535,7 +16957,7 @@ __export(exports_run_stats, {
16535
16957
  computeRunStats: () => computeRunStats,
16536
16958
  buildRunListRows: () => buildRunListRows
16537
16959
  });
16538
- import { readdirSync as readdirSync4, readFileSync as readFileSync8, statSync as statSync4 } from "node:fs";
16960
+ import { readdirSync as readdirSync4, readFileSync as readFileSync9, statSync as statSync4 } from "node:fs";
16539
16961
  import { join as join8 } from "node:path";
16540
16962
  function runsDir2(base) {
16541
16963
  return runsDir(base);
@@ -16618,7 +17040,7 @@ function tailText(path, bytes = 4096) {
16618
17040
  const size = statSync4(path).size;
16619
17041
  if (size === 0)
16620
17042
  return "";
16621
- const buf = readFileSync8(path);
17043
+ const buf = readFileSync9(path);
16622
17044
  const start = Math.max(0, size - bytes);
16623
17045
  let text = buf.subarray(start).toString("utf-8");
16624
17046
  if (start > 0) {
@@ -16640,7 +17062,7 @@ function grepRunLogs(files, pattern, opts = {}) {
16640
17062
  continue;
16641
17063
  let content;
16642
17064
  try {
16643
- content = readFileSync8(file.path, "utf-8");
17065
+ content = readFileSync9(file.path, "utf-8");
16644
17066
  } catch {
16645
17067
  continue;
16646
17068
  }
@@ -17240,7 +17662,9 @@ async function main() {
17240
17662
  throw err;
17241
17663
  }
17242
17664
  try {
17243
- validateAutoMergeConfig(config.agent);
17665
+ for (const warning of validateAutoMergeConfig(config.agent, config.repoAgentPaths, config.repoArmedGates)) {
17666
+ banner.warn(warning);
17667
+ }
17244
17668
  validateBudgetConfig(config.agent);
17245
17669
  validateSweepConfig(config.agent);
17246
17670
  validateRankingConfig(config.agent);
@@ -17618,14 +18042,14 @@ import { realpathSync, writeFileSync as writeFileSync6 } from "node:fs";
17618
18042
  import { dirname as dirname7, resolve as resolve5, sep as sep3 } from "node:path";
17619
18043
  import { GIT_NO_HOOKS as GIT_NO_HOOKS11 } from "@gethmy/harness";
17620
18044
  import {
17621
- findLocalConfigPath as findLocalConfigPath2,
18045
+ findLocalConfigPath as findLocalConfigPath3,
17622
18046
  getLocalConfigPath,
17623
18047
  loadLocalConfig as loadLocalConfig2
17624
18048
  } from "@gethmy/mcp/src/config.js";
17625
18049
  function migrateLocalPin(cwd, opts = {}) {
17626
18050
  const here = resolve5(cwd ?? process.cwd());
17627
18051
  const boundary = opts.repoRoot ?? gitTopLevel(here) ?? here;
17628
- const found = findLocalConfigPath2(boundary);
18052
+ const found = findLocalConfigPath3(boundary);
17629
18053
  if (found === null) {
17630
18054
  return { outcome: "no-pin", from: null, to: null };
17631
18055
  }
@@ -17700,11 +18124,23 @@ function realOrResolved(path) {
17700
18124
  }
17701
18125
  var init_local_pin_migration = () => {};
17702
18126
 
18127
+ // src/head-anchor.ts
18128
+ import { log as log52 } from "@gethmy/harness";
18129
+ function sealProvenHead(sha, provenance) {
18130
+ if (!/^[0-9a-f]{40}$/.test(sha)) {
18131
+ log52.warn(TAG48, `refusing to seal "${sha}" as a head anchor (${provenance}) — an anchor is compared against the pull request's own head for equality, so an abbreviated or malformed sha would read as a mismatch rather than as the unreadable head it is`);
18132
+ return;
18133
+ }
18134
+ return sha;
18135
+ }
18136
+ var TAG48 = "head-anchor";
18137
+ var init_head_anchor = () => {};
18138
+
17703
18139
  // src/merge-cleanup.ts
17704
18140
  import { execFileSync as execFileSync11 } from "node:child_process";
17705
- import { existsSync as existsSync7, realpathSync as realpathSync2 } from "node:fs";
18141
+ import { existsSync as existsSync7, realpathSync as realpathSync2, statSync as statSync6 } from "node:fs";
17706
18142
  import { basename, dirname as dirname8, isAbsolute, resolve as resolve6, sep as sep4 } from "node:path";
17707
- import { cleanupWorktree as cleanupWorktree6, GIT_NO_HOOKS as GIT_NO_HOOKS12, log as log52 } from "@gethmy/harness";
18143
+ import { cleanupWorktree as cleanupWorktree6, GIT_NO_HOOKS as GIT_NO_HOOKS12, log as log53 } from "@gethmy/harness";
17708
18144
  function canonicalize(p) {
17709
18145
  const original = isAbsolute(p) ? p : `${process.cwd()}${sep4}${p}`;
17710
18146
  try {
@@ -17731,36 +18167,97 @@ function cwdIsInsideWorktree(cwd, worktreePath) {
17731
18167
  const b = canonicalize(worktreePath);
17732
18168
  return a === b || a.startsWith(b + sep4);
17733
18169
  }
17734
- function linkedWorktreePaths(cwd) {
18170
+ function worktreeRecords(cwd) {
17735
18171
  let listing;
17736
18172
  try {
17737
18173
  listing = execFileSync11("git", [...GIT_NO_HOOKS12, "worktree", "list", "--porcelain", "-z"], { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] });
17738
18174
  } catch {
17739
18175
  return null;
17740
18176
  }
17741
- return listing.split("\x00").filter((line) => line.startsWith("worktree ")).map((line) => line.slice("worktree ".length)).slice(1).map(canonicalize);
18177
+ const records = [];
18178
+ let current = null;
18179
+ for (const field of listing.split("\x00")) {
18180
+ if (field === "") {
18181
+ current = null;
18182
+ continue;
18183
+ }
18184
+ if (field.startsWith("worktree ")) {
18185
+ current = {
18186
+ path: canonicalize(field.slice("worktree ".length)),
18187
+ bare: false
18188
+ };
18189
+ records.push(current);
18190
+ continue;
18191
+ }
18192
+ if (current && field === "bare")
18193
+ current.bare = true;
18194
+ }
18195
+ return records;
18196
+ }
18197
+ function mainWorktreePath(cwd) {
18198
+ try {
18199
+ const out = execFileSync11("git", [...GIT_NO_HOOKS12, "rev-parse", "--git-common-dir"], { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] }).trim();
18200
+ if (!out)
18201
+ return null;
18202
+ return canonicalize(dirname8(resolve6(cwd, out)));
18203
+ } catch {
18204
+ return null;
18205
+ }
17742
18206
  }
17743
- function isRegisteredWorktree(worktreePath, cwd) {
18207
+ function linkedWorktreePaths(cwd) {
18208
+ const records = worktreeRecords(cwd);
18209
+ if (records === null)
18210
+ return null;
18211
+ if (records.some((r) => r.bare)) {
18212
+ return records.filter((r) => !r.bare).map((r) => r.path);
18213
+ }
18214
+ const main2 = mainWorktreePath(cwd);
18215
+ if (main2 !== null && records.some((r) => r.path === main2)) {
18216
+ return records.filter((r) => r.path !== main2).map((r) => r.path);
18217
+ }
18218
+ log53.warn(TAG49, `could not identify the main working tree of ${cwd} by path (git derived ${main2 ?? "nothing"} from --git-common-dir, which names no worktree in the listing) — falling back to dropping the first record, which git documents as the main tree. A merge or cleanup that then refuses a worktree it should accept is this fallback, not a forged registration.`);
18219
+ return records.slice(1).map((r) => r.path);
18220
+ }
18221
+ function sameDirectoryIgnoringCase(asked, registered) {
18222
+ try {
18223
+ const a = statSync6(asked);
18224
+ const b = statSync6(registered);
18225
+ return a.dev === b.dev && a.ino === b.ino;
18226
+ } catch {
18227
+ return false;
18228
+ }
18229
+ }
18230
+ function classifyWorktreeRegistration(worktreePath, cwd) {
17744
18231
  const registered = linkedWorktreePaths(cwd);
17745
18232
  if (registered === null)
17746
- return false;
17747
- return registered.includes(canonicalize(worktreePath));
18233
+ return { kind: "unanswerable" };
18234
+ const asked = canonicalize(worktreePath);
18235
+ if (registered.includes(asked))
18236
+ return { kind: "registered" };
18237
+ const folded = registered.find((p) => p !== asked && p.toLowerCase() === asked.toLowerCase() && sameDirectoryIgnoringCase(asked, p));
18238
+ if (folded !== undefined) {
18239
+ return { kind: "case-mismatch", registered: folded };
18240
+ }
18241
+ return { kind: "unregistered" };
18242
+ }
18243
+ function isRegisteredWorktree(worktreePath, cwd) {
18244
+ return classifyWorktreeRegistration(worktreePath, cwd).kind === "registered";
17748
18245
  }
17749
18246
  function cleanupMergedWorktree(args) {
17750
18247
  if (!isRegisteredWorktree(args.worktreePath, args.cwd)) {
17751
18248
  const reason = `refusing to clean up ${args.worktreePath} — git does not list it as a ` + `worktree of the repository at ${args.cwd}, and removing a path git ` + `does not manage would be a recursive delete of an unknown directory. ` + `Pass the worktree path Step B5 created, and re-run from the ` + `repository root.`;
17752
- log52.warn(TAG48, reason);
18249
+ log53.warn(TAG49, reason);
17753
18250
  return { removed: false, reason };
17754
18251
  }
17755
18252
  if (cwdIsInsideWorktree(args.cwd, args.worktreePath) || cwdIsInsideWorktree(process.cwd(), args.worktreePath)) {
17756
18253
  const reason = `refusing to clean up ${args.worktreePath} from inside it — the branch ` + `delete would run with a deleted cwd and fail silently. Re-run from the ` + `repository root.`;
17757
- log52.warn(TAG48, reason);
18254
+ log53.warn(TAG49, reason);
17758
18255
  return { removed: false, reason };
17759
18256
  }
17760
18257
  cleanupWorktree6(args.worktreePath, args.branchName ?? undefined);
17761
18258
  return { removed: !existsSync7(args.worktreePath) };
17762
18259
  }
17763
- var TAG48 = "merge-cleanup";
18260
+ var TAG49 = "merge-cleanup";
17764
18261
  var init_merge_cleanup = () => {};
17765
18262
 
17766
18263
  // src/merge-command.ts
@@ -17775,7 +18272,7 @@ import { resolve as resolve7 } from "node:path";
17775
18272
  import { promisify as promisify2 } from "node:util";
17776
18273
  import {
17777
18274
  GIT_NO_HOOKS as GIT_NO_HOOKS13,
17778
- log as log53,
18275
+ log as log54,
17779
18276
  resolvePrHeadBranch as resolvePrHeadBranch3
17780
18277
  } from "@gethmy/harness";
17781
18278
  function buildFindingsBlock(items) {
@@ -17802,7 +18299,7 @@ async function gitRevParse(what, at) {
17802
18299
  async function cwdIsRepositoryTopLevel(cwd) {
17803
18300
  const top = await gitRevParse("--show-toplevel", cwd);
17804
18301
  if (!top || realpathOr(top) !== realpathOr(cwd)) {
17805
- log53.warn(TAG49, `not reading a local head from ${cwd} — it is not the top level of a git checkout (git answered ${top ?? "nothing"}). Run merge from the repository root, or pass --worktree.`);
18302
+ log54.warn(TAG50, `not reading a local head from ${cwd} — it is not the top level of a git checkout (git answered ${top ?? "nothing"}). Run merge from the repository root, or pass --worktree.`);
17806
18303
  return false;
17807
18304
  }
17808
18305
  const gitDir = await gitRevParse("--git-dir", cwd);
@@ -17814,7 +18311,7 @@ async function cwdIsRepositoryTopLevel(cwd) {
17814
18311
  }
17815
18312
  if (isRegisteredWorktree(cwd, cwd))
17816
18313
  return true;
17817
- log53.warn(TAG49, `not reading a local head from ${cwd} — git does not list it as a worktree of its own repository.`);
18314
+ log54.warn(TAG50, `not reading a local head from ${cwd} — git does not list it as a worktree of its own repository.`);
17818
18315
  return false;
17819
18316
  }
17820
18317
  function realpathOr(p) {
@@ -17824,28 +18321,72 @@ function realpathOr(p) {
17824
18321
  return p;
17825
18322
  }
17826
18323
  }
17827
- async function readValidatedWorktreeHead(worktreePath, cwd) {
17828
- if (worktreePath && !isRegisteredWorktree(worktreePath, cwd)) {
17829
- log53.warn(TAG49, `not reading a local head from ${worktreePath} — git does not list it as a worktree of the repository at ${cwd}. The merge will refuse: an interactive merge is only allowed for a head this run can prove it built.`);
17830
- return;
18324
+ async function checkedOutBranch(at) {
18325
+ try {
18326
+ const { stdout } = await execFileAsync2("git", [...GIT_NO_HOOKS13, "symbolic-ref", "--quiet", "--short", "HEAD"], { cwd: at });
18327
+ const branch = stdout.trim();
18328
+ return branch === "" ? null : branch;
18329
+ } catch {
18330
+ return null;
18331
+ }
18332
+ }
18333
+ async function readValidatedWorktreeHead(deps) {
18334
+ const { worktreePath, cwd } = deps;
18335
+ if (worktreePath) {
18336
+ const registration = classifyWorktreeRegistration(worktreePath, cwd);
18337
+ if (registration.kind !== "registered") {
18338
+ const detail = registration.kind === "case-mismatch" ? ` The worktree ${worktreePath} is the same directory as the registered ${registration.registered} and differs from it only in case, but git's registration carries the spelling above and this merge matches it exactly. Pass the registered spelling.` : registration.kind === "unanswerable" ? ` The worktree registrations of ${cwd} could not be listed at all, so ${worktreePath} could not be proved to be one of them.` : ` No validated worktree HEAD was available for ${worktreePath} — git does not list it as a worktree of the repository at ${cwd} — so the merge could not be bound to the head this run built and held for a person.`;
18339
+ log54.warn(TAG50, `not reading a local head from ${worktreePath} (${registration.kind}).${detail} The merge will refuse: an interactive merge is only allowed for a head this run can prove it built.`);
18340
+ return { sha: undefined, detail };
18341
+ }
18342
+ }
18343
+ if (!worktreePath && !await cwdIsRepositoryTopLevel(cwd)) {
18344
+ return {
18345
+ sha: undefined,
18346
+ detail: ` No validated local HEAD was available: no --worktree was passed, and ${cwd} is not the top level of a checkout of this repository. The merge could not be bound to the head this run built and held for a person.`
18347
+ };
17831
18348
  }
17832
- if (!worktreePath && !await cwdIsRepositoryTopLevel(cwd))
17833
- return;
17834
18349
  const readAt = worktreePath ?? cwd;
17835
- const sha = await gitRevParse("HEAD", readAt);
17836
- if (sha && /^[0-9a-f]{40}$/.test(sha))
17837
- return sha;
17838
- log53.warn(TAG49, `could not read a HEAD sha from ${readAt} (git answered ${sha ? `"${sha}"` : "nothing"}) — the merge will refuse, since it can only run for a head this run can prove it built`);
17839
- return;
18350
+ const raw = await gitRevParse("HEAD", readAt);
18351
+ const sha = raw ? sealProvenHead(raw, worktreePath ? "registered-worktree" : "repository-top-level") : undefined;
18352
+ if (!sha) {
18353
+ log54.warn(TAG50, `could not read a HEAD sha from ${readAt} (git answered ${raw ? `"${raw}"` : "nothing"}) — the merge will refuse, since it can only run for a head this run can prove it built`);
18354
+ return {
18355
+ sha: undefined,
18356
+ detail: ` No HEAD sha could be read from ${readAt}, so the merge could not be bound to the head this run built and held for a person.`
18357
+ };
18358
+ }
18359
+ if (worktreePath) {
18360
+ const resolved = await resolvePrHeadBranch3(deps.prUrl, cwd, deps.provider);
18361
+ if (resolved.kind === "skip") {
18362
+ log54.warn(TAG50, `not corroborating ${worktreePath} against the head branch of ${deps.prUrl}: ${resolved.reason}. The anchor stands on the head sha alone.`);
18363
+ } else {
18364
+ const branch = await checkedOutBranch(worktreePath);
18365
+ if (branch === null) {
18366
+ log54.warn(TAG50, `${worktreePath} has no branch checked out (detached HEAD), so it could not be corroborated against ${resolved.branch}. The anchor stands on the head sha alone.`);
18367
+ } else if (branch !== resolved.branch) {
18368
+ const detail = ` The worktree ${worktreePath} is on "${branch}", and ${deps.prUrl} has "${resolved.branch}" as its head branch — so that worktree is not where this pull request was built. --worktree is composed from a model's memory of the Step B5 path, which is why it is checked against the pull request and not only against git.`;
18369
+ log54.warn(TAG50, `refusing the anchor from ${worktreePath}.${detail}`);
18370
+ return { sha: undefined, detail };
18371
+ }
18372
+ }
18373
+ }
18374
+ return { sha, detail: "" };
17840
18375
  }
17841
18376
  async function runMergeCommand(deps) {
17842
18377
  const waitMs = deps.config.review.autoMerge.waitMinutes * 60000;
17843
18378
  const started = deps.now();
17844
18379
  let requestedReview = false;
17845
- const localHeadSha = await readValidatedWorktreeHead(deps.worktreePath, deps.cwd);
18380
+ const anchor = await readValidatedWorktreeHead({
18381
+ worktreePath: deps.worktreePath,
18382
+ cwd: deps.cwd,
18383
+ prUrl: deps.prUrl,
18384
+ provider: deps.provider
18385
+ });
18386
+ const localHeadSha = anchor.sha;
17846
18387
  let base = null;
17847
18388
  let prHead = null;
17848
- const anchorDetail = localHeadSha ? "" : deps.worktreePath ? ` No validated worktree HEAD was available for ${deps.worktreePath}, so the merge could not be bound to the head this run built and held for a person.` : ` No validated local HEAD was available: no --worktree was passed, and ${deps.cwd} is not the top level of a checkout of this repository. The merge could not be bound to the head this run built and held for a person.`;
18389
+ const anchorDetail = anchor.detail;
17849
18390
  for (;; ) {
17850
18391
  const findings = [];
17851
18392
  let outcome;
@@ -17875,7 +18416,7 @@ async function runMergeCommand(deps) {
17875
18416
  });
17876
18417
  } catch (err) {
17877
18418
  const detail = err instanceof Error ? err.message : String(err);
17878
- log53.warn(TAG49, `merge attempt failed: ${detail}`);
18419
+ log54.warn(TAG50, `merge attempt failed: ${detail}`);
17879
18420
  deps.emit({
17880
18421
  type: "parked",
17881
18422
  reason: `the merge attempt failed: ${detail}`
@@ -17896,7 +18437,7 @@ async function runMergeCommand(deps) {
17896
18437
  if (outcome === "report-findings") {
17897
18438
  const reported = findings[0];
17898
18439
  if (!reported) {
17899
- log53.warn(TAG49, "the merge reported findings but none arrived through the callback — parking rather than reporting an empty list");
18440
+ log54.warn(TAG50, "the merge reported findings but none arrived through the callback — parking rather than reporting an empty list");
17900
18441
  deps.emit({
17901
18442
  type: "parked",
17902
18443
  reason: "the independent review posted blocking findings, but they could not be reported — the card stays in Review for a person"
@@ -17946,7 +18487,7 @@ async function branchRefGone(branchName, cwd) {
17946
18487
  const stderr = String(failure.stderr ?? "").trim();
17947
18488
  if (failure.code === 1 && !stderr)
17948
18489
  return true;
17949
- log53.warn(TAG49, `could not determine whether ${branchName} still exists (code=${String(failure.code)}${stderr ? `, ${stderr}` : ""}) — reporting it as possibly present`);
18490
+ log54.warn(TAG50, `could not determine whether ${branchName} still exists (code=${String(failure.code)}${stderr ? `, ${stderr}` : ""}) — reporting it as possibly present`);
17950
18491
  return false;
17951
18492
  }
17952
18493
  }
@@ -17957,7 +18498,7 @@ async function finish(deps) {
17957
18498
  const resolved = await resolvePrHeadBranch3(deps.prUrl, deps.cwd, deps.provider);
17958
18499
  const branch = resolved.kind === "branch" ? resolved.branch : null;
17959
18500
  if (resolved.kind === "skip") {
17960
- log53.warn(TAG49, `no head branch for ${deps.prUrl}: ${resolved.reason}`);
18501
+ log54.warn(TAG50, `no head branch for ${deps.prUrl}: ${resolved.reason}`);
17961
18502
  }
17962
18503
  const out = cleanupMergedWorktree({
17963
18504
  worktreePath,
@@ -17966,12 +18507,12 @@ async function finish(deps) {
17966
18507
  });
17967
18508
  if (!out.removed) {
17968
18509
  const reason = out.reason ?? "the worktree directory still exists after cleanup";
17969
- log53.warn(TAG49, `cleanup refused for ${worktreePath}: ${reason}`);
18510
+ log54.warn(TAG50, `cleanup refused for ${worktreePath}: ${reason}`);
17970
18511
  deps.emit({ type: "cleanup_refused", worktree: worktreePath, reason });
17971
18512
  } else {
17972
18513
  const branchGone = branch ? await branchRefGone(branch, deps.cwd) : true;
17973
18514
  if (branch && !branchGone) {
17974
- log53.warn(TAG49, `local branch ${branch} may still exist after cleanup`);
18515
+ log54.warn(TAG50, `local branch ${branch} may still exist after cleanup`);
17975
18516
  }
17976
18517
  deps.emit({
17977
18518
  type: "cleaned",
@@ -17981,7 +18522,7 @@ async function finish(deps) {
17981
18522
  }
17982
18523
  } catch (err) {
17983
18524
  const detail = err instanceof Error ? err.message : String(err);
17984
- log53.warn(TAG49, `worktree cleanup failed for ${worktreePath}: ${detail}`);
18525
+ log54.warn(TAG50, `worktree cleanup failed for ${worktreePath}: ${detail}`);
17985
18526
  deps.emit({
17986
18527
  type: "cleanup_refused",
17987
18528
  worktree: worktreePath,
@@ -17999,19 +18540,20 @@ async function finish(deps) {
17999
18540
  });
18000
18541
  if (!out.moved) {
18001
18542
  const reason = out.reason ?? "the card could not be moved out of Review";
18002
- log53.warn(TAG49, `board not updated after merge: ${reason}`);
18543
+ log54.warn(TAG50, `board not updated after merge: ${reason}`);
18003
18544
  deps.emit({ type: "board_incomplete", reason });
18004
18545
  }
18005
18546
  } catch (err) {
18006
18547
  const detail = err instanceof Error ? err.message : String(err);
18007
- log53.warn(TAG49, `board not updated after merge: ${detail}`);
18548
+ log54.warn(TAG50, `board not updated after merge: ${detail}`);
18008
18549
  deps.emit({ type: "board_incomplete", reason: detail });
18009
18550
  }
18010
18551
  }
18011
- var TAG49 = "merge-command", POLL_INTERVAL_MS = 30000, execFileAsync2, KEEP_WAITING, PARK_REASONS;
18552
+ var TAG50 = "merge-command", POLL_INTERVAL_MS = 30000, execFileAsync2, KEEP_WAITING, PARK_REASONS;
18012
18553
  var init_merge_command = __esm(() => {
18013
18554
  init_dist();
18014
18555
  init_auto_merge();
18556
+ init_head_anchor();
18015
18557
  init_merge_cleanup();
18016
18558
  init_merge_complete();
18017
18559
  execFileAsync2 = promisify2(execFile2);
@@ -18033,10 +18575,118 @@ var init_merge_command = __esm(() => {
18033
18575
  };
18034
18576
  });
18035
18577
 
18578
+ // src/scan-commands.ts
18579
+ var exports_scan_commands = {};
18580
+ __export(exports_scan_commands, {
18581
+ scanCommandsCommand: () => scanCommandsCommand
18582
+ });
18583
+ import { existsSync as existsSync8, readFileSync as readFileSync10, writeFileSync as writeFileSync7 } from "node:fs";
18584
+ import { basename as basename2, join as join9 } from "node:path";
18585
+ import {
18586
+ REPO_COMMAND_KEYS,
18587
+ REPO_CONFIG_FILENAMES as REPO_CONFIG_FILENAMES2,
18588
+ renderScanProposal,
18589
+ scanRepoCommands
18590
+ } from "@gethmy/harness";
18591
+ async function scanCommandsCommand(opts = {}) {
18592
+ const cwd = opts.cwd ?? process.cwd();
18593
+ const out = opts.out ?? ((line) => process.stdout.write(`${line}
18594
+ `));
18595
+ const scan = await scanRepoCommands(cwd);
18596
+ out(renderScanProposal(scan));
18597
+ out("");
18598
+ if (!opts.write) {
18599
+ out(`Nothing was written. Re-run with --write to merge this into ${basename2(existingPinPath(cwd))}.`);
18600
+ return 0;
18601
+ }
18602
+ try {
18603
+ const path = writeProposal(cwd, scan);
18604
+ out(`Wrote the commands block to ${path}.`);
18605
+ out("Commit it: a pin git ignores exists in your working directory and in no worktree the daemon cuts.");
18606
+ return 0;
18607
+ } catch (err) {
18608
+ out(`Could not write ${existingPinPath(cwd)}: ${err instanceof Error ? err.message : err}`);
18609
+ return 1;
18610
+ }
18611
+ }
18612
+ function writeProposal(cwd, scan) {
18613
+ const path = existingPinPath(cwd);
18614
+ let existing = {};
18615
+ try {
18616
+ const parsed = JSON.parse(readFileSync10(path, "utf-8"));
18617
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
18618
+ existing = parsed;
18619
+ }
18620
+ } catch {}
18621
+ const merged = {
18622
+ ...existing,
18623
+ commands: { ...unscannedCommands(existing, scan), ...scan.proposal }
18624
+ };
18625
+ writeFileSync7(path, `${JSON.stringify(merged, null, indentOf(cwd))}
18626
+ `);
18627
+ return path;
18628
+ }
18629
+ function existingPinPath(cwd) {
18630
+ for (const name of REPO_CONFIG_FILENAMES2) {
18631
+ const candidate = join9(cwd, name);
18632
+ if (existsSync8(candidate))
18633
+ return candidate;
18634
+ }
18635
+ return join9(cwd, PIN_FILENAME);
18636
+ }
18637
+ function unscannedCommands(existing, scan) {
18638
+ const before = existing.commands;
18639
+ if (!before || typeof before !== "object" || Array.isArray(before))
18640
+ return {};
18641
+ const carried = {};
18642
+ for (const key of REPO_COMMAND_KEYS) {
18643
+ if (key in scan.proposal)
18644
+ continue;
18645
+ if (Object.hasOwn(before, key)) {
18646
+ carried[key] = before[key];
18647
+ }
18648
+ }
18649
+ return carried;
18650
+ }
18651
+ function indentOf(cwd) {
18652
+ for (const name of ["biome.json", "biome.jsonc"]) {
18653
+ let parsed;
18654
+ try {
18655
+ parsed = JSON.parse(readFileSync10(join9(cwd, name), "utf-8").replace(/^\s*\/\/.*$/gm, "").replace(/,(\s*[}\]])/g, "$1"));
18656
+ } catch {
18657
+ continue;
18658
+ }
18659
+ if (!parsed || typeof parsed !== "object")
18660
+ continue;
18661
+ const formatter = parsed.formatter;
18662
+ if (formatter?.indentStyle === "space") {
18663
+ const width = formatter.indentWidth;
18664
+ return typeof width === "number" && width > 0 ? width : 2;
18665
+ }
18666
+ return "\t";
18667
+ }
18668
+ try {
18669
+ const editorconfig = readFileSync10(join9(cwd, ".editorconfig"), "utf-8");
18670
+ if (/^\s*indent_style\s*=\s*tab\s*$/im.test(editorconfig))
18671
+ return "\t";
18672
+ const size = editorconfig.match(/^\s*indent_size\s*=\s*(\d+)\s*$/im);
18673
+ if (size) {
18674
+ const width = Number(size[1]);
18675
+ if (width > 0)
18676
+ return width;
18677
+ }
18678
+ } catch {}
18679
+ return 2;
18680
+ }
18681
+ var PIN_FILENAME;
18682
+ var init_scan_commands = __esm(() => {
18683
+ [PIN_FILENAME] = REPO_CONFIG_FILENAMES2;
18684
+ });
18685
+
18036
18686
  // src/cli.ts
18037
18687
  import { realpathSync as realpathSync4 } from "node:fs";
18038
18688
  import { fileURLToPath } from "node:url";
18039
- import { log as log54 } from "@gethmy/harness";
18689
+ import { log as log55 } from "@gethmy/harness";
18040
18690
  var USAGE = `
18041
18691
  Harmony Agent — push-based daemon + ops toolkit.
18042
18692
 
@@ -18058,6 +18708,11 @@ Usage:
18058
18708
  to .hmy.json. The old file is KEPT. Exit 1
18059
18709
  means the preflight failed OR --fix was
18060
18710
  refused; the message says which.
18711
+ harmony-agent scan-commands [--write]
18712
+ Propose this repo's .hmy.json commands block
18713
+ by RUNNING each candidate. Prints only;
18714
+ --write merges it into the pin. A repo with
18715
+ no dev server is a clean result, not an error.
18061
18716
  harmony-agent gc One-shot worktree garbage collection
18062
18717
  harmony-agent recover One-shot recovery of stranded Review cards
18063
18718
  harmony-agent merge --card N [--worktree PATH]
@@ -18293,7 +18948,10 @@ async function doctorCommand() {
18293
18948
  const fixCode = process.argv.includes("--fix") ? await fixLocalPinCommand() : 0;
18294
18949
  try {
18295
18950
  const config = loadDaemonConfig2();
18296
- validateAutoMergeConfig2(config.agent);
18951
+ for (const warning of validateAutoMergeConfig2(config.agent, config.repoAgentPaths, config.repoArmedGates)) {
18952
+ process.stderr.write(`preflight warning: ${warning}
18953
+ `);
18954
+ }
18297
18955
  validateBudgetConfig2(config.agent);
18298
18956
  validateSweepConfig2(config.agent);
18299
18957
  validateRankingConfig2(config.agent);
@@ -18311,11 +18969,11 @@ async function doctorCommand() {
18311
18969
  }
18312
18970
  }
18313
18971
  async function openStateStoreForOps() {
18314
- const { existsSync: existsSync8 } = await import("node:fs");
18972
+ const { existsSync: existsSync9 } = await import("node:fs");
18315
18973
  const { resolveProjectIdForOps: resolveProjectIdForOps2 } = await Promise.resolve().then(() => (init_config(), exports_config));
18316
18974
  const { defaultStatePath: defaultStatePath2, StateStore: StateStore2 } = await Promise.resolve().then(() => (init_state_store(), exports_state_store));
18317
18975
  const projectId = resolveProjectIdForOps2();
18318
- if (projectId && existsSync8(defaultStatePath2(projectId))) {
18976
+ if (projectId && existsSync9(defaultStatePath2(projectId))) {
18319
18977
  return StateStore2.open({ projectId });
18320
18978
  }
18321
18979
  return StateStore2.open();
@@ -18418,6 +19076,10 @@ merge: accepts ${[...MERGE_FLAGS].join(" ")}
18418
19076
  }
18419
19077
  const { loadDaemonConfig: loadDaemonConfig2, createApiClient: createApiClient2 } = await Promise.resolve().then(() => (init_config(), exports_config));
18420
19078
  const config = loadDaemonConfig2();
19079
+ for (const warning of config.configWarnings) {
19080
+ process.stderr.write(`merge: warning: ${warning}
19081
+ `);
19082
+ }
18421
19083
  if (!config.agent.review.autoMerge.interactive) {
18422
19084
  process.stdout.write(`${JSON.stringify({ type: "disabled" })}
18423
19085
  `);
@@ -18425,7 +19087,10 @@ merge: accepts ${[...MERGE_FLAGS].join(" ")}
18425
19087
  }
18426
19088
  const { validateAutoMergeConfig: validateAutoMergeConfig2, ConfigValidationError: ConfigValidationError2 } = await Promise.resolve().then(() => (init_config_validation(), exports_config_validation));
18427
19089
  try {
18428
- validateAutoMergeConfig2(config.agent);
19090
+ for (const warning of validateAutoMergeConfig2(config.agent, config.repoAgentPaths, config.repoArmedGates)) {
19091
+ process.stderr.write(`merge: warning: ${warning}
19092
+ `);
19093
+ }
18429
19094
  } catch (err) {
18430
19095
  if (!(err instanceof ConfigValidationError2))
18431
19096
  throw err;
@@ -18601,8 +19266,8 @@ async function runsShowCommand(rest) {
18601
19266
  `);
18602
19267
  let content;
18603
19268
  try {
18604
- const { readFileSync: readFileSync9 } = await import("node:fs");
18605
- content = readFileSync9(logFile.path, "utf-8");
19269
+ const { readFileSync: readFileSync11 } = await import("node:fs");
19270
+ content = readFileSync11(logFile.path, "utf-8");
18606
19271
  } catch (err) {
18607
19272
  process.stderr.write(`could not read log: ${err instanceof Error ? err.message : err}
18608
19273
  `);
@@ -18765,6 +19430,10 @@ async function dispatch(argv) {
18765
19430
  return healthCommand();
18766
19431
  case "doctor":
18767
19432
  return doctorCommand();
19433
+ case "scan-commands": {
19434
+ const { scanCommandsCommand: scanCommandsCommand2 } = await Promise.resolve().then(() => (init_scan_commands(), exports_scan_commands));
19435
+ return scanCommandsCommand2({ write: args.includes("--write") });
19436
+ }
18768
19437
  case "gc":
18769
19438
  return gcCommand();
18770
19439
  case "recover":
@@ -18803,7 +19472,7 @@ if (isMainModule()) {
18803
19472
  if (code !== 0)
18804
19473
  process.exit(code);
18805
19474
  }).catch((err) => {
18806
- log54.error("cli", err instanceof Error ? err.message : String(err));
19475
+ log55.error("cli", err instanceof Error ? err.message : String(err));
18807
19476
  process.exit(1);
18808
19477
  });
18809
19478
  }