@davesheffer/hunch 1.45.1 → 1.46.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/dist/cli/index.js +165 -33
  2. package/dist/cli/invocation.d.ts +14 -0
  3. package/dist/cli/invocation.js +23 -0
  4. package/dist/cli/serve.d.ts +2 -0
  5. package/dist/cli/serve.js +2 -1
  6. package/dist/cli/verify.d.ts +28 -0
  7. package/dist/cli/verify.js +265 -0
  8. package/dist/constitution/experiment.d.ts +1 -1
  9. package/dist/core/agenthook.d.ts +1 -0
  10. package/dist/core/agenthook.js +82 -26
  11. package/dist/core/boundedSpawn.d.ts +57 -0
  12. package/dist/core/boundedSpawn.js +321 -0
  13. package/dist/core/config.d.ts +6 -0
  14. package/dist/core/config.js +1 -0
  15. package/dist/core/delivery.d.ts +10 -0
  16. package/dist/core/delivery.js +117 -9
  17. package/dist/core/migrate.d.ts +10 -1
  18. package/dist/core/migrate.js +17 -6
  19. package/dist/core/pipeline.d.ts +9 -2
  20. package/dist/core/pipeline.js +13 -3
  21. package/dist/core/procstart.d.ts +17 -3
  22. package/dist/core/procstart.js +26 -5
  23. package/dist/core/projectDna.js +11 -1
  24. package/dist/core/relativeImports.js +6 -2
  25. package/dist/core/taskSelection.d.ts +1 -0
  26. package/dist/core/taskSelection.js +132 -11
  27. package/dist/core/testSelection.d.ts +122 -0
  28. package/dist/core/testSelection.js +588 -0
  29. package/dist/core/whyRecord.d.ts +47 -0
  30. package/dist/core/whyRecord.js +105 -0
  31. package/dist/extractors/git.d.ts +32 -0
  32. package/dist/extractors/git.js +206 -94
  33. package/dist/extractors/indexer.js +42 -6
  34. package/dist/extractors/languages.d.ts +9 -0
  35. package/dist/extractors/languages.js +28 -0
  36. package/dist/extractors/parse.d.ts +20 -0
  37. package/dist/extractors/parse.js +81 -5
  38. package/dist/extractors/workspaces.js +3 -1
  39. package/dist/integrations/health.d.ts +6 -0
  40. package/dist/integrations/health.js +33 -12
  41. package/dist/integrations/hooks.d.ts +4 -2
  42. package/dist/integrations/hooks.js +12 -2
  43. package/dist/integrations/mergeDriver.js +5 -3
  44. package/dist/integrations/optout.d.ts +3 -0
  45. package/dist/integrations/optout.js +17 -0
  46. package/dist/integrations/providers.d.ts +6 -2
  47. package/dist/integrations/providers.js +19 -2
  48. package/dist/integrations/team.js +6 -4
  49. package/dist/integrations/uninstall.d.ts +17 -4
  50. package/dist/integrations/uninstall.js +35 -13
  51. package/dist/mcp/server.js +179 -30
  52. package/dist/serve/app.js +9 -1
  53. package/dist/serve/config.js +5 -0
  54. package/dist/serve/writelock.d.ts +6 -5
  55. package/dist/serve/writelock.js +26 -21
  56. package/dist/store/hunchStore.d.ts +12 -1
  57. package/dist/store/hunchStore.js +86 -4
  58. package/dist/store/jsonStore.d.ts +24 -4
  59. package/dist/store/jsonStore.js +75 -26
  60. package/dist/store/stateBinding.js +17 -4
  61. package/dist/synthesis/provider.js +26 -78
  62. package/dist/synthesis/synthesize.js +1 -1
  63. package/package.json +2 -2
  64. package/server.json +2 -2
@@ -7,6 +7,7 @@ import { isAbsolute, resolve, join, basename, dirname, relative, sep, posix } fr
7
7
  import { mkdtempSync, openSync, closeSync, readSync, mkdirSync, rmSync, statSync, lstatSync, realpathSync, readFileSync, renameSync, readdirSync, existsSync } from "node:fs";
8
8
  import { fileURLToPath } from "node:url";
9
9
  import { MEMLOG_FORMAT } from "../core/memorylog.js";
10
+ import { boundedExecFileSync } from "../core/boundedSpawn.js";
10
11
  import { writeFileAtomic } from "../core/io.js";
11
12
  import { hunchAttributesAreSafe, hunchTreeAttributesAreSafe, safeOverlayTree } from "../core/overlaySafety.js";
12
13
  import { createRepoFileReader } from "../core/safeRepoFile.js";
@@ -154,10 +155,11 @@ function headShaWithEnv(cwd, env) {
154
155
  function gitSafeWithEnv(args, cwd, env) {
155
156
  try {
156
157
  return execFileSync("git", ["-C", cwd, ...args], {
157
- encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], env, timeout: 2_000,
158
+ encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], env, timeout: pullLocalGitTimeoutMs(),
158
159
  }).trim();
159
160
  }
160
- catch {
161
+ catch (error) {
162
+ notePullGitError(error);
161
163
  return "";
162
164
  }
163
165
  }
@@ -223,6 +225,60 @@ function clearStrandedIndexLock(repoDir, env, sinceMs, error) {
223
225
  return false; // no lock present, or git itself unavailable — nothing to heal
224
226
  }
225
227
  }
228
+ /** Why the last pull-path git call failed (issue #482). The bounded helpers swallow
229
+ * their errors into booleans; they also note the error here so mergeRemote can attach
230
+ * it to the failing step. Synchronous code only, so a module slot is race-free. */
231
+ let lastPullGitError;
232
+ function notePullGitError(error) { lastPullGitError = error; }
233
+ /** Captured git stderr can echo a remote URL with embedded credentials; strip the
234
+ * userinfo (`://user:pass@`, `://token@`) before it is stored or printed. */
235
+ export function redactUrlUserinfo(text) {
236
+ return text.replace(/:\/\/[^\s/@'"]+@/g, "://***@");
237
+ }
238
+ function pullFailureDetail(step, error) {
239
+ const detail = { step };
240
+ if (!error || typeof error !== "object")
241
+ return detail;
242
+ const err = error;
243
+ if (typeof err.code === "string" && err.code)
244
+ detail.code = err.code;
245
+ else if (typeof err.signal === "string" && err.signal)
246
+ detail.code = err.signal;
247
+ else if (typeof err.status === "number")
248
+ detail.code = `exit ${err.status}`;
249
+ const stderr = (typeof err.stderr === "string" ? err.stderr : Buffer.isBuffer(err.stderr) ? err.stderr.toString("utf8") : "").trim();
250
+ if (stderr)
251
+ detail.stderr = redactUrlUserinfo(stderr).slice(-400);
252
+ return detail;
253
+ }
254
+ /** One-line rendering for logs and refusal messages: `step: fetch, ETIMEDOUT, fatal: ...`. */
255
+ export function describePullFailure(detail) {
256
+ if (!detail)
257
+ return "";
258
+ const firstLine = detail.stderr?.split(/\r?\n/).find((line) => line.trim())?.trim();
259
+ return [`step: ${detail.step}`, detail.code, firstLine].filter(Boolean).join(", ");
260
+ }
261
+ /** HUNCH_PULL_GIT_TIMEOUT_MS: per-process override of the read-side pull's DEFAULT
262
+ * remote budget (ls-remote/fetch/merge) for slow runners — a caller's explicit
263
+ * timeoutMs still wins; ignored unless a positive integer. */
264
+ export function pullGitTimeoutOverride() {
265
+ const raw = process.env.HUNCH_PULL_GIT_TIMEOUT_MS?.trim();
266
+ if (!raw || !/^\d+$/.test(raw))
267
+ return undefined;
268
+ const value = Number(raw);
269
+ return value > 0 ? Math.min(600_000, value) : undefined;
270
+ }
271
+ /** Remote budget for one read-side pull: an explicit caller budget always wins;
272
+ * HUNCH_PULL_GIT_TIMEOUT_MS only replaces the 5s default. */
273
+ export function pullRemoteTimeoutMs(callerMs) {
274
+ return callerMs ?? pullGitTimeoutOverride() ?? DEFAULT_READ_REMOTE_TIMEOUT_MS;
275
+ }
276
+ /** Budget for the bounded local git helpers (status, rev-parse, ls-tree): 2s by
277
+ * default, scaled with HUNCH_PULL_GIT_TIMEOUT_MS to max(2s, override / 2.5). */
278
+ export function pullLocalGitTimeoutMs() {
279
+ const override = pullGitTimeoutOverride();
280
+ return override === undefined ? 2_000 : Math.max(2_000, Math.round(override / 2.5));
281
+ }
226
282
  /** Compare physical directory identity before path text. Git for Windows can
227
283
  * return an 8.3/short or differently-cased spelling for the same top-level
228
284
  * directory that Node reached through its long path. A nonzero file ID keeps
@@ -525,7 +581,9 @@ function observeGit(observer, event) {
525
581
  catch { /* observation cannot change a Git outcome */ }
526
582
  }
527
583
  const CAPTURE_REMOTE_TIMEOUT_MS = 15_000;
528
- const READ_REMOTE_TIMEOUT_MS = 5_000;
584
+ /** A user's whole branch can be large, so `hunch push` gets far longer than a memory capture push. */
585
+ const BRANCH_PUSH_TIMEOUT_MS = 300_000;
586
+ export const DEFAULT_READ_REMOTE_TIMEOUT_MS = 5_000;
529
587
  // A capture can spend roughly 90s in commit + bounded merge/push/retry seams.
530
588
  // A contending writer waits beyond that proven ceiling, then takes the lock and
531
589
  // drains every already-durable JSON write itself. This removes the old "maybe a
@@ -950,10 +1008,11 @@ function hunchWorktreeClean(hunchDir, env) {
950
1008
  encoding: "utf8",
951
1009
  stdio: ["ignore", "pipe", "ignore"],
952
1010
  env,
953
- timeout: 2_000,
1011
+ timeout: pullLocalGitTimeoutMs(),
954
1012
  }).trim() === "";
955
1013
  }
956
- catch {
1014
+ catch (error) {
1015
+ notePullGitError(error);
957
1016
  return false;
958
1017
  }
959
1018
  }
@@ -965,7 +1024,7 @@ function overlayGitTreeIsSafe(hunchDir, revision, env) {
965
1024
  encoding: "utf8",
966
1025
  stdio: ["ignore", "pipe", "ignore"],
967
1026
  env,
968
- timeout: 2_000,
1027
+ timeout: pullLocalGitTimeoutMs(),
969
1028
  maxBuffer: 64 * 1024 * 1024,
970
1029
  });
971
1030
  return hunchTreeAttributesAreSafe(listing, (oid) => {
@@ -974,16 +1033,18 @@ function overlayGitTreeIsSafe(hunchDir, revision, env) {
974
1033
  encoding: "utf8",
975
1034
  stdio: ["ignore", "pipe", "ignore"],
976
1035
  env,
977
- timeout: 2_000,
1036
+ timeout: pullLocalGitTimeoutMs(),
978
1037
  maxBuffer: 4 * 1024 * 1024,
979
1038
  });
980
1039
  }
981
- catch {
1040
+ catch (error) {
1041
+ notePullGitError(error);
982
1042
  return null;
983
1043
  }
984
1044
  });
985
1045
  }
986
- catch {
1046
+ catch (error) {
1047
+ notePullGitError(error);
987
1048
  return false;
988
1049
  }
989
1050
  }
@@ -1135,16 +1196,13 @@ function contractRemoteHeads(hunchDir, contract, direction, env, timeoutMs) {
1135
1196
  if (!hooksDir)
1136
1197
  return null;
1137
1198
  try {
1138
- const out = execFileSync("git", [
1199
+ // Network git runs under boundedExecFileSync: execFileSync's timeout kills only
1200
+ // the root, orphaning git-remote-https.exe on Windows (issue #498).
1201
+ const out = boundedExecFileSync("git", [
1139
1202
  "-C", hunchDir,
1140
1203
  "-c", `core.hooksPath=${hooksDir}`,
1141
1204
  "ls-remote", "--refs", "--heads", "--upload-pack=git-upload-pack", url,
1142
- ], {
1143
- encoding: "utf8",
1144
- stdio: ["ignore", "pipe", "ignore"],
1145
- env: boundedTeamEnv(env),
1146
- timeout: timeoutMs,
1147
- });
1205
+ ], { env: boundedTeamEnv(env), timeout: timeoutMs });
1148
1206
  if (!contractReady(contract))
1149
1207
  return null;
1150
1208
  const heads = [];
@@ -1156,7 +1214,8 @@ function contractRemoteHeads(hunchDir, contract, direction, env, timeoutMs) {
1156
1214
  }
1157
1215
  return heads;
1158
1216
  }
1159
- catch {
1217
+ catch (error) {
1218
+ notePullGitError(error);
1160
1219
  return null;
1161
1220
  }
1162
1221
  }
@@ -1167,27 +1226,28 @@ function fetchContractRef(hunchDir, contract, env, timeoutMs) {
1167
1226
  if (!hooksDir)
1168
1227
  return "";
1169
1228
  try {
1170
- execFileSync("git", [
1229
+ boundedExecFileSync("git", [
1171
1230
  "-C", hunchDir,
1172
1231
  "-c", `core.hooksPath=${hooksDir}`,
1173
1232
  "fetch", "--no-tags", "--no-write-fetch-head", "--upload-pack=git-upload-pack",
1174
1233
  contract.fetchUrl, `+${contract.ref}:${TEAM_FETCH_REF}`,
1175
- ], { stdio: "ignore", env: boundedTeamEnv(env), timeout: timeoutMs });
1234
+ ], { stdout: "ignore", env: boundedTeamEnv(env), timeout: timeoutMs });
1176
1235
  if (!contractReady(contract))
1177
1236
  return "";
1178
1237
  const oid = execFileSync("git", ["-C", hunchDir, "rev-parse", "--verify", TEAM_FETCH_REF], {
1179
- encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], env, timeout: 2_000,
1238
+ encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], env, timeout: pullLocalGitTimeoutMs(),
1180
1239
  }).trim();
1181
1240
  return /^[0-9a-f]{40,64}$/i.test(oid) ? oid : "";
1182
1241
  }
1183
- catch {
1242
+ catch (error) {
1243
+ notePullGitError(error);
1184
1244
  return "";
1185
1245
  }
1186
1246
  }
1187
1247
  function setContractUpstream(hunchDir, contract, env) {
1188
1248
  try {
1189
1249
  const branch = execFileSync("git", ["-C", hunchDir, "symbolic-ref", "--quiet", "--short", "HEAD"], {
1190
- encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], env, timeout: 2_000,
1250
+ encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], env, timeout: pullLocalGitTimeoutMs(),
1191
1251
  }).trim();
1192
1252
  if (!branch)
1193
1253
  return false;
@@ -1198,13 +1258,14 @@ function setContractUpstream(hunchDir, contract, env) {
1198
1258
  if (!oid || !remoteBranch)
1199
1259
  return false;
1200
1260
  execFileSync("git", ["-C", hunchDir, "update-ref", `refs/remotes/origin/${remoteBranch}`, oid], {
1201
- stdio: "ignore", env, timeout: 2_000,
1261
+ stdio: "ignore", env, timeout: pullLocalGitTimeoutMs(),
1202
1262
  });
1203
- execFileSync("git", ["-C", hunchDir, "config", `branch.${branch}.remote`, "origin"], { stdio: "ignore", env, timeout: 2_000 });
1204
- execFileSync("git", ["-C", hunchDir, "config", `branch.${branch}.merge`, contract.ref], { stdio: "ignore", env, timeout: 2_000 });
1263
+ execFileSync("git", ["-C", hunchDir, "config", `branch.${branch}.remote`, "origin"], { stdio: "ignore", env, timeout: pullLocalGitTimeoutMs() });
1264
+ execFileSync("git", ["-C", hunchDir, "config", `branch.${branch}.merge`, contract.ref], { stdio: "ignore", env, timeout: pullLocalGitTimeoutMs() });
1205
1265
  return contractReady(contract);
1206
1266
  }
1207
- catch {
1267
+ catch (error) {
1268
+ notePullGitError(error);
1208
1269
  return false;
1209
1270
  }
1210
1271
  }
@@ -1215,7 +1276,7 @@ function setContractUpstream(hunchDir, contract, env) {
1215
1276
  function unbornBootstrapFingerprint(hunchDir, env) {
1216
1277
  try {
1217
1278
  const status = execFileSync("git", ["-C", hunchDir, "status", "--porcelain=v1", "--untracked-files=all", "--", "."], {
1218
- encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], env, timeout: 2_000,
1279
+ encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], env, timeout: pullLocalGitTimeoutMs(),
1219
1280
  }).trim();
1220
1281
  const lines = status ? status.split(/\r?\n/) : [];
1221
1282
  if (lines.some((line) => !/^\?\? (?:\.hunch\/)?manifest\.json$/.test(line.trim())))
@@ -1235,7 +1296,8 @@ function unbornBootstrapFingerprint(hunchDir, env) {
1235
1296
  }
1236
1297
  return createHash("sha256").update(status).update("\0").update(manifest).digest("hex");
1237
1298
  }
1238
- catch {
1299
+ catch (error) {
1300
+ notePullGitError(error);
1239
1301
  return null;
1240
1302
  }
1241
1303
  }
@@ -1261,12 +1323,13 @@ function adoptContractHead(hunchDir, fetchedHead, contract, env, fingerprint) {
1261
1323
  "-c", "core.autocrlf=false",
1262
1324
  "reset", "--hard", fetchedHead,
1263
1325
  ], {
1264
- stdio: "ignore", env, timeout: 5_000,
1326
+ stdio: ["ignore", "ignore", "pipe"], env, timeout: 5_000,
1265
1327
  });
1266
1328
  return overlayGitTreeIsSafe(hunchDir, headShaWithEnv(hunchDir, env), env)
1267
1329
  && overlayAttributeSourcesAreSafe(hunchDir, env);
1268
1330
  }
1269
- catch {
1331
+ catch (error) {
1332
+ notePullGitError(error);
1270
1333
  if (moved) {
1271
1334
  try {
1272
1335
  renameSync(backup, manifest);
@@ -1283,43 +1346,70 @@ function adoptContractHead(hunchDir, fetchedHead, contract, env, fingerprint) {
1283
1346
  catch { /* lock cleanup is the final backstop */ }
1284
1347
  }
1285
1348
  }
1286
- function mergeRemote(hunchDir, env, timeoutMs, contract, allowUnrelatedHistories = false) {
1349
+ function mergeRemote(hunchDir, env, timeoutMs, contract, allowUnrelatedHistories = false, diag) {
1287
1350
  env = machineCommitEnv(hunchDir, env);
1288
1351
  env.GIT_ATTR_NOSYSTEM = "1";
1352
+ const failed = (step, error = lastPullGitError) => {
1353
+ if (diag)
1354
+ diag.detail = pullFailureDetail(step, error);
1355
+ return "failed";
1356
+ };
1357
+ // First failing step of an ordered conjunction; each check starts from a clean
1358
+ // error slot so the noted git error belongs to the step that failed.
1359
+ const firstFailure = (checks) => {
1360
+ for (const [step, ok] of checks) {
1361
+ lastPullGitError = undefined;
1362
+ if (!ok())
1363
+ return step;
1364
+ }
1365
+ return null;
1366
+ };
1289
1367
  let overlayRoot = "";
1290
1368
  try {
1291
1369
  overlayRoot = dirname(realpathSync(hunchDir));
1292
1370
  }
1293
- catch {
1294
- return "failed";
1295
- }
1296
- if (!isGitRepoRoot(overlayRoot) || !safeOverlayTree(overlayRoot)
1297
- || !overlayAttributeSourcesAreSafe(hunchDir, env) || !contractReady(contract)) {
1298
- return "failed";
1371
+ catch (error) {
1372
+ return failed("resolve-overlay", error);
1299
1373
  }
1374
+ let bad = firstFailure([
1375
+ ["overlay-repo-root", () => isGitRepoRoot(overlayRoot)],
1376
+ ["overlay-tree-safe", () => safeOverlayTree(overlayRoot)],
1377
+ ["overlay-attributes-safe", () => overlayAttributeSourcesAreSafe(hunchDir, env)],
1378
+ ["remote-contract", () => contractReady(contract)],
1379
+ ]);
1380
+ if (bad)
1381
+ return failed(bad);
1382
+ lastPullGitError = undefined;
1300
1383
  const localHead = headShaWithEnv(hunchDir, env);
1301
1384
  const unbornFingerprint = localHead ? null : unbornBootstrapFingerprint(hunchDir, env);
1302
- if (localHead ? !hunchWorktreeClean(hunchDir, env) : !unbornFingerprint)
1303
- return "failed";
1385
+ if (localHead ? !hunchWorktreeClean(hunchDir, env) : !unbornFingerprint) {
1386
+ return failed(localHead ? "worktree-clean" : "unborn-bootstrap");
1387
+ }
1304
1388
  const hooksDir = disabledHooksDir(hunchDir);
1305
1389
  if (!hooksDir)
1306
- return "failed";
1390
+ return failed("hooks-dir");
1307
1391
  const tryGit = (args, timeout = timeoutMs) => {
1308
1392
  const startedAt = Date.now();
1393
+ lastPullGitError = undefined;
1394
+ const argv = [
1395
+ "-C", hunchDir,
1396
+ "-c", `core.hooksPath=${hooksDir}`,
1397
+ "-c", `core.attributesFile=${gitNullDevice()}`,
1398
+ "-c", "core.autocrlf=false",
1399
+ "-c", "commit.gpgsign=false",
1400
+ ...args,
1401
+ ];
1309
1402
  try {
1310
- execFileSync("git", [
1311
- "-C", hunchDir,
1312
- "-c", `core.hooksPath=${hooksDir}`,
1313
- "-c", `core.attributesFile=${gitNullDevice()}`,
1314
- "-c", "core.autocrlf=false",
1315
- "-c", "commit.gpgsign=false",
1316
- ...args,
1317
- ], {
1318
- stdio: "ignore", env, timeout,
1319
- });
1403
+ // Only the network call pays for the tree-killing supervisor (#498); the
1404
+ // merge of an already-fetched sha and rev-parse/abort stay local.
1405
+ if (args[0] === "fetch")
1406
+ boundedExecFileSync("git", argv, { stdout: "ignore", env, timeout });
1407
+ else
1408
+ execFileSync("git", argv, { stdio: ["ignore", "ignore", "pipe"], env, timeout });
1320
1409
  return true;
1321
1410
  }
1322
1411
  catch (error) {
1412
+ notePullGitError(error);
1323
1413
  // Same stranding class as the commit path: a timeout-killed merge/fetch
1324
1414
  // leaves index.lock behind and wedges every later sync (issue #53).
1325
1415
  clearStrandedIndexLock(hunchDir, env, startedAt, error);
@@ -1328,51 +1418,67 @@ function mergeRemote(hunchDir, env, timeoutMs, contract, allowUnrelatedHistories
1328
1418
  };
1329
1419
  let fetchedHead = "";
1330
1420
  if (contract) {
1421
+ lastPullGitError = undefined;
1331
1422
  const heads = contractRemoteHeads(hunchDir, contract, "fetch", env, timeoutMs);
1332
1423
  if (!heads)
1333
- return "failed";
1424
+ return failed("ls-remote");
1334
1425
  if (heads.length === 0)
1335
1426
  return "unconfigured";
1336
1427
  if (heads.length !== 1 || heads[0].ref !== contract.ref)
1337
- return "failed";
1428
+ return failed("remote-heads", null);
1429
+ lastPullGitError = undefined;
1338
1430
  fetchedHead = fetchContractRef(hunchDir, contract, env, timeoutMs);
1431
+ if (!fetchedHead)
1432
+ return failed("fetch");
1339
1433
  }
1340
1434
  else {
1341
- if (!tryGit(["rev-parse", "--abbrev-ref", "@{upstream}"], 2_000))
1435
+ if (!tryGit(["rev-parse", "--abbrev-ref", "@{upstream}"], pullLocalGitTimeoutMs()))
1342
1436
  return "unconfigured";
1343
1437
  if (!tryGit(["fetch", "--no-tags"]))
1344
- return "failed";
1438
+ return failed("fetch");
1439
+ lastPullGitError = undefined;
1345
1440
  fetchedHead = upstreamSha(hunchDir, env);
1441
+ if (!fetchedHead)
1442
+ return failed("upstream-sha");
1346
1443
  }
1347
- if (!fetchedHead || !overlayGitTreeIsSafe(hunchDir, fetchedHead, env))
1348
- return "failed";
1444
+ bad = firstFailure([["fetched-tree-safe", () => overlayGitTreeIsSafe(hunchDir, fetchedHead, env)]]);
1445
+ if (bad)
1446
+ return failed(bad);
1349
1447
  // Another process need not honor Hunch's lock. Re-prove both the exact local
1350
1448
  // revision and the filesystem boundary after the network operation, before
1351
1449
  // Git is permitted to materialize the already-validated fetched tree.
1352
- if (headShaWithEnv(hunchDir, env) !== localHead
1353
- || !safeOverlayTree(overlayRoot)
1354
- || !overlayAttributeSourcesAreSafe(hunchDir, env)
1355
- || (localHead ? !hunchWorktreeClean(hunchDir, env) : unbornBootstrapFingerprint(hunchDir, env) !== unbornFingerprint)
1356
- || (localHead && !overlayGitTreeIsSafe(hunchDir, localHead, env))
1357
- || !contractReady(contract))
1358
- return "failed";
1450
+ bad = firstFailure([
1451
+ ["local-head-unchanged", () => headShaWithEnv(hunchDir, env) === localHead],
1452
+ ["overlay-tree-safe-after-fetch", () => safeOverlayTree(overlayRoot)],
1453
+ ["overlay-attributes-safe-after-fetch", () => overlayAttributeSourcesAreSafe(hunchDir, env)],
1454
+ [localHead ? "worktree-clean-after-fetch" : "unborn-bootstrap-after-fetch",
1455
+ () => (localHead ? hunchWorktreeClean(hunchDir, env) : unbornBootstrapFingerprint(hunchDir, env) === unbornFingerprint)],
1456
+ ["local-tree-safe", () => !localHead || overlayGitTreeIsSafe(hunchDir, localHead, env)],
1457
+ ["remote-contract-after-fetch", () => contractReady(contract)],
1458
+ ]);
1459
+ if (bad)
1460
+ return failed(bad);
1359
1461
  // Canonicalize local branch/upstream metadata before the worktree mutation.
1360
1462
  // If this fails, the exact fetched tree has not been materialized yet.
1463
+ lastPullGitError = undefined;
1361
1464
  if (contract && !setContractUpstream(hunchDir, contract, env))
1362
- return "failed";
1465
+ return failed("set-upstream");
1363
1466
  if (!localHead && contract && unbornFingerprint) {
1364
- return adoptContractHead(hunchDir, fetchedHead, contract, env, unbornFingerprint) ? "merged" : "failed";
1467
+ lastPullGitError = undefined;
1468
+ return adoptContractHead(hunchDir, fetchedHead, contract, env, unbornFingerprint) ? "merged" : failed("adopt-remote-head");
1365
1469
  }
1366
1470
  if (tryGit(["merge", "--no-edit", ...(allowUnrelatedHistories ? ["--allow-unrelated-histories"] : []), fetchedHead])) {
1367
- return safeOverlayTree(overlayRoot)
1368
- && overlayAttributeSourcesAreSafe(hunchDir, env)
1369
- && contractReady(contract)
1370
- && overlayGitTreeIsSafe(hunchDir, headShaWithEnv(hunchDir, env), env)
1371
- ? "merged"
1372
- : "failed";
1373
- }
1374
- tryGit(["merge", "--abort"], 2_000); // conflict/timeout → restore a clean tree
1375
- return "failed";
1471
+ bad = firstFailure([
1472
+ ["overlay-tree-safe-after-merge", () => safeOverlayTree(overlayRoot)],
1473
+ ["overlay-attributes-safe-after-merge", () => overlayAttributeSourcesAreSafe(hunchDir, env)],
1474
+ ["remote-contract-after-merge", () => contractReady(contract)],
1475
+ ["merged-tree-safe", () => overlayGitTreeIsSafe(hunchDir, headShaWithEnv(hunchDir, env), env)],
1476
+ ]);
1477
+ return bad ? failed(bad) : "merged";
1478
+ }
1479
+ const mergeError = lastPullGitError;
1480
+ tryGit(["merge", "--abort"], pullLocalGitTimeoutMs()); // conflict/timeout → restore a clean tree
1481
+ return failed("merge", mergeError);
1376
1482
  }
1377
1483
  function upstreamSha(hunchDir, env) {
1378
1484
  try {
@@ -1380,10 +1486,11 @@ function upstreamSha(hunchDir, env) {
1380
1486
  encoding: "utf8",
1381
1487
  stdio: ["ignore", "pipe", "ignore"],
1382
1488
  env,
1383
- timeout: 2_000,
1489
+ timeout: pullLocalGitTimeoutMs(),
1384
1490
  }).trim();
1385
1491
  }
1386
- catch {
1492
+ catch (error) {
1493
+ notePullGitError(error);
1387
1494
  return "";
1388
1495
  }
1389
1496
  }
@@ -1393,7 +1500,7 @@ function upstreamSha(hunchDir, env) {
1393
1500
  * and discard oversized/unavailable output without changing its exit result. */
1394
1501
  function runObservedPush(args, env, timeoutMs, observe) {
1395
1502
  if (!observe) {
1396
- execFileSync("git", args, { stdio: "ignore", env, timeout: timeoutMs });
1503
+ boundedExecFileSync("git", args, { stdout: "ignore", env, timeout: timeoutMs });
1397
1504
  return null;
1398
1505
  }
1399
1506
  let directory, descriptor;
@@ -1408,11 +1515,11 @@ function runObservedPush(args, env, timeoutMs, observe) {
1408
1515
  }
1409
1516
  catch { }
1410
1517
  }
1411
- execFileSync("git", args, { stdio: "ignore", env, timeout: timeoutMs });
1518
+ boundedExecFileSync("git", args, { stdout: "ignore", env, timeout: timeoutMs });
1412
1519
  return null;
1413
1520
  }
1414
1521
  try {
1415
- execFileSync("git", args, { stdio: ["ignore", descriptor, "ignore"], env, timeout: timeoutMs });
1522
+ boundedExecFileSync("git", args, { stdout: descriptor, env, timeout: timeoutMs });
1416
1523
  try {
1417
1524
  const buffer = Buffer.alloc(1_000_001);
1418
1525
  const bytes = readSync(descriptor, buffer, 0, buffer.length, 0);
@@ -1460,8 +1567,8 @@ function observePushResult(hunchDir, env, output, observer) {
1460
1567
  if (!destination || /[\x00-\x1f\x7f]/.test(destination) || Date.now() >= deadline)
1461
1568
  continue;
1462
1569
  try {
1463
- const result = execFileSync("git", ["-C", hunchDir, "ls-remote", "--refs", "--exit-code", "--", destination, ref], {
1464
- env, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: Math.max(1, deadline - Date.now()), maxBuffer: 16_384,
1570
+ const result = boundedExecFileSync("git", ["-C", hunchDir, "ls-remote", "--refs", "--exit-code", "--", destination, ref], {
1571
+ env, timeout: Math.max(1, deadline - Date.now()), maxBuffer: 16_384,
1465
1572
  }).trim();
1466
1573
  const rows = result.split("\n").filter(row => row.split("\t")[1] === ref);
1467
1574
  const commitSha = rows.length === 1 ? rows[0].split("\t")[0] : "";
@@ -1553,12 +1660,7 @@ function establishEmptyRemoteUpstream(hunchDir, env, protectedRepoRoot, timeoutM
1553
1660
  if (!before)
1554
1661
  return false;
1555
1662
  try {
1556
- const refs = execFileSync("git", ["-C", hunchDir, "ls-remote", "--refs", remote], {
1557
- encoding: "utf8",
1558
- stdio: ["ignore", "pipe", "ignore"],
1559
- env,
1560
- timeout: timeoutMs,
1561
- }).trim();
1663
+ const refs = boundedExecFileSync("git", ["-C", hunchDir, "ls-remote", "--refs", remote], { env, timeout: timeoutMs }).trim();
1562
1664
  if (refs)
1563
1665
  return false;
1564
1666
  }
@@ -1614,6 +1716,11 @@ export function teamPullIsFresh(status) {
1614
1716
  return status === "updated" || status === "current" || status === "empty";
1615
1717
  }
1616
1718
  export function pullHunchStatus(hunchDir, opts = {}) {
1719
+ return pullHunchStatusDetailed(hunchDir, opts).status;
1720
+ }
1721
+ /** pullHunchStatus plus a diagnosable reason for a "failed" pull. The remote budget is
1722
+ * the caller's (default 5s) unless HUNCH_PULL_GIT_TIMEOUT_MS overrides it. */
1723
+ export function pullHunchStatusDetailed(hunchDir, opts = {}) {
1617
1724
  const env = foreignRepoEnv({
1618
1725
  ...process.env,
1619
1726
  ...opts.env,
@@ -1624,19 +1731,22 @@ export function pullHunchStatus(hunchDir, opts = {}) {
1624
1731
  const before = headShaWithEnv(hunchDir, env);
1625
1732
  const lock = join(hunchDir, ".hunch-commit.lock");
1626
1733
  if (acquireCommitLock(lock).state !== "acquired")
1627
- return "busy";
1734
+ return { status: "busy" };
1735
+ const diag = {};
1628
1736
  try {
1629
- const merged = mergeRemote(hunchDir, env, opts.timeoutMs ?? READ_REMOTE_TIMEOUT_MS, opts.remote, opts.allowUnrelatedHistories ?? false);
1737
+ const timeoutMs = pullRemoteTimeoutMs(opts.timeoutMs);
1738
+ const merged = mergeRemote(hunchDir, env, timeoutMs, opts.remote, opts.allowUnrelatedHistories ?? false, diag);
1630
1739
  if (merged === "failed" || merged === "unconfigured" || !contractReady(opts.remote)) {
1631
- if (merged !== "unconfigured" || !contractReady(opts.remote))
1632
- return "failed";
1740
+ if (merged !== "unconfigured" || !contractReady(opts.remote)) {
1741
+ return { status: "failed", detail: merged === "failed" ? diag.detail ?? { step: "merge-remote" } : { step: "remote-contract-after-pull" } };
1742
+ }
1633
1743
  // The advertised remote has no branch yet AND this clone never committed memory:
1634
1744
  // both sides are empty, so there is nothing to be stale against (#427). A clone
1635
1745
  // WITH memory against an empty remote stays "unconfigured" (unpublished, or the
1636
1746
  // remote lost its history) so strict guards keep failing closed on it.
1637
- return opts.remote && !before && !headShaWithEnv(hunchDir, env) ? "empty" : merged;
1747
+ return { status: opts.remote && !before && !headShaWithEnv(hunchDir, env) ? "empty" : merged };
1638
1748
  }
1639
- return headShaWithEnv(hunchDir, env) !== before ? "updated" : "current";
1749
+ return { status: headShaWithEnv(hunchDir, env) !== before ? "updated" : "current" };
1640
1750
  }
1641
1751
  finally {
1642
1752
  try {
@@ -2221,7 +2331,9 @@ export function memoryMoveDiff(sha, root) {
2221
2331
  * false when there is no upstream / offline / not a repo. */
2222
2332
  export function pushCurrentBranch(root) {
2223
2333
  try {
2224
- execFileSync("git", ["-C", root, "push"], { stdio: "ignore" });
2334
+ // Bounded so an unreachable remote can't hang the caller, and a timeout
2335
+ // kills git's whole tree, not just the root (#498).
2336
+ boundedExecFileSync("git", ["-C", root, "push"], { stdout: "ignore", timeout: BRANCH_PUSH_TIMEOUT_MS });
2225
2337
  return true;
2226
2338
  }
2227
2339
  catch {
@@ -10,7 +10,7 @@
10
10
  */
11
11
  import { readFileSync } from "node:fs";
12
12
  import { dirname, join, posix } from "node:path";
13
- import { parseSource, attributeCalls, attributeRelations, MAX_BODY_TEXT_CHARS } from "./parse.js";
13
+ import { parseSource, attributeCalls, attributeQualifiedCalls, attributeRelations, MAX_BODY_TEXT_CHARS } from "./parse.js";
14
14
  import { isParserLoadError } from "./nativeTreeSitter.js";
15
15
  import { extractHelmDirectives } from "./helm.js";
16
16
  import { extractK8sManifest, extractKustomization, isKustomizationFile, kustomizeNamespaces, namespacesCompatible } from "./k8sManifest.js";
@@ -332,7 +332,7 @@ export function scanRepo(store, root, opts = {}) {
332
332
  }
333
333
  fileSymbols.set(rel, idsInFile);
334
334
  fileSymbolIndexId.set(rel, symbolIndexId);
335
- perFileCalls.push({ file: rel, bySym: attributeCalls(parsed) });
335
+ perFileCalls.push({ file: rel, bySym: attributeCalls(parsed), qualified: attributeQualifiedCalls(parsed) });
336
336
  perFileImports.push({ file: rel, imports: parsed.imports });
337
337
  perFileRelations.push({ file: rel, bySym: attributeRelations(parsed) });
338
338
  if (languageFor(rel)?.id === "php") {
@@ -359,7 +359,7 @@ export function scanRepo(store, root, opts = {}) {
359
359
  if (langId === "php") {
360
360
  return resolvePhpImportTargets(file, spec, phpNamespaces.get(file) ?? null, phpUseDeclarations.get(file) ?? [], phpPsr4, trackedFiles);
361
361
  }
362
- return [resolveImport(file, spec, fileSymbols)].filter((target) => !!target);
362
+ return [resolveImport(file, spec, trackedFiles)].filter((target) => !!target);
363
363
  };
364
364
  const importedFiles = new Map(perFileImports.map(({ file, imports }) => {
365
365
  const targets = new Set(imports.flatMap((specifier) => resolveImportTargets(file, specifier)));
@@ -381,7 +381,7 @@ export function scanRepo(store, root, opts = {}) {
381
381
  edgeSeen.add(e.id);
382
382
  edges.push(e);
383
383
  };
384
- for (const { file, bySym } of perFileCalls) {
384
+ for (const { file, bySym, qualified } of perFileCalls) {
385
385
  // Most languages leave this unset and get "calls" — YAML's alias->anchor
386
386
  // references aren't function calls, so its LanguageSpec declares "references".
387
387
  const edgeType = languageFor(file)?.referenceEdgeType ?? "calls";
@@ -423,6 +423,42 @@ export function scanRepo(store, root, opts = {}) {
423
423
  });
424
424
  }
425
425
  }
426
+ // Calls pinned to one imported module (`ns.fn()`, aliased `g()`): the member
427
+ // gate above drops them, so resolve the name ONLY inside that module's files —
428
+ // a unique match there is the binding; zero or several -> no edge.
429
+ // A module binding exposes top-level exports, so `U.helper()` prefers a function
430
+ // `helper` over a same-named class method `Foo.helper`; a sole method match still
431
+ // binds, since `module.exports = { helper() {} }` parses as a method too.
432
+ const moduleFiles = new Map(); // resolve each module once per file
433
+ for (const [callerIndex, targets] of qualified) {
434
+ const callerId = indexToId.get(callerIndex);
435
+ if (!callerId)
436
+ continue;
437
+ const callerName = byId.get(callerId)?.name ?? "?";
438
+ for (const { callee, module } of targets) {
439
+ let files = moduleFiles.get(module);
440
+ if (!files)
441
+ moduleFiles.set(module, files = new Set(resolveImportTargets(file, module)));
442
+ const inModule = (nameIndex.get(callee) ?? []).filter((id) => {
443
+ const sym = byId.get(id);
444
+ return !!sym && files.has(sym.file);
445
+ });
446
+ const nonMethods = inModule.filter((id) => byId.get(id).kind !== "method");
447
+ const matches = nonMethods.length > 0 ? nonMethods : inModule;
448
+ if (matches.length !== 1 || matches[0] === callerId)
449
+ continue;
450
+ const calleeId = matches[0];
451
+ addEdge({
452
+ schema: "hunch.edge/1",
453
+ id: edgeId(callerId, calleeId, edgeType),
454
+ from: callerId, to: calleeId, type: edgeType,
455
+ reason: `${callerName} ${edgeType} ${callee}`, strength: 0.8,
456
+ provenance: extracted(0.8, [file]),
457
+ environment: null,
458
+ metadata: {},
459
+ });
460
+ }
461
+ }
426
462
  }
427
463
  // ---- K8s manifest cross-resource references (Phase 1: name-keyed) --------
428
464
  // Own resolver, not resolveName(): resolveName() indexes by bare symbol name
@@ -747,8 +783,8 @@ function resolveName(name, file, importedFiles, nameIndex, byId) {
747
783
  return imported.length === 1 ? imported[0] : null;
748
784
  }
749
785
  /** Resolve a relative import specifier to a concrete tracked file path. */
750
- function resolveImport(fromFile, spec, fileSymbols) {
751
- return resolveRelativeImport(fromFile, spec, fileSymbols.keys()).path;
786
+ function resolveImport(fromFile, spec, trackedFiles) {
787
+ return resolveRelativeImport(fromFile, spec, trackedFiles).path;
752
788
  }
753
789
  /** First of `${modulePath}.py` / `${modulePath}/__init__.py` that's a tracked file,
754
790
  * or null — the shared "module file vs. package __init__" candidate check used by
@@ -17,6 +17,15 @@ export interface LanguageSpec {
17
17
  defKindOf: Record<string, ParsedSymbolKind>;
18
18
  /** Query capture-name (ending in ".name") -> the matching ".def" capture-name. */
19
19
  nameToDef: Record<string, string>;
20
+ /** Optional query binding a local name to an imported module, run with
21
+ * matches() so captures pair up: @bind.src (module specifier), @bind.local
22
+ * (local name), @bind.name (an imported member, `from X import name`). Feeds
23
+ * qualified calls (`ns.fn()`), resolved only inside the bound module's files. */
24
+ bindingQuery?: string;
25
+ /** Local name an import binds when @bind.local is absent (Go `"a/geo"` -> geo). */
26
+ defaultBindingLocal?(src: string): string | null;
27
+ /** Module path of `from <src> import <name>` when name is itself a submodule. */
28
+ joinSubmodule?(src: string, name: string): string;
20
29
  /** Optional semantic relationship captures. The generic parser/indexer emit
21
30
  * these through the normal graph path; languages only describe syntax. */
22
31
  relationKindOf?: Record<string, {