@davesheffer/hunch 1.8.3 → 1.9.2

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 (58) hide show
  1. package/README.md +92 -1
  2. package/dist/cli/index.js +1222 -397
  3. package/dist/constitution/adapters.js +31 -14
  4. package/dist/constitution/behaviorEvaluator.js +20 -7
  5. package/dist/constitution/behaviorProof.js +3 -2
  6. package/dist/constitution/canonical.js +7 -1
  7. package/dist/constitution/card.js +7 -2
  8. package/dist/constitution/compiler.js +71 -1
  9. package/dist/constitution/correctionPolicyMaterializer.js +496 -0
  10. package/dist/constitution/delta.js +3 -2
  11. package/dist/constitution/evaluator.js +29 -3
  12. package/dist/constitution/experiment.js +6 -5
  13. package/dist/constitution/experimentRunner.js +43 -14
  14. package/dist/constitution/g2BehaviorCandidates.js +49 -26
  15. package/dist/constitution/g2BehaviorDependencies.js +203 -14
  16. package/dist/constitution/g2Candidates.js +1 -1
  17. package/dist/constitution/lifecycle.js +17 -0
  18. package/dist/constitution/plan.js +26 -9
  19. package/dist/constitution/replacementFreeGit.js +67 -0
  20. package/dist/constitution/replay.js +6 -0
  21. package/dist/constitution/replayCache.js +1 -1
  22. package/dist/constitution/replayWorker.js +1 -1
  23. package/dist/constitution/repository.js +141 -5
  24. package/dist/constitution/safeCheckout.js +75 -0
  25. package/dist/constitution/schema.js +30 -5
  26. package/dist/constitution/service.js +53 -10
  27. package/dist/constitution/sourceMutation.js +65 -12
  28. package/dist/constitution/staticGraphBaseline.js +44 -0
  29. package/dist/constitution/structural.js +60 -4
  30. package/dist/core/autoreview.js +1 -1
  31. package/dist/core/canonicalOrder.js +6 -0
  32. package/dist/core/conformance.js +68 -27
  33. package/dist/core/docscan.js +2 -1
  34. package/dist/core/escalations.js +11 -0
  35. package/dist/core/io.js +44 -9
  36. package/dist/core/overlaySafety.js +178 -0
  37. package/dist/core/paths.js +13 -2
  38. package/dist/core/safeRepoFile.js +74 -0
  39. package/dist/extractors/comments.js +6 -8
  40. package/dist/extractors/git.js +1631 -82
  41. package/dist/extractors/indexer.js +86 -47
  42. package/dist/extractors/repoSource.js +390 -0
  43. package/dist/integrations/ciAction.js +10 -2
  44. package/dist/integrations/gitignore.js +44 -5
  45. package/dist/integrations/mergeDriver.js +23 -5
  46. package/dist/integrations/sync.js +61 -5
  47. package/dist/integrations/team.js +666 -23
  48. package/dist/mcp/server.js +261 -34
  49. package/dist/store/db.js +57 -7
  50. package/dist/store/hunchStore.js +92 -11
  51. package/dist/store/jsonStore.js +350 -63
  52. package/dist/store/schema.js +27 -11
  53. package/dist/synthesis/provider.js +13 -4
  54. package/dist/synthesis/synthesize.js +56 -19
  55. package/dist/wiki/graph.js +5 -4
  56. package/dist/wiki/wiki.js +16 -10
  57. package/package.json +7 -2
  58. package/tooling/md1-benchmark.mjs +628 -0
@@ -1,9 +1,59 @@
1
1
  /** Deterministic git introspection for the extractor + learning loop.
2
2
  * No LLM here — just parsing what git already knows. */
3
3
  import { execFileSync } from "node:child_process";
4
- import { isAbsolute, resolve, join, basename, dirname } from "node:path";
5
- import { mkdirSync, rmSync, statSync, realpathSync, readFileSync } from "node:fs";
4
+ import { createHash } from "node:crypto";
5
+ import { devNull } from "node:os";
6
+ import { isAbsolute, resolve, join, basename, dirname, relative, sep } from "node:path";
7
+ import { mkdirSync, rmSync, statSync, lstatSync, realpathSync, readFileSync, renameSync, readdirSync } from "node:fs";
8
+ import { fileURLToPath } from "node:url";
6
9
  import { MEMLOG_FORMAT } from "../core/memorylog.js";
10
+ import { hunchAttributesAreSafe, hunchTreeAttributesAreSafe, safeOverlayTree } from "../core/overlaySafety.js";
11
+ import { createRepoFileReader } from "../core/safeRepoFile.js";
12
+ // `git` exports these repository-local variables to hooks. They outrank cwd/-C,
13
+ // so carrying them from the code repository into a command for the memory
14
+ // overlay can target the wrong index/object store. This is the documented set
15
+ // from `git rev-parse --local-env-vars`; keep explicit global credentials and
16
+ // transport settings, but always clear repository identity before selecting cwd.
17
+ const LOCAL_GIT_ENV_VARS = [
18
+ "GIT_ALTERNATE_OBJECT_DIRECTORIES", "GIT_CONFIG", "GIT_CONFIG_PARAMETERS", "GIT_CONFIG_COUNT",
19
+ "GIT_OBJECT_DIRECTORY", "GIT_DIR", "GIT_WORK_TREE", "GIT_IMPLICIT_WORK_TREE", "GIT_GRAFT_FILE",
20
+ "GIT_INDEX_FILE", "GIT_NO_REPLACE_OBJECTS", "GIT_REPLACE_REF_BASE", "GIT_PREFIX",
21
+ "GIT_INTERNAL_SUPER_PREFIX", "GIT_SHALLOW_FILE", "GIT_COMMON_DIR",
22
+ ];
23
+ export function foreignRepoEnv(source) {
24
+ const env = { ...source };
25
+ for (const key of LOCAL_GIT_ENV_VARS)
26
+ delete env[key];
27
+ for (const key of Object.keys(env)) {
28
+ if (/^GIT_CONFIG_(?:KEY|VALUE)_\d+$/.test(key))
29
+ delete env[key];
30
+ }
31
+ return env;
32
+ }
33
+ function machineCommitEnv(repoRoot, source) {
34
+ const env = foreignRepoEnv(source);
35
+ const configured = (key) => {
36
+ try {
37
+ return execFileSync("git", ["-C", repoRoot, "config", "--get", key], {
38
+ encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], env, timeout: 2_000,
39
+ }).trim();
40
+ }
41
+ catch {
42
+ return "";
43
+ }
44
+ };
45
+ const name = configured("user.name");
46
+ const email = configured("user.email");
47
+ if (!env.GIT_AUTHOR_NAME && !name)
48
+ env.GIT_AUTHOR_NAME = "Hunch Memory";
49
+ if (!env.GIT_COMMITTER_NAME && !name)
50
+ env.GIT_COMMITTER_NAME = "Hunch Memory";
51
+ if (!env.GIT_AUTHOR_EMAIL && !email)
52
+ env.GIT_AUTHOR_EMAIL = "hunch-memory@localhost";
53
+ if (!env.GIT_COMMITTER_EMAIL && !email)
54
+ env.GIT_COMMITTER_EMAIL = "hunch-memory@localhost";
55
+ return env;
56
+ }
7
57
  function git(args, cwd, maxBuffer = 64 * 1024 * 1024) {
8
58
  // stdio: capture stdout, silence stderr (so "no commits yet" etc. don't leak).
9
59
  return execFileSync("git", args, {
@@ -19,6 +69,23 @@ function gitSafe(args, cwd, maxBuffer) {
19
69
  return "";
20
70
  }
21
71
  }
72
+ /** Object-identity reads must not inherit clone-local replacement refs/grafts.
73
+ * Most Git helpers intentionally preserve ordinary repository behavior; use
74
+ * this narrower path only where a cross-clone canonical identity is minted. */
75
+ function gitSafeWithoutReplacements(args, cwd, maxBuffer = 64 * 1024 * 1024) {
76
+ try {
77
+ return execFileSync("git", args, {
78
+ cwd,
79
+ encoding: "utf8",
80
+ env: { ...process.env, GIT_NO_REPLACE_OBJECTS: "1" },
81
+ maxBuffer,
82
+ stdio: ["ignore", "pipe", "ignore"],
83
+ }).trim();
84
+ }
85
+ catch {
86
+ return "";
87
+ }
88
+ }
22
89
  function gitRawSafe(args, cwd, maxBuffer = 64 * 1024 * 1024) {
23
90
  try {
24
91
  return execFileSync("git", args, {
@@ -30,9 +97,323 @@ function gitRawSafe(args, cwd, maxBuffer = 64 * 1024 * 1024) {
30
97
  return null;
31
98
  }
32
99
  }
100
+ function gitSafeIsolated(args, cwd, maxBuffer = 64 * 1024 * 1024) {
101
+ try {
102
+ return execFileSync("git", args, {
103
+ cwd, encoding: "utf8", maxBuffer,
104
+ stdio: ["ignore", "pipe", "ignore"],
105
+ env: foreignRepoEnv(process.env),
106
+ }).trim();
107
+ }
108
+ catch {
109
+ return "";
110
+ }
111
+ }
112
+ function gitRawSafeIsolated(args, cwd, maxBuffer = 64 * 1024 * 1024) {
113
+ try {
114
+ return execFileSync("git", args, {
115
+ cwd, encoding: "utf8", maxBuffer,
116
+ stdio: ["ignore", "pipe", "ignore"],
117
+ env: foreignRepoEnv(process.env),
118
+ });
119
+ }
120
+ catch {
121
+ return null;
122
+ }
123
+ }
124
+ function headShaWithEnv(cwd, env) {
125
+ return gitSafeWithEnv(["rev-parse", "HEAD"], cwd, env);
126
+ }
127
+ /** Git query for a repository other than the invocation repository. The
128
+ * caller supplies an environment with code-repo GIT_DIR/GIT_INDEX_FILE state
129
+ * removed, so hooks cannot redirect an overlay query back into the code repo. */
130
+ function gitSafeWithEnv(args, cwd, env) {
131
+ try {
132
+ return execFileSync("git", ["-C", cwd, ...args], {
133
+ encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], env, timeout: 2_000,
134
+ }).trim();
135
+ }
136
+ catch {
137
+ return "";
138
+ }
139
+ }
33
140
  export function isGitRepo(cwd) {
34
141
  return gitSafe(["rev-parse", "--is-inside-work-tree"], cwd) === "true";
35
142
  }
143
+ /** Canonical worktree root, or null when Git cannot positively identify one.
144
+ * Callers enforcing a privacy boundary must distinguish "different repo" from
145
+ * "malformed/unknown Git state" instead of treating both as safe. */
146
+ export function gitWorktreeRoot(cwd) {
147
+ const top = gitSafeIsolated(["rev-parse", "--show-toplevel"], cwd);
148
+ return top ? canonicalPath(top) : null;
149
+ }
150
+ /** True only when `cwd` is the repository's actual worktree root. Unlike
151
+ * `isGitRepo`, this does not accept an ancestor repository discovered by Git's
152
+ * upward walk. Private overlays use this stronger boundary so a nested
153
+ * `.hunch-private/.hunch` can never stage or commit into the code repository. */
154
+ export function isGitRepoRoot(cwd) {
155
+ const raw = gitSafeIsolated(["rev-parse", "--show-toplevel"], cwd);
156
+ return !!raw && sameFilesystemEntry(raw, cwd);
157
+ }
158
+ function canonicalPath(path) {
159
+ try {
160
+ return realpathSync(path);
161
+ }
162
+ catch {
163
+ return resolve(path);
164
+ }
165
+ }
166
+ export function gitNullDevice() {
167
+ return process.platform === "win32" ? "NUL" : devNull;
168
+ }
169
+ /** Compare physical directory identity before path text. Git for Windows can
170
+ * return an 8.3/short or differently-cased spelling for the same top-level
171
+ * directory that Node reached through its long path. A nonzero file ID keeps
172
+ * this exact even on case-sensitive Windows directories; canonical text is a
173
+ * conservative fallback for filesystems that do not expose stable IDs. */
174
+ function sameFilesystemEntry(left, right) {
175
+ try {
176
+ const leftStat = statSync(left, { bigint: true });
177
+ const rightStat = statSync(right, { bigint: true });
178
+ if (leftStat.ino !== 0n && rightStat.ino !== 0n) {
179
+ return leftStat.dev === rightStat.dev && leftStat.ino === rightStat.ino;
180
+ }
181
+ }
182
+ catch { /* fall back to canonical path text */ }
183
+ return canonicalPath(left) === canonicalPath(right);
184
+ }
185
+ /** Whether two paths resolve to the same repository identity. Comparing only
186
+ * worktree roots is insufficient: linked worktrees have different roots but
187
+ * share one Git common directory and therefore one publishable history. */
188
+ export function sameGitRepository(left, right) {
189
+ if (sameFilesystemEntry(left, right))
190
+ return true;
191
+ const common = (cwd) => {
192
+ const value = gitSafeIsolated(["rev-parse", "--git-common-dir"], cwd);
193
+ return value ? (isAbsolute(value) ? value : resolve(cwd, value)) : "";
194
+ };
195
+ const leftCommon = common(left);
196
+ const rightCommon = common(right);
197
+ return !!leftCommon && !!rightCommon && sameFilesystemEntry(leftCommon, rightCommon);
198
+ }
199
+ function remoteIdentity(raw, cwd, purpose = "route") {
200
+ const value = raw.trim();
201
+ if (!value)
202
+ return "";
203
+ const trimRepoSuffix = (path) => path.replace(/[\\/]+$/, "").replace(/\.git$/i, "");
204
+ // Local repositories are filesystem identities, not provider aliases. Both
205
+ // `/srv/memory` and `/srv/memory.git` may exist and publish unrelated graphs;
206
+ // collapsing the conventional suffix is safe only for known network hosts.
207
+ const normalizeLocalPath = (path) => canonicalPath(path).replace(/[\\/]+$/, "");
208
+ const normalizeNetworkPath = (host, path) => {
209
+ const normalized = trimRepoSuffix(path).replace(/^\/+/, "");
210
+ return host === "github.com" || host === "www.github.com" ? normalized.toLowerCase() : normalized;
211
+ };
212
+ const azureDevOpsIdentity = (host, path) => {
213
+ const segments = trimRepoSuffix(path).replace(/^\/+/, "").split("/");
214
+ let coordinates = null;
215
+ if (host === "dev.azure.com" && segments.length === 4 && segments[2].toLowerCase() === "_git") {
216
+ coordinates = [segments[0], segments[1], segments[3]];
217
+ }
218
+ else if ((host === "ssh.dev.azure.com" || host === "vs-ssh.visualstudio.com")
219
+ && segments.length === 4 && segments[0].toLowerCase() === "v3") {
220
+ coordinates = [segments[1], segments[2], segments[3]];
221
+ }
222
+ else {
223
+ const legacyHost = host.match(/^([^.]+)\.visualstudio\.com$/);
224
+ if (legacyHost && segments.length === 3 && segments[1].toLowerCase() === "_git") {
225
+ coordinates = [legacyHost[1], segments[0], segments[2]];
226
+ }
227
+ }
228
+ if (!coordinates?.every(Boolean))
229
+ return null;
230
+ return `provider:azure-devops:${coordinates.map((part) => part.toLowerCase()).join("/")}`;
231
+ };
232
+ const networkIdentity = (host, path, pathCaseHost = host, username = "", transport = "ssh") => {
233
+ const provider = azureDevOpsIdentity(host, path);
234
+ if (provider)
235
+ return provider;
236
+ if (host === "github.com" || host === "www.github.com") {
237
+ return `provider:github:${normalizeNetworkPath("github.com", path)}`;
238
+ }
239
+ // Route proof is deliberately exact for an unknown host: transport and SSH
240
+ // account can select different namespaces. Preserve username bytes/case.
241
+ // Publication proof is deliberately conservative in the other direction:
242
+ // https://host/org/repo and ssh://user@host/org/repo may publish the same
243
+ // history, so an overlay must not evade the code-remote boundary by changing
244
+ // transport spelling.
245
+ const account = purpose === "route" && username ? `${username}@` : "";
246
+ const route = ["ssh", "git+ssh", "ssh+git"].includes(transport) ? "ssh" : transport;
247
+ const normalizedPath = normalizeNetworkPath(pathCaseHost, path);
248
+ return `net:${purpose === "route" ? route : "any"}://${account}${host}/${purpose === "publication" ? normalizedPath.toLowerCase() : normalizedPath}`;
249
+ };
250
+ try {
251
+ if (value.startsWith("file://"))
252
+ return `file:${normalizeLocalPath(fileURLToPath(value))}`;
253
+ }
254
+ catch { /* fall through to the literal URL form */ }
255
+ if (isAbsolute(value) || value.startsWith("./") || value.startsWith("../")) {
256
+ return `file:${normalizeLocalPath(resolve(cwd, value))}`;
257
+ }
258
+ const scp = value.match(/^(?:([^@/]+)@)?([^:/]+):(.+)$/);
259
+ if (scp && !value.includes("://")) {
260
+ const rawHost = scp[2].toLowerCase().replace(/\.$/, "");
261
+ const host = rawHost === "www.github.com" ? "github.com" : rawHost;
262
+ return networkIdentity(host, scp[3], host, scp[1] ?? "", "ssh");
263
+ }
264
+ if (!value.includes("://"))
265
+ return `file:${normalizeLocalPath(resolve(cwd, value))}`;
266
+ try {
267
+ const url = new URL(value);
268
+ const rawHostname = url.hostname.toLowerCase().replace(/\.$/, "");
269
+ const protocol = url.protocol.toLowerCase();
270
+ const githubSsh443 = rawHostname === "ssh.github.com" && ["ssh:", "git+ssh:", "ssh+git:"].includes(protocol) && url.port === "443";
271
+ const hostname = rawHostname === "www.github.com" || githubSsh443 ? "github.com" : rawHostname;
272
+ const defaults = { "ssh:": "22", "git+ssh:": "22", "ssh+git:": "22", "https:": "443", "http:": "80", "git:": "9418" };
273
+ const port = !githubSsh443 && url.port && url.port !== defaults[protocol] ? `:${url.port}` : "";
274
+ const host = `${hostname}${port}`;
275
+ return networkIdentity(host, decodeURIComponent(url.pathname), hostname, decodeURIComponent(url.username), protocol.replace(/:$/, ""));
276
+ }
277
+ catch {
278
+ return `literal:${trimRepoSuffix(value)}`;
279
+ }
280
+ }
281
+ function localRemotePath(raw, cwd) {
282
+ const value = raw.trim();
283
+ if (!value)
284
+ return null;
285
+ try {
286
+ if (value.startsWith("file://"))
287
+ return canonicalPath(fileURLToPath(value));
288
+ }
289
+ catch {
290
+ return null;
291
+ }
292
+ if (isAbsolute(value) || value.startsWith("./") || value.startsWith("../")) {
293
+ return canonicalPath(resolve(cwd, value));
294
+ }
295
+ const scp = value.match(/^(?:[^@/]+@)?[^:/]+:.+$/);
296
+ if (!value.includes("://") && !scp)
297
+ return canonicalPath(resolve(cwd, value));
298
+ return null;
299
+ }
300
+ function repositoriesShareCommitObjects(left, right) {
301
+ const leftHead = gitSafeIsolated(["rev-parse", "--verify", "HEAD^{commit}"], left);
302
+ const rightHead = gitSafeIsolated(["rev-parse", "--verify", "HEAD^{commit}"], right);
303
+ if (!leftHead || !rightHead)
304
+ return false;
305
+ if (gitRawSafeIsolated(["cat-file", "-e", `${leftHead}^{commit}`], right) !== null
306
+ || gitRawSafeIsolated(["cat-file", "-e", `${rightHead}^{commit}`], left) !== null)
307
+ return true;
308
+ // Comparing only the tips misses a forked contamination: the code repo and
309
+ // an overlay can each add a unique commit after sharing an older code
310
+ // history. Pushing the overlay tip would still publish every reachable code
311
+ // ancestor. Full repositories that share ancestry share at least one root;
312
+ // for shallow repositories, also ask whether either visible boundary/root is
313
+ // present in the other object database.
314
+ const roots = (cwd) => gitSafeIsolated(["rev-list", "--max-parents=0", "HEAD"], cwd)
315
+ .split(/\r?\n/)
316
+ .filter((oid) => /^[0-9a-f]{40,64}$/i.test(oid));
317
+ const leftRoots = roots(left);
318
+ const rightRoots = roots(right);
319
+ if (!leftRoots.length || !rightRoots.length)
320
+ return false;
321
+ const rightRootSet = new Set(rightRoots);
322
+ if (leftRoots.some((oid) => rightRootSet.has(oid)))
323
+ return true;
324
+ return leftRoots.some((oid) => gitRawSafeIsolated(["cat-file", "-e", `${oid}^{commit}`], right) !== null)
325
+ || rightRoots.some((oid) => gitRawSafeIsolated(["cat-file", "-e", `${oid}^{commit}`], left) !== null);
326
+ }
327
+ function localRemoteTargets(cwd) {
328
+ const out = gitRawSafeIsolated(["remote", "-v"], cwd) ?? "";
329
+ const targets = new Set();
330
+ for (const line of out.split("\n")) {
331
+ const match = line.match(/^[^\t]+\t(.+) \((?:fetch|push)\)$/);
332
+ if (!match)
333
+ continue;
334
+ const target = localRemotePath(match[1], cwd);
335
+ if (target)
336
+ targets.add(target);
337
+ }
338
+ return [...targets];
339
+ }
340
+ function repositoryTargetMatches(target, repoRoot) {
341
+ return sameGitRepository(target, repoRoot) || repositoriesShareCommitObjects(target, repoRoot);
342
+ }
343
+ /** Resolve a user-supplied Git remote once, before handing it to commands that
344
+ * run from different working directories. Git otherwise gives a relative local
345
+ * URL a different meaning under `git clone` and `git -C <overlay> remote add`,
346
+ * which can turn a successful preflight into a later privacy-boundary escape. */
347
+ export function canonicalRemoteUrl(raw, cwd) {
348
+ const value = raw.trim();
349
+ if (!value)
350
+ return "";
351
+ try {
352
+ if (value.startsWith("file://"))
353
+ return canonicalPath(fileURLToPath(value));
354
+ }
355
+ catch { /* let Git report an invalid URL without weakening the boundary */ }
356
+ if (isAbsolute(value))
357
+ return canonicalPath(value);
358
+ const scp = value.match(/^(?:[^@/]+@)?[^:/]+:.+$/);
359
+ if (!value.includes("://") && !scp)
360
+ return canonicalPath(resolve(cwd, value));
361
+ return value;
362
+ }
363
+ /** Compare two remote spellings in the contexts where Git would interpret
364
+ * them. This is identity comparison, not brittle string equality. */
365
+ export function sameRemoteUrl(left, leftCwd, right, rightCwd) {
366
+ const leftIdentity = remoteIdentity(left, leftCwd, "route");
367
+ return !!leftIdentity && leftIdentity === remoteIdentity(right, rightCwd, "route");
368
+ }
369
+ function gitRemoteIdentities(cwd, direction = "any") {
370
+ const out = gitRawSafeIsolated(["remote", "-v"], cwd) ?? "";
371
+ const identities = new Set();
372
+ for (const line of out.split("\n")) {
373
+ const match = line.match(/^[^\t]+\t(.+) \((fetch|push)\)$/);
374
+ if (!match)
375
+ continue;
376
+ if (direction !== "any" && match[2] !== direction)
377
+ continue;
378
+ const identity = remoteIdentity(match[1], cwd, "publication");
379
+ if (identity)
380
+ identities.add(identity);
381
+ }
382
+ return identities;
383
+ }
384
+ /** True when two worktrees can publish to the same local Git history OR name
385
+ * the same configured remote repository. Separate clones of one remote are a
386
+ * single publication boundary even though their local common dirs differ. */
387
+ export function sameGitPublication(left, right) {
388
+ if (sameGitRepository(left, right))
389
+ return true;
390
+ // A clone of the code repository with its origin removed still carries the
391
+ // same commit objects. Treat shared history as one publication boundary; a
392
+ // memory overlay must start from an independent graph history.
393
+ if (repositoriesShareCommitObjects(left, right))
394
+ return true;
395
+ for (const target of localRemoteTargets(left))
396
+ if (repositoryTargetMatches(target, right))
397
+ return true;
398
+ for (const target of localRemoteTargets(right))
399
+ if (repositoryTargetMatches(target, left))
400
+ return true;
401
+ const leftRemotes = gitRemoteIdentities(left);
402
+ if (!leftRemotes.size)
403
+ return false;
404
+ for (const remote of gitRemoteIdentities(right))
405
+ if (leftRemotes.has(remote))
406
+ return true;
407
+ return false;
408
+ }
409
+ /** Preflight a requested overlay URL before clone/attach can mutate a remote. */
410
+ export function repositoryUsesRemote(repoRoot, remoteUrl, remoteCwd = repoRoot) {
411
+ const localTarget = localRemotePath(remoteUrl, remoteCwd);
412
+ if (localTarget && repositoryTargetMatches(localTarget, repoRoot))
413
+ return true;
414
+ const requested = remoteIdentity(remoteUrl, remoteCwd, "publication");
415
+ return !!requested && gitRemoteIdentities(repoRoot).has(requested);
416
+ }
36
417
  /** The MAIN worktree's root — the stable anchor for an overlay store. A linked worktree
37
418
  * can be `git worktree remove`d, so anything anchored inside it (an overlay clone, an
38
419
  * absolute pointer target) silently dies for every OTHER worktree; the main checkout
@@ -45,42 +426,131 @@ export function mainWorktreeRoot(root) {
45
426
  const abs = resolve(root, common);
46
427
  return basename(abs) === ".git" ? dirname(abs) : root;
47
428
  }
48
- /** Best-effort: stage ONLY the hunch dir, commit, and push the repo it lives in. Shared by
49
- * the post-commit auto-commit (CLI sync --commit), MCP private writes, and `hunch private
50
- * --sync`. HUNCH_SYNC=1 stops the created commit from re-triggering the post-commit hook
51
- * (no recursion). Stages with a pathspec scoped to `hunchDir`, so it never sweeps unrelated
52
- * working-tree changes. Never throws a non-repo dir / offline push just no-ops.
53
- * `push: false` commits WITHOUT merging or pushing required when hunchDir is the PUBLIC
54
- * .hunch/ inside the user's code repo: an automatic pull/push there would merge the remote
55
- * into their working branch and publish their unpushed code commits. The memory commit
56
- * simply rides the user's next push.
57
- * Returns what ACTUALLY happened, so callers never report a commit that was skipped:
58
- * "pushed" (commit created and pushed), "committed" (commit created; push not requested,
59
- * or the merge/push failed — retry rides the next flush), null (nothing committed: lock
60
- * held, backstop refusal, nothing staged, or not a repo). */
61
- export function commitAndPushHunch(hunchDir, message, opts = {}) {
429
+ /** Stable, privacy-safe repository label for artifacts that must be reusable
430
+ * across linked worktrees and ordinary clones. Prefer the canonical fetch
431
+ * remote identity: its SHA-256 digest exposes neither a private URL nor a local
432
+ * path, ignores mutable remote aliases, and does not depend on clone depth. If
433
+ * several fetch remotes exist, canonical identity ordering makes the choice
434
+ * deterministic without privileging a name such as `origin`.
435
+ *
436
+ * Remote-less full clones fall back to intrinsic root commits. A remote-less
437
+ * shallow clone cannot prove a clone-independent repository identity without
438
+ * fetching missing history or persisting a shared ID, so it retains the local
439
+ * main-worktree label (which is still stable across linked worktrees). */
440
+ export function stableRepositoryName(root) {
441
+ const fetchRemote = [...gitRemoteIdentities(root, "fetch")].sort()[0];
442
+ if (fetchRemote) {
443
+ const digest = createHash("sha256").update(fetchRemote, "utf8").digest("hex");
444
+ return `git-remote:sha256:${digest}`;
445
+ }
446
+ const shallow = gitSafe(["rev-parse", "--is-shallow-repository"], root) === "true";
447
+ if (shallow)
448
+ return basename(mainWorktreeRoot(root));
449
+ const roots = gitSafeWithoutReplacements(["rev-list", "--max-parents=0", "HEAD"], root)
450
+ .split(/\s+/)
451
+ .filter(Boolean)
452
+ .sort();
453
+ if (roots.length)
454
+ return `git:${roots.join("+")}`;
455
+ return basename(mainWorktreeRoot(root));
456
+ }
457
+ const CAPTURE_REMOTE_TIMEOUT_MS = 15_000;
458
+ const READ_REMOTE_TIMEOUT_MS = 5_000;
459
+ // A capture can spend roughly 90s in commit + bounded merge/push/retry seams.
460
+ // A contending writer waits beyond that proven ceiling, then takes the lock and
461
+ // drains every already-durable JSON write itself. This removes the old "maybe a
462
+ // third capture sweeps it later" liveness hole.
463
+ const CAPTURE_LOCK_HANDOFF_MS = 120_000;
464
+ function unsafeOverlayPublication(hunchDir, protectedRepoRoot) {
465
+ let currentOverlayRoot = dirname(resolve(hunchDir));
466
+ try {
467
+ currentOverlayRoot = dirname(realpathSync(hunchDir));
468
+ }
469
+ catch {
470
+ return true;
471
+ }
472
+ const standalone = isGitRepoRoot(currentOverlayRoot);
473
+ const overlapsCode = standalone ? sameGitPublication(currentOverlayRoot, protectedRepoRoot) : null;
474
+ const unsafe = !standalone || overlapsCode === true;
475
+ if (unsafe && process.env.HUNCH_TEAM_CLONE_DEBUG === "1") {
476
+ process.stderr.write(`[hunch-team-boundary] standalone=${standalone} overlaps_code=${overlapsCode ?? "unchecked"}\n`);
477
+ }
478
+ return unsafe;
479
+ }
480
+ export function commitAndPushHunch(hunchDir, message, opts) {
481
+ // Runtime callers may be older compiled JS even though TypeScript requires the
482
+ // protected-repository contract. Preserve this helper's never-throw promise and
483
+ // fail closed instead of dereferencing a missing options object.
484
+ if (!opts) {
485
+ console.error(`hunch: refusing to auto-commit memory at "${hunchDir}" — the protected repository identity was not provided. Nothing was staged, committed, or pushed.`);
486
+ return null;
487
+ }
488
+ // A push-capable target is a PRIVATE/SHARED overlay and must live directly
489
+ // inside its OWN repository. `git -C` otherwise walks upward and may resolve
490
+ // a nested overlay to the user's code repo. JSON-only private artifacts would
491
+ // pass the staged-file backstop below, so enforce the repository boundary
492
+ // before staging even one byte. Public `.hunch/` commits intentionally use
493
+ // push:false and are allowed to resolve to the enclosing project repository.
494
+ if (opts.push !== false) {
495
+ if (unsafeOverlayPublication(hunchDir, opts.protectedRepoRoot)) {
496
+ console.error(`hunch: refusing to auto-commit private memory at "${hunchDir}" — it is not a standalone Git repository distinct from the protected code repository. Nothing was staged, committed, or pushed. (Run \`hunch private\` or \`hunch shared --repo <url>\` to create a dedicated overlay repository.)`);
497
+ return null;
498
+ }
499
+ }
62
500
  // Serialize across worktrees: several worktrees auto-committing the SAME overlay repo
63
- // at once would race git's index.lock. An atomic-mkdir lock lets one proceed; the others
64
- // skip safe because each record is already written to disk, so `git add .` here sweeps
65
- // up anything a skipped run left pending (eventually-consistent, never lost).
501
+ // at once would race git's index.lock. A contender whose record is already
502
+ // durable waits for the live owner to finish, then acquires the lock and
503
+ // drains anything the owner's exact path snapshot did not include.
66
504
  const lock = join(hunchDir, ".hunch-commit.lock");
67
- if (!acquireCommitLock(lock))
505
+ const firstLockAttempt = acquireCommitLock(lock);
506
+ if (firstLockAttempt.state !== "acquired"
507
+ && !waitForCommitLockHandoff(lock, firstLockAttempt, CAPTURE_LOCK_HANDOFF_MS))
68
508
  return null;
69
509
  try {
70
- const env = { ...process.env, HUNCH_SYNC: "1" };
510
+ const env = machineCommitEnv(hunchDir, {
511
+ ...process.env,
512
+ HUNCH_SYNC: "1",
513
+ GIT_TERMINAL_PROMPT: "0",
514
+ GIT_MERGE_AUTOEDIT: "no",
515
+ GIT_ATTR_NOSYSTEM: "1",
516
+ });
71
517
  const run = (args) => {
72
518
  try {
73
519
  execFileSync("git", ["-C", hunchDir, ...args], { stdio: "ignore", env });
520
+ return true;
521
+ }
522
+ catch {
523
+ return false; // best-effort: nothing staged / not a repo / offline
74
524
  }
75
- catch { /* best-effort: nothing staged / not a repo / offline */ }
76
525
  };
77
- run(["add", "--", "."]);
526
+ if (opts.push !== false) {
527
+ if (!overlayAttributeSourcesAreSafe(hunchDir, env)) {
528
+ console.error(`hunch: refusing to auto-commit private memory at "${hunchDir}" — an unsafe Git attributes source could transform memory bytes. Nothing was staged, committed, or pushed.`);
529
+ return null;
530
+ }
531
+ const paths = committableOverlayJsonPaths(hunchDir);
532
+ if (!paths)
533
+ return null;
534
+ // Force-add only the exact contained JSON source-of-truth allowlist. A
535
+ // remote .gitignore, local info/exclude, or ambient excludesFile must not
536
+ // be able to silently stop the shared graph's heartbeat.
537
+ for (let index = 0; index < paths.length; index += 128) {
538
+ if (!run(["-c", `core.attributesFile=${gitNullDevice()}`, "add", "-f", "--", ...paths.slice(index, index + 128)])) {
539
+ run(["reset", "-q", "--", "."]);
540
+ return null;
541
+ }
542
+ }
543
+ }
544
+ else {
545
+ run(["add", "--", "."]);
546
+ }
78
547
  // SAFETY BACKSTOP (critical — bug_overlay_clobber): a memory sync is PURELY ADDITIVE small
79
548
  // JSON. If the staged set contains a DELETION, rename, or any non-.json file, hunchDir is NOT
80
549
  // a clean overlay store — most dangerously, the overlay was never its own git repo so `git -C`
81
550
  // walked UP to the PROJECT repo. Committing/pushing there would overwrite/delete the user's
82
551
  // code (we shipped exactly this). Refuse hard: unstage and bail without committing or pushing.
83
- if (!stagedIsMemoryOnly(hunchDir, env)) {
552
+ const memoryPaths = stagedMemoryPaths(hunchDir, env);
553
+ if (memoryPaths === null) {
84
554
  try {
85
555
  execFileSync("git", ["-C", hunchDir, "reset", "-q", "--", "."], { stdio: "ignore", env });
86
556
  }
@@ -94,34 +564,74 @@ export function commitAndPushHunch(hunchDir, message, opts = {}) {
94
564
  }
95
565
  return null;
96
566
  }
567
+ if (memoryPaths.length === 0)
568
+ return null;
97
569
  // Grounding docs refreshed by this capture ride the same memory commit, so committed record
98
570
  // counts can never go stale (the refresh-counts treadmill: every capture commit bumped the
99
571
  // count and re-staled the docs for the next release-gate clean-tree check). Staged AFTER the
100
572
  // memory-only backstop on purpose: alsoStage is a code-controlled list of generated grounding
101
573
  // docs the caller verified git-clean BEFORE rewriting, so it can neither weaken the
102
574
  // bug_overlay_clobber detection above nor sweep user edits.
103
- for (const file of opts.alsoStage ?? [])
104
- run(["add", "--", file]);
575
+ for (const file of opts.alsoStage ?? []) {
576
+ run(opts.push === false
577
+ ? ["add", "--", file]
578
+ : ["-c", `core.attributesFile=${gitNullDevice()}`, "add", "--", file]);
579
+ }
105
580
  // Only sync+push when a memory commit was actually created — never run pull/push against the
106
581
  // enclosing repo on an empty stage. Two-way sync: MERGE the remote BEFORE pushing so a push
107
582
  // can't be rejected non-fast-forward; the .hunch merge driver resolves same-record conflicts
108
583
  // by id. On conflict/offline, mergeRemote aborts to a clean tree and we skip the push.
109
584
  let committed = false;
585
+ // `git commit` without pathspecs commits the ENTIRE index. Even after the
586
+ // staged-set check, another process (or a pre-staged JSON file) could enter
587
+ // the index before commit. `--only` makes the mutation boundary mechanical:
588
+ // commit this Hunch tree plus the caller-vetted grounding files, and leave
589
+ // every unrelated staged byte untouched.
590
+ // Commit an exact path allowlist. A hook or concurrent index writer cannot
591
+ // smuggle local.json (or any other path) into a broad `--only -- .` commit.
592
+ // Hooks are disabled for this machine-generated commit; user hooks are an
593
+ // untrusted mutation seam and are unnecessary for JSON memory artifacts.
594
+ const hooksDir = disabledHooksDir(hunchDir);
595
+ if (!hooksDir)
596
+ return null;
597
+ const commitPaths = [...memoryPaths, ...(opts.alsoStage ?? [])];
110
598
  try {
111
- execFileSync("git", ["-C", hunchDir, "commit", "-m", message], { stdio: "ignore", env });
599
+ execFileSync("git", [
600
+ "-C", hunchDir,
601
+ "-c", `core.hooksPath=${hooksDir}`,
602
+ ...(opts.push === false ? [] : ["-c", `core.attributesFile=${gitNullDevice()}`]),
603
+ "-c", "commit.gpgsign=false",
604
+ "commit", "--no-gpg-sign", "--only", "-m", message, "--", ...commitPaths,
605
+ ], { stdio: "ignore", env, timeout: 15_000 });
112
606
  committed = true;
113
607
  }
114
608
  catch { /* nothing staged / not a repo */ }
115
609
  if (!committed)
116
610
  return null;
117
- if (opts.push !== false && mergeRemote(hunchDir, env)) {
611
+ if (opts.push !== false) {
612
+ // The overlay remote is mutable process state. Re-prove the publication
613
+ // boundary after the local commit and BEFORE pull: hooks or another
614
+ // process may have re-pointed it since the pre-staging check.
615
+ if (unsafeOverlayPublication(hunchDir, opts.protectedRepoRoot)) {
616
+ console.error(`hunch: private memory was committed locally, but the overlay publication boundary changed before sync. Nothing was pulled or pushed.`);
617
+ return "committed";
618
+ }
619
+ if (!contractReady(opts.remote)
620
+ || mergeRemote(hunchDir, env, CAPTURE_REMOTE_TIMEOUT_MS, opts.remote) === "failed")
621
+ return "committed";
622
+ // Hooks are disabled in the merge seam, but another process can still
623
+ // rewrite Git configuration. Check once after merge and once at the
624
+ // actual push seam; either refusal leaves the private commit local.
625
+ if (unsafeOverlayPublication(hunchDir, opts.protectedRepoRoot) || !contractReady(opts.remote)) {
626
+ console.error(`hunch: private memory was committed locally, but the overlay publication boundary changed during sync. Nothing was pushed.`);
627
+ return "committed";
628
+ }
118
629
  // Push tracked (not via run): a no-upstream/offline/rejected push must report
119
630
  // "committed", not overclaim "pushed" — the next flush's merge+push retries.
120
- try {
121
- execFileSync("git", ["-C", hunchDir, "push"], { stdio: "ignore", env });
631
+ if (unsafeOverlayPublication(hunchDir, opts.protectedRepoRoot) || !contractReady(opts.remote))
632
+ return "committed";
633
+ if (pushWithOneRemoteAdvanceRetry(hunchDir, env, opts.protectedRepoRoot, CAPTURE_REMOTE_TIMEOUT_MS, opts.remote))
122
634
  return "pushed";
123
- }
124
- catch { /* offline / no upstream */ }
125
635
  }
126
636
  return "committed";
127
637
  }
@@ -136,7 +646,10 @@ export function commitAndPushHunch(hunchDir, message, opts = {}) {
136
646
  * dirty, so a doc the user never committed is never swept into a memory commit). */
137
647
  export function isGitCleanPath(root, rel) {
138
648
  try {
139
- return execFileSync("git", ["-C", root, "status", "--porcelain", "--", rel], { encoding: "utf8" }).trim() === "";
649
+ return execFileSync("git", ["-C", root, "status", "--porcelain", "--", rel], {
650
+ encoding: "utf8",
651
+ env: foreignRepoEnv(process.env),
652
+ }).trim() === "";
140
653
  }
141
654
  catch {
142
655
  return false;
@@ -146,18 +659,28 @@ export function isGitCleanPath(root, rel) {
146
659
  * The overlay store is entirely JSON (decisions/, bugs/, …, manifest.json). A real memory sync
147
660
  * is purely additive; a DELETION, rename, or any non-.json staged path means hunchDir is NOT a
148
661
  * clean overlay repo (e.g. it resolved to the project repo), so committing there would clobber
149
- * code. Empty stage ⇒ false (nothing to commit). The transient mkdir lock is ignored. */
150
- function stagedIsMemoryOnly(hunchDir, env) {
662
+ * code. Empty stage ⇒ [] (nothing to commit); invalid stage ⇒ null. The transient mkdir lock is ignored. */
663
+ function stagedMemoryPaths(hunchDir, env) {
151
664
  let out = "";
665
+ let prefix = "";
152
666
  try {
153
- out = execFileSync("git", ["-C", hunchDir, "diff", "--cached", "--name-status"], { encoding: "utf8", env });
667
+ prefix = execFileSync("git", ["-C", hunchDir, "rev-parse", "--show-prefix"], { encoding: "utf8", env }).trim().replace(/\\/g, "/");
154
668
  }
155
669
  catch {
156
- return false;
670
+ return null;
671
+ }
672
+ if (!prefix)
673
+ return null; // a Hunch layout is a scoped subdirectory, never the whole repository
674
+ try {
675
+ out = execFileSync("git", ["-C", hunchDir, "diff", "--cached", "--no-ext-diff", "--no-textconv", "--name-status"], { encoding: "utf8", env });
676
+ }
677
+ catch {
678
+ return null;
157
679
  }
158
680
  const lines = out.split("\n").map((l) => l.trim()).filter(Boolean);
159
681
  if (!lines.length)
160
- return false;
682
+ return [];
683
+ const memoryPaths = [];
161
684
  for (const line of lines) {
162
685
  const parts = line.split("\t");
163
686
  const status = (parts[0] ?? "").trim();
@@ -165,42 +688,716 @@ function stagedIsMemoryOnly(hunchDir, env) {
165
688
  if (path.includes(".hunch-commit.lock"))
166
689
  continue; // transient lock dir, never a record
167
690
  if (!/^[AM]$/.test(status))
168
- return false; // only Add / Modify — any D/R/C/T → not a memory sync
169
- if (!path.endsWith(".json"))
170
- return false; // the store is entirely JSON records
171
- }
172
- return true;
173
- }
174
- /** Merge the overlay's remote into the local branch (pull, no rebase), leaving a CLEAN tree
175
- * whether it succeeds or not. Returns true when the branch is safe to push (merged, or there's
176
- * no upstream to merge), false when a conflict was aborted (caller skips the push and retries
177
- * on the next write). Never throws. */
178
- function mergeRemote(hunchDir, env) {
179
- const tryGit = (args) => {
691
+ return null; // only Add / Modify — any D/R/C/T → not a memory sync
692
+ const normalizedPath = path.replace(/\\/g, "/");
693
+ if (!normalizedPath.startsWith(prefix))
694
+ return null; // never bless another staged JSON file
695
+ const memoryRelativePath = normalizedPath.slice(prefix.length);
696
+ if (!memoryRelativePath || memoryRelativePath === "local.json")
697
+ return null; // machine-local overlay pointer; never publish it
698
+ if (!normalizedPath.endsWith(".json"))
699
+ return null; // the store is entirely JSON records
700
+ memoryPaths.push(memoryRelativePath);
701
+ }
702
+ return [...new Set(memoryPaths)];
703
+ }
704
+ /** Enumerate ordinary JSON files already contained under an overlay. Push-capable
705
+ * stores force-add this exact allowlist so remote .gitignore, info/exclude, or an
706
+ * ambient excludesFile cannot silently stop the memory pump. Public push:false
707
+ * stores intentionally keep ordinary Git ignore semantics after migration. */
708
+ function committableOverlayJsonPaths(hunchDir) {
709
+ try {
710
+ const root = realpathSync(hunchDir);
711
+ const paths = [];
712
+ const walk = (dir, prefix = "") => {
713
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
714
+ if (!prefix && entry.name === ".hunch-commit.lock")
715
+ continue;
716
+ const absolute = join(dir, entry.name);
717
+ const relativeName = prefix ? `${prefix}/${entry.name}` : entry.name;
718
+ const stat = lstatSync(absolute);
719
+ if (stat.isSymbolicLink())
720
+ return false;
721
+ if (stat.isDirectory()) {
722
+ if (!walk(absolute, relativeName))
723
+ return false;
724
+ }
725
+ else if (!stat.isFile()) {
726
+ return false;
727
+ }
728
+ else if (relativeName.endsWith(".json") && relativeName !== "local.json") {
729
+ const fromRoot = relative(root, realpathSync(absolute));
730
+ if (fromRoot === ".." || fromRoot.startsWith(`..${sep}`) || isAbsolute(fromRoot))
731
+ return false;
732
+ paths.push(relativeName);
733
+ }
734
+ else if (relativeName === "local.json") {
735
+ return false;
736
+ }
737
+ else if (/^[^/]+\.sqlite[^/]*$/i.test(relativeName)
738
+ || relativeName.split("/").some((segment) => segment.includes(".tmp"))
739
+ || relativeName === "events.log") {
740
+ // Known clone-local/derived artifacts are never staged. Everything
741
+ // else is a topology violation: a shared graph repository cannot
742
+ // quietly carry arbitrary source alongside its JSON memory.
743
+ continue;
744
+ }
745
+ else {
746
+ return false;
747
+ }
748
+ }
749
+ return true;
750
+ };
751
+ return walk(root) ? paths.sort() : null;
752
+ }
753
+ catch {
754
+ return null;
755
+ }
756
+ }
757
+ /** True only when the checked-out Hunch tree has no staged, unstaged, untracked,
758
+ * or conflicted memory bytes. Remote sync must never use autostash: applying a
759
+ * stash can feed a user's uncommitted record through the structured merge driver
760
+ * and replace it with a higher-confidence remote record. */
761
+ function hunchWorktreeClean(hunchDir, env) {
762
+ try {
763
+ return execFileSync("git", ["-C", hunchDir, "status", "--porcelain=v1", "--untracked-files=all", "--", "."], {
764
+ encoding: "utf8",
765
+ stdio: ["ignore", "pipe", "ignore"],
766
+ env,
767
+ timeout: 2_000,
768
+ }).trim() === "";
769
+ }
770
+ catch {
771
+ return false;
772
+ }
773
+ }
774
+ function overlayGitTreeIsSafe(hunchDir, revision, env) {
775
+ if (!/^[0-9a-f]+$/i.test(revision))
776
+ return false;
777
+ try {
778
+ const listing = execFileSync("git", ["-C", hunchDir, "ls-tree", "--full-tree", "-r", "-t", "-z", revision], {
779
+ encoding: "utf8",
780
+ stdio: ["ignore", "pipe", "ignore"],
781
+ env,
782
+ timeout: 2_000,
783
+ maxBuffer: 64 * 1024 * 1024,
784
+ });
785
+ return hunchTreeAttributesAreSafe(listing, (oid) => {
786
+ try {
787
+ return execFileSync("git", ["-C", hunchDir, "cat-file", "blob", oid], {
788
+ encoding: "utf8",
789
+ stdio: ["ignore", "pipe", "ignore"],
790
+ env,
791
+ timeout: 2_000,
792
+ maxBuffer: 4 * 1024 * 1024,
793
+ });
794
+ }
795
+ catch {
796
+ return null;
797
+ }
798
+ });
799
+ }
800
+ catch {
801
+ return false;
802
+ }
803
+ }
804
+ const MAX_ATTRIBUTES_BYTES = 4 * 1024 * 1024;
805
+ function boundedAttributesFileIsSafe(file, expectedCanonicalPath) {
806
+ try {
807
+ const stat = lstatSync(file);
808
+ if (stat.isSymbolicLink() || !stat.isFile() || stat.nlink !== 1 || stat.size > MAX_ATTRIBUTES_BYTES)
809
+ return false;
810
+ if (!sameFilesystemEntry(file, expectedCanonicalPath))
811
+ return false;
812
+ return hunchAttributesAreSafe(readFileSync(file, "utf8"));
813
+ }
814
+ catch (error) {
815
+ return error.code === "ENOENT";
816
+ }
817
+ }
818
+ function absoluteGitMetadataDir(cwd, env) {
819
+ try {
820
+ const raw = execFileSync("git", ["-C", cwd, "rev-parse", "--absolute-git-dir"], {
821
+ encoding: "utf8",
822
+ stdio: ["ignore", "pipe", "ignore"],
823
+ env,
824
+ timeout: 2_000,
825
+ }).trim();
826
+ if (!raw)
827
+ return null;
828
+ const canonical = realpathSync(raw);
829
+ const stat = lstatSync(canonical);
830
+ return !stat.isSymbolicLink() && stat.isDirectory() ? canonical : null;
831
+ }
832
+ catch {
833
+ return null;
834
+ }
835
+ }
836
+ function overlayGitMetadataDir(hunchDir, env) {
837
+ try {
838
+ const overlayRoot = dirname(realpathSync(hunchDir));
839
+ const expected = realpathSync(join(overlayRoot, ".git"));
840
+ const actual = absoluteGitMetadataDir(hunchDir, env);
841
+ return actual && sameFilesystemEntry(actual, expected) ? expected : null;
842
+ }
843
+ catch {
844
+ return null;
845
+ }
846
+ }
847
+ /** Attribute sources not stored in the fetched tree can still influence Git's
848
+ * clean/smudge pipeline. Validate the live root and nested Hunch attributes and
849
+ * the otherwise-unavoidable $GIT_DIR/info/attributes before any add/merge/reset.
850
+ * Global and system sources are separately disabled on the command itself. */
851
+ function overlayAttributeSourcesAreSafe(hunchDir, env) {
852
+ try {
853
+ const canonicalHunch = realpathSync(hunchDir);
854
+ const overlayRoot = dirname(canonicalHunch);
855
+ if (canonicalHunch !== join(overlayRoot, ".hunch") || !safeOverlayTree(overlayRoot))
856
+ return false;
857
+ const gitDir = overlayGitMetadataDir(hunchDir, env);
858
+ if (!gitDir)
859
+ return false;
860
+ if (!boundedAttributesFileIsSafe(join(overlayRoot, ".gitattributes"), join(overlayRoot, ".gitattributes"))
861
+ || !boundedAttributesFileIsSafe(join(gitDir, "info", "attributes"), join(gitDir, "info", "attributes"))) {
862
+ return false;
863
+ }
864
+ const walk = (dir) => {
865
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
866
+ if (dir === canonicalHunch && entry.name === ".hunch-commit.lock")
867
+ continue;
868
+ const path = join(dir, entry.name);
869
+ const stat = lstatSync(path);
870
+ if (stat.isSymbolicLink())
871
+ return false;
872
+ if (stat.isDirectory()) {
873
+ if (!walk(path))
874
+ return false;
875
+ }
876
+ else if (!stat.isFile()) {
877
+ return false;
878
+ }
879
+ else if (entry.name === ".gitattributes"
880
+ && !boundedAttributesFileIsSafe(path, path)) {
881
+ return false;
882
+ }
883
+ }
884
+ return true;
885
+ };
886
+ return walk(canonicalHunch);
887
+ }
888
+ catch {
889
+ return false;
890
+ }
891
+ }
892
+ const TEAM_FETCH_REF = "refs/hunch/team-sync";
893
+ function contractReady(contract) {
894
+ if (!contract)
895
+ return true;
896
+ if (!contract.fetchUrl || !contract.pushUrl || !/^refs\/heads\/[A-Za-z0-9][A-Za-z0-9._/-]*$/.test(contract.ref)) {
897
+ return false;
898
+ }
899
+ try {
900
+ return contract.verify();
901
+ }
902
+ catch {
903
+ return false;
904
+ }
905
+ }
906
+ /** Team network operations deliberately ignore process-level Git command/config
907
+ * injection. Repository-local route overrides are rejected by the contract
908
+ * verifier; these variables are the remaining ambient way to replace SSH or add
909
+ * arbitrary `-c` entries between validation and use. */
910
+ function boundedTeamEnv(env) {
911
+ const bounded = foreignRepoEnv(env);
912
+ for (const key of Object.keys(bounded)) {
913
+ if (key === "GIT_SSH" || key === "GIT_SSH_COMMAND" || key === "GIT_CONFIG_PARAMETERS"
914
+ || key === "GIT_CONFIG_COUNT" || /^GIT_CONFIG_(?:KEY|VALUE)_\d+$/.test(key)) {
915
+ delete bounded[key];
916
+ }
917
+ }
918
+ bounded.GIT_CONFIG_NOSYSTEM = "1";
919
+ bounded.GIT_ATTR_NOSYSTEM = "1";
920
+ bounded.GIT_ALLOW_PROTOCOL = "https:ssh:git:file";
921
+ bounded.GIT_TERMINAL_PROMPT = "0";
922
+ return bounded;
923
+ }
924
+ function disabledHooksDir(hunchDir) {
925
+ try {
926
+ const gitDir = absoluteGitMetadataDir(hunchDir, foreignRepoEnv(process.env));
927
+ if (!gitDir)
928
+ return null;
929
+ const hooksDir = join(gitDir, "hunch-disabled-hooks");
930
+ mkdirSync(hooksDir, { recursive: true });
931
+ const stat = lstatSync(hooksDir);
932
+ if (stat.isSymbolicLink() || !stat.isDirectory()
933
+ || relative(gitDir, realpathSync(hooksDir)) !== "hunch-disabled-hooks"
934
+ || readdirSync(hooksDir).length !== 0)
935
+ return null;
936
+ return hooksDir;
937
+ }
938
+ catch {
939
+ return null;
940
+ }
941
+ }
942
+ /** List every branch on the exact contract URL. A team graph has either zero
943
+ * branches while bootstrapping or exactly its one canonical branch. */
944
+ function contractRemoteHeads(hunchDir, contract, direction, env, timeoutMs) {
945
+ if (!contractReady(contract))
946
+ return null;
947
+ const url = direction === "fetch" ? contract.fetchUrl : contract.pushUrl;
948
+ const hooksDir = disabledHooksDir(hunchDir);
949
+ if (!hooksDir)
950
+ return null;
951
+ try {
952
+ const out = execFileSync("git", [
953
+ "-C", hunchDir,
954
+ "-c", `core.hooksPath=${hooksDir}`,
955
+ "ls-remote", "--refs", "--heads", "--upload-pack=git-upload-pack", url,
956
+ ], {
957
+ encoding: "utf8",
958
+ stdio: ["ignore", "pipe", "ignore"],
959
+ env: boundedTeamEnv(env),
960
+ timeout: timeoutMs,
961
+ });
962
+ if (!contractReady(contract))
963
+ return null;
964
+ const heads = [];
965
+ for (const line of out.split(/\r?\n/).filter(Boolean)) {
966
+ const match = line.match(/^([0-9a-f]{40,64})\t(refs\/heads\/.+)$/i);
967
+ if (!match)
968
+ return null;
969
+ heads.push({ oid: match[1], ref: match[2] });
970
+ }
971
+ return heads;
972
+ }
973
+ catch {
974
+ return null;
975
+ }
976
+ }
977
+ function fetchContractRef(hunchDir, contract, env, timeoutMs) {
978
+ if (!contractReady(contract))
979
+ return "";
980
+ const hooksDir = disabledHooksDir(hunchDir);
981
+ if (!hooksDir)
982
+ return "";
983
+ try {
984
+ execFileSync("git", [
985
+ "-C", hunchDir,
986
+ "-c", `core.hooksPath=${hooksDir}`,
987
+ "fetch", "--no-tags", "--no-write-fetch-head", "--upload-pack=git-upload-pack",
988
+ contract.fetchUrl, `+${contract.ref}:${TEAM_FETCH_REF}`,
989
+ ], { stdio: "ignore", env: boundedTeamEnv(env), timeout: timeoutMs });
990
+ if (!contractReady(contract))
991
+ return "";
992
+ const oid = execFileSync("git", ["-C", hunchDir, "rev-parse", "--verify", TEAM_FETCH_REF], {
993
+ encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], env, timeout: 2_000,
994
+ }).trim();
995
+ return /^[0-9a-f]{40,64}$/i.test(oid) ? oid : "";
996
+ }
997
+ catch {
998
+ return "";
999
+ }
1000
+ }
1001
+ function setContractUpstream(hunchDir, contract, env) {
1002
+ try {
1003
+ const branch = execFileSync("git", ["-C", hunchDir, "symbolic-ref", "--quiet", "--short", "HEAD"], {
1004
+ encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], env, timeout: 2_000,
1005
+ }).trim();
1006
+ if (!branch)
1007
+ return false;
1008
+ const remoteBranch = contract.ref.slice("refs/heads/".length);
1009
+ let oid = gitSafeWithEnv(["rev-parse", "--verify", TEAM_FETCH_REF], hunchDir, env);
1010
+ if (!oid)
1011
+ oid = headShaWithEnv(hunchDir, env);
1012
+ if (!oid || !remoteBranch)
1013
+ return false;
1014
+ execFileSync("git", ["-C", hunchDir, "update-ref", `refs/remotes/origin/${remoteBranch}`, oid], {
1015
+ stdio: "ignore", env, timeout: 2_000,
1016
+ });
1017
+ execFileSync("git", ["-C", hunchDir, "config", `branch.${branch}.remote`, "origin"], { stdio: "ignore", env, timeout: 2_000 });
1018
+ execFileSync("git", ["-C", hunchDir, "config", `branch.${branch}.merge`, contract.ref], { stdio: "ignore", env, timeout: 2_000 });
1019
+ return contractReady(contract);
1020
+ }
1021
+ catch {
1022
+ return false;
1023
+ }
1024
+ }
1025
+ /** A fresh clone of an empty remote has no HEAD, but auto-wiring creates the
1026
+ * default manifest before another teammate may publish first. That one generated
1027
+ * file is safe to replace with the now-canonical remote tree; any actual record or
1028
+ * other dirty path makes bootstrap fail closed. */
1029
+ function unbornBootstrapFingerprint(hunchDir, env) {
1030
+ try {
1031
+ const status = execFileSync("git", ["-C", hunchDir, "status", "--porcelain=v1", "--untracked-files=all", "--", "."], {
1032
+ encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], env, timeout: 2_000,
1033
+ }).trim();
1034
+ const lines = status ? status.split(/\r?\n/) : [];
1035
+ if (lines.some((line) => !/^\?\? (?:\.hunch\/)?manifest\.json$/.test(line.trim())))
1036
+ return null;
1037
+ let manifest = "";
1038
+ try {
1039
+ manifest = readFileSync(join(hunchDir, "manifest.json"), "utf8");
1040
+ const value = JSON.parse(manifest);
1041
+ if (!value || Array.isArray(value) || typeof value !== "object"
1042
+ || Object.keys(value).some((key) => key !== "schema_version")
1043
+ || typeof value.schema_version !== "number")
1044
+ return null;
1045
+ }
1046
+ catch {
1047
+ if (lines.length)
1048
+ return null;
1049
+ }
1050
+ return createHash("sha256").update(status).update("\0").update(manifest).digest("hex");
1051
+ }
1052
+ catch {
1053
+ return null;
1054
+ }
1055
+ }
1056
+ function adoptContractHead(hunchDir, fetchedHead, contract, env, fingerprint) {
1057
+ if (unbornBootstrapFingerprint(hunchDir, env) !== fingerprint || !contractReady(contract))
1058
+ return false;
1059
+ const hooksDir = disabledHooksDir(hunchDir);
1060
+ if (!hooksDir)
1061
+ return false;
1062
+ const manifest = join(hunchDir, "manifest.json");
1063
+ const backup = join(hunchDir, ".hunch-commit.lock", "bootstrap-manifest.json");
1064
+ let moved = false;
1065
+ try {
1066
+ try {
1067
+ renameSync(manifest, backup);
1068
+ moved = true;
1069
+ }
1070
+ catch { /* absent default manifest */ }
1071
+ execFileSync("git", [
1072
+ "-C", hunchDir,
1073
+ "-c", `core.hooksPath=${hooksDir}`,
1074
+ "-c", `core.attributesFile=${gitNullDevice()}`,
1075
+ "reset", "--hard", fetchedHead,
1076
+ ], {
1077
+ stdio: "ignore", env, timeout: 5_000,
1078
+ });
1079
+ return overlayGitTreeIsSafe(hunchDir, headShaWithEnv(hunchDir, env), env)
1080
+ && overlayAttributeSourcesAreSafe(hunchDir, env);
1081
+ }
1082
+ catch {
1083
+ if (moved) {
1084
+ try {
1085
+ renameSync(backup, manifest);
1086
+ }
1087
+ catch { /* leave the durable backup under the lock */ }
1088
+ }
1089
+ return false;
1090
+ }
1091
+ finally {
1092
+ if (moved)
1093
+ try {
1094
+ rmSync(backup, { force: true });
1095
+ }
1096
+ catch { /* lock cleanup is the final backstop */ }
1097
+ }
1098
+ }
1099
+ function mergeRemote(hunchDir, env, timeoutMs, contract, allowUnrelatedHistories = false) {
1100
+ env = machineCommitEnv(hunchDir, env);
1101
+ env.GIT_ATTR_NOSYSTEM = "1";
1102
+ let overlayRoot = "";
1103
+ try {
1104
+ overlayRoot = dirname(realpathSync(hunchDir));
1105
+ }
1106
+ catch {
1107
+ return "failed";
1108
+ }
1109
+ if (!isGitRepoRoot(overlayRoot) || !safeOverlayTree(overlayRoot)
1110
+ || !overlayAttributeSourcesAreSafe(hunchDir, env) || !contractReady(contract)) {
1111
+ return "failed";
1112
+ }
1113
+ const localHead = headShaWithEnv(hunchDir, env);
1114
+ const unbornFingerprint = localHead ? null : unbornBootstrapFingerprint(hunchDir, env);
1115
+ if (localHead ? !hunchWorktreeClean(hunchDir, env) : !unbornFingerprint)
1116
+ return "failed";
1117
+ const hooksDir = disabledHooksDir(hunchDir);
1118
+ if (!hooksDir)
1119
+ return "failed";
1120
+ const tryGit = (args, timeout = timeoutMs) => {
180
1121
  try {
181
- execFileSync("git", ["-C", hunchDir, ...args], { stdio: "ignore", env });
1122
+ execFileSync("git", [
1123
+ "-C", hunchDir,
1124
+ "-c", `core.hooksPath=${hooksDir}`,
1125
+ "-c", `core.attributesFile=${gitNullDevice()}`,
1126
+ "-c", "commit.gpgsign=false",
1127
+ ...args,
1128
+ ], {
1129
+ stdio: "ignore", env, timeout,
1130
+ });
182
1131
  return true;
183
1132
  }
184
1133
  catch {
185
1134
  return false;
186
1135
  }
187
1136
  };
188
- if (!tryGit(["rev-parse", "--abbrev-ref", "@{upstream}"]))
189
- return true; // no remote/offline → push no-ops
190
- if (tryGit(["pull", "--no-edit", "--no-rebase", "--autostash"]))
1137
+ let fetchedHead = "";
1138
+ if (contract) {
1139
+ const heads = contractRemoteHeads(hunchDir, contract, "fetch", env, timeoutMs);
1140
+ if (!heads)
1141
+ return "failed";
1142
+ if (heads.length === 0)
1143
+ return "unconfigured";
1144
+ if (heads.length !== 1 || heads[0].ref !== contract.ref)
1145
+ return "failed";
1146
+ fetchedHead = fetchContractRef(hunchDir, contract, env, timeoutMs);
1147
+ }
1148
+ else {
1149
+ if (!tryGit(["rev-parse", "--abbrev-ref", "@{upstream}"], 2_000))
1150
+ return "unconfigured";
1151
+ if (!tryGit(["fetch", "--no-tags"]))
1152
+ return "failed";
1153
+ fetchedHead = upstreamSha(hunchDir, env);
1154
+ }
1155
+ if (!fetchedHead || !overlayGitTreeIsSafe(hunchDir, fetchedHead, env))
1156
+ return "failed";
1157
+ // Another process need not honor Hunch's lock. Re-prove both the exact local
1158
+ // revision and the filesystem boundary after the network operation, before
1159
+ // Git is permitted to materialize the already-validated fetched tree.
1160
+ if (headShaWithEnv(hunchDir, env) !== localHead
1161
+ || !safeOverlayTree(overlayRoot)
1162
+ || !overlayAttributeSourcesAreSafe(hunchDir, env)
1163
+ || (localHead ? !hunchWorktreeClean(hunchDir, env) : unbornBootstrapFingerprint(hunchDir, env) !== unbornFingerprint)
1164
+ || (localHead && !overlayGitTreeIsSafe(hunchDir, localHead, env))
1165
+ || !contractReady(contract))
1166
+ return "failed";
1167
+ // Canonicalize local branch/upstream metadata before the worktree mutation.
1168
+ // If this fails, the exact fetched tree has not been materialized yet.
1169
+ if (contract && !setContractUpstream(hunchDir, contract, env))
1170
+ return "failed";
1171
+ if (!localHead && contract && unbornFingerprint) {
1172
+ return adoptContractHead(hunchDir, fetchedHead, contract, env, unbornFingerprint) ? "merged" : "failed";
1173
+ }
1174
+ if (tryGit(["merge", "--no-edit", ...(allowUnrelatedHistories ? ["--allow-unrelated-histories"] : []), fetchedHead])) {
1175
+ return safeOverlayTree(overlayRoot)
1176
+ && overlayAttributeSourcesAreSafe(hunchDir, env)
1177
+ && contractReady(contract)
1178
+ && overlayGitTreeIsSafe(hunchDir, headShaWithEnv(hunchDir, env), env)
1179
+ ? "merged"
1180
+ : "failed";
1181
+ }
1182
+ tryGit(["merge", "--abort"], 2_000); // conflict/timeout → restore a clean tree
1183
+ return "failed";
1184
+ }
1185
+ function upstreamSha(hunchDir, env) {
1186
+ try {
1187
+ return execFileSync("git", ["-C", hunchDir, "rev-parse", "@{upstream}"], {
1188
+ encoding: "utf8",
1189
+ stdio: ["ignore", "pipe", "ignore"],
1190
+ env,
1191
+ timeout: 2_000,
1192
+ }).trim();
1193
+ }
1194
+ catch {
1195
+ return "";
1196
+ }
1197
+ }
1198
+ function tryPush(hunchDir, env, timeoutMs, contract) {
1199
+ if (!contractReady(contract))
1200
+ return false;
1201
+ const hooksDir = disabledHooksDir(hunchDir);
1202
+ if (!hooksDir)
1203
+ return false;
1204
+ try {
1205
+ if (contract && !setContractUpstream(hunchDir, contract, env))
1206
+ return false;
1207
+ const args = contract
1208
+ ? ["-C", hunchDir, "-c", `core.hooksPath=${hooksDir}`, "push", "--receive-pack=git-receive-pack", contract.pushUrl, `HEAD:${contract.ref}`]
1209
+ : ["-C", hunchDir, "-c", `core.hooksPath=${hooksDir}`, "push"];
1210
+ execFileSync("git", args, { stdio: "ignore", env: contract ? boundedTeamEnv(env) : env, timeout: timeoutMs });
1211
+ return contractReady(contract);
1212
+ }
1213
+ catch {
1214
+ return false;
1215
+ }
1216
+ }
1217
+ /** Publish an unborn/untracked local branch to a genuinely empty shared remote
1218
+ * and establish its upstream. This path is deliberately narrower than a generic
1219
+ * `push -u`: it selects only an explicit configured push remote (or the sole
1220
+ * remote), proves that remote has no refs, snapshots its identity across the
1221
+ * network check, and uses a non-force push so a concurrent first writer wins
1222
+ * safely instead of being overwritten. */
1223
+ function establishEmptyRemoteUpstream(hunchDir, env, protectedRepoRoot, timeoutMs, contract) {
1224
+ if (unsafeOverlayPublication(hunchDir, protectedRepoRoot)
1225
+ || !hunchWorktreeClean(hunchDir, env)
1226
+ || !contractReady(contract))
1227
+ return false;
1228
+ if (contract) {
1229
+ const heads = contractRemoteHeads(hunchDir, contract, "push", env, timeoutMs);
1230
+ if (!heads || heads.length !== 0 || !contractReady(contract))
1231
+ return false;
1232
+ // Exact URL + exact canonical ref + non-force push. If another teammate wins
1233
+ // the first-writer race after the empty proof, Git rejects this safely and the
1234
+ // bounded retry path fetches/merges that winner.
1235
+ return tryPush(hunchDir, env, timeoutMs, contract);
1236
+ }
1237
+ let branch = "";
1238
+ let remotes = [];
1239
+ let preferred = "";
1240
+ try {
1241
+ branch = execFileSync("git", ["-C", hunchDir, "symbolic-ref", "--quiet", "--short", "HEAD"], {
1242
+ encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], env, timeout: 2_000,
1243
+ }).trim();
1244
+ remotes = execFileSync("git", ["-C", hunchDir, "remote"], {
1245
+ encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], env, timeout: 2_000,
1246
+ }).split("\n").map((remote) => remote.trim()).filter(Boolean);
1247
+ preferred = execFileSync("git", ["-C", hunchDir, "config", "--get", "remote.pushDefault"], {
1248
+ encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], env, timeout: 2_000,
1249
+ }).trim();
1250
+ }
1251
+ catch {
1252
+ // A missing remote.pushDefault is normal; choose the sole remote below.
1253
+ }
1254
+ const remote = preferred && remotes.includes(preferred)
1255
+ ? preferred
1256
+ : remotes.length === 1 ? remotes[0] : "";
1257
+ if (!branch || !remote)
1258
+ return false;
1259
+ const pushIdentity = () => {
1260
+ try {
1261
+ const url = execFileSync("git", ["-C", hunchDir, "remote", "get-url", "--push", remote], {
1262
+ encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], env, timeout: 2_000,
1263
+ }).trim();
1264
+ return remoteIdentity(url, hunchDir);
1265
+ }
1266
+ catch {
1267
+ return "";
1268
+ }
1269
+ };
1270
+ const before = pushIdentity();
1271
+ if (!before)
1272
+ return false;
1273
+ try {
1274
+ const refs = execFileSync("git", ["-C", hunchDir, "ls-remote", "--refs", remote], {
1275
+ encoding: "utf8",
1276
+ stdio: ["ignore", "pipe", "ignore"],
1277
+ env,
1278
+ timeout: timeoutMs,
1279
+ }).trim();
1280
+ if (refs)
1281
+ return false;
1282
+ }
1283
+ catch {
1284
+ return false;
1285
+ }
1286
+ if (unsafeOverlayPublication(hunchDir, protectedRepoRoot)
1287
+ || !hunchWorktreeClean(hunchDir, env)
1288
+ || pushIdentity() !== before)
1289
+ return false;
1290
+ try {
1291
+ const hooksDir = disabledHooksDir(hunchDir);
1292
+ if (!hooksDir)
1293
+ return false;
1294
+ execFileSync("git", ["-C", hunchDir, "-c", `core.hooksPath=${hooksDir}`, "push", "--set-upstream", remote, `HEAD:refs/heads/${branch}`], {
1295
+ stdio: "ignore",
1296
+ env,
1297
+ timeout: timeoutMs,
1298
+ });
191
1299
  return true;
192
- tryGit(["merge", "--abort"]); // conflict the driver couldn't resolve → restore a clean tree
193
- return false;
1300
+ }
1301
+ catch {
1302
+ return false;
1303
+ }
1304
+ }
1305
+ /** Push once, then retry exactly once only when a bounded pull proves the upstream
1306
+ * advanced during the first push seam. Offline/auth/hook failures with an unchanged
1307
+ * upstream never loop, and every remote mutation re-proves the publication boundary. */
1308
+ function pushWithOneRemoteAdvanceRetry(hunchDir, env, protectedRepoRoot, timeoutMs, contract) {
1309
+ if (unsafeOverlayPublication(hunchDir, protectedRepoRoot) || !contractReady(contract))
1310
+ return false;
1311
+ const before = contract
1312
+ ? gitSafeWithEnv(["rev-parse", "--verify", TEAM_FETCH_REF], hunchDir, env)
1313
+ : upstreamSha(hunchDir, env);
1314
+ if (!before)
1315
+ return establishEmptyRemoteUpstream(hunchDir, env, protectedRepoRoot, timeoutMs, contract);
1316
+ if (tryPush(hunchDir, env, timeoutMs, contract))
1317
+ return true;
1318
+ if (unsafeOverlayPublication(hunchDir, protectedRepoRoot) || !contractReady(contract))
1319
+ return false;
1320
+ const merged = mergeRemote(hunchDir, env, timeoutMs, contract);
1321
+ const after = contract
1322
+ ? gitSafeWithEnv(["rev-parse", "--verify", TEAM_FETCH_REF], hunchDir, env)
1323
+ : upstreamSha(hunchDir, env);
1324
+ if (merged !== "merged" || !before || !after || before === after)
1325
+ return false;
1326
+ if (unsafeOverlayPublication(hunchDir, protectedRepoRoot) || !contractReady(contract))
1327
+ return false;
1328
+ return tryPush(hunchDir, env, timeoutMs, contract);
1329
+ }
1330
+ export function pullHunchStatus(hunchDir, opts = {}) {
1331
+ const env = foreignRepoEnv({
1332
+ ...process.env,
1333
+ ...opts.env,
1334
+ HUNCH_SYNC: "1",
1335
+ GIT_TERMINAL_PROMPT: "0",
1336
+ GIT_MERGE_AUTOEDIT: "no",
1337
+ });
1338
+ const before = headShaWithEnv(hunchDir, env);
1339
+ const lock = join(hunchDir, ".hunch-commit.lock");
1340
+ if (acquireCommitLock(lock).state !== "acquired")
1341
+ return "busy";
1342
+ try {
1343
+ const merged = mergeRemote(hunchDir, env, opts.timeoutMs ?? READ_REMOTE_TIMEOUT_MS, opts.remote, opts.allowUnrelatedHistories ?? false);
1344
+ if (merged === "failed" || merged === "unconfigured" || !contractReady(opts.remote)) {
1345
+ return merged === "unconfigured" ? merged : "failed";
1346
+ }
1347
+ return headShaWithEnv(hunchDir, env) !== before ? "updated" : "current";
1348
+ }
1349
+ finally {
1350
+ try {
1351
+ rmSync(lock, { recursive: true, force: true });
1352
+ }
1353
+ catch { /* released best-effort */ }
1354
+ }
194
1355
  }
195
- /** Best-effort READ-side sync: merge the overlay's remote into the local branch (e.g. on MCP
196
- * server start) so this machine/session sees other machines' memory. Never throws; leaves a
197
- * clean tree. Serialized with the commit lock so it can't race a concurrent flush. */
198
1356
  export function pullHunch(hunchDir) {
1357
+ return pullHunchStatus(hunchDir) === "updated";
1358
+ }
1359
+ /** Explicit retry path for a clean overlay that already has a local memory commit
1360
+ * stranded by an earlier offline/rejected push. Unlike commitAndPushHunch this
1361
+ * creates no commit: it only converges and publishes existing overlay history. */
1362
+ export function syncExistingHunch(hunchDir, protectedRepoRoot, timeoutMs = CAPTURE_REMOTE_TIMEOUT_MS, remote) {
1363
+ if (unsafeOverlayPublication(hunchDir, protectedRepoRoot) || !contractReady(remote))
1364
+ return "failed";
199
1365
  const lock = join(hunchDir, ".hunch-commit.lock");
200
- if (!acquireCommitLock(lock))
201
- return;
1366
+ if (acquireCommitLock(lock).state !== "acquired")
1367
+ return "failed";
202
1368
  try {
203
- mergeRemote(hunchDir, { ...process.env, HUNCH_SYNC: "1" });
1369
+ const env = foreignRepoEnv({
1370
+ ...process.env,
1371
+ HUNCH_SYNC: "1",
1372
+ GIT_TERMINAL_PROMPT: "0",
1373
+ GIT_MERGE_AUTOEDIT: "no",
1374
+ });
1375
+ const merged = mergeRemote(hunchDir, env, timeoutMs, remote);
1376
+ if (merged === "failed" || unsafeOverlayPublication(hunchDir, protectedRepoRoot) || !contractReady(remote))
1377
+ return "failed";
1378
+ if (merged === "unconfigured") {
1379
+ return pushWithOneRemoteAdvanceRetry(hunchDir, env, protectedRepoRoot, timeoutMs, remote) ? "pushed" : "failed";
1380
+ }
1381
+ const upstream = remote
1382
+ ? gitSafeWithEnv(["rev-parse", "--verify", TEAM_FETCH_REF], hunchDir, env)
1383
+ : upstreamSha(hunchDir, env);
1384
+ if (!upstream)
1385
+ return "failed";
1386
+ let ahead = false;
1387
+ try {
1388
+ ahead = execFileSync("git", ["-C", hunchDir, "rev-list", "--count", `${upstream}..HEAD`], {
1389
+ encoding: "utf8",
1390
+ stdio: ["ignore", "pipe", "ignore"],
1391
+ env,
1392
+ timeout: 2_000,
1393
+ }).trim() !== "0";
1394
+ }
1395
+ catch {
1396
+ return "failed";
1397
+ }
1398
+ if (!ahead)
1399
+ return "current";
1400
+ return pushWithOneRemoteAdvanceRetry(hunchDir, env, protectedRepoRoot, timeoutMs, remote) ? "pushed" : "failed";
204
1401
  }
205
1402
  finally {
206
1403
  try {
@@ -209,28 +1406,127 @@ export function pullHunch(hunchDir) {
209
1406
  catch { /* released best-effort */ }
210
1407
  }
211
1408
  }
212
- /** Atomic mkdir lock; reclaims a stale lock (a crashed holder) older than 60s. Returns
213
- * false when another live holder owns it — the caller skips rather than blocks. */
214
- function acquireCommitLock(lock) {
1409
+ const UNKNOWN_LOCK_STALE_MS = 10 * 60_000;
1410
+ function createOwnedCommitLock(lock) {
1411
+ let created = false;
215
1412
  try {
1413
+ // mkdir is the exclusive atomic operation here. Renaming a staged directory
1414
+ // is NOT exclusive on POSIX: it may replace an already-existing empty lock
1415
+ // directory, which would steal a fresh legacy/ownerless lock. There is a
1416
+ // harmless ownerless window between these two mkdir calls; contenders treat
1417
+ // it as held until the conservative legacy TTL expires.
216
1418
  mkdirSync(lock);
1419
+ created = true;
1420
+ // Empty directories are not Git worktree entries, so owner metadata cannot
1421
+ // be staged by the memory-only `git add .` seam.
1422
+ mkdirSync(join(lock, `owner-${process.pid}`));
217
1423
  return true;
218
1424
  }
219
1425
  catch {
220
- try {
221
- if (Date.now() - statSync(lock).mtimeMs > 60_000) {
1426
+ // If the exclusive mkdir succeeded but owner creation somehow failed, only
1427
+ // this process can have created the still-ownerless directory. Remove it so
1428
+ // a transient local failure does not strand every writer for the full TTL.
1429
+ if (created && !commitLockOwner(lock)) {
1430
+ try {
222
1431
  rmSync(lock, { recursive: true, force: true });
223
- mkdirSync(lock);
224
- return true;
225
1432
  }
1433
+ catch { /* best effort */ }
1434
+ }
1435
+ return false;
1436
+ }
1437
+ }
1438
+ function commitLockOwner(lock) {
1439
+ try {
1440
+ for (const name of readdirSync(lock)) {
1441
+ const match = name.match(/^owner-([1-9][0-9]*)$/);
1442
+ if (match)
1443
+ return { name, pid: Number(match[1]) };
1444
+ }
1445
+ }
1446
+ catch { /* vanished or unreadable */ }
1447
+ return null;
1448
+ }
1449
+ function processIsAlive(pid) {
1450
+ try {
1451
+ process.kill(pid, 0);
1452
+ return true;
1453
+ }
1454
+ catch (error) {
1455
+ return error.code === "EPERM";
1456
+ }
1457
+ }
1458
+ /** Atomic directory lock with PID ownership. A dead owner is recoverable
1459
+ * immediately; a legacy owner-less lock is reclaimed only after a TTL safely
1460
+ * above the longest bounded sync. The fixed reclaim directory serializes
1461
+ * competing crash recovery attempts and the owner is re-read after claiming. */
1462
+ function acquireCommitLock(lock) {
1463
+ if (createOwnedCommitLock(lock))
1464
+ return { state: "acquired" };
1465
+ const observed = commitLockOwner(lock);
1466
+ if (observed && processIsAlive(observed.pid))
1467
+ return { state: "held-live", ownerPid: observed.pid };
1468
+ let reclaimable = !!observed;
1469
+ if (!observed) {
1470
+ try {
1471
+ reclaimable = Date.now() - statSync(lock).mtimeMs > UNKNOWN_LOCK_STALE_MS;
1472
+ }
1473
+ catch {
1474
+ return createOwnedCommitLock(lock) ? { state: "acquired" } : { state: "held-unknown" };
226
1475
  }
227
- catch { /* lock vanished or races another reclaimer — treat as held */ }
1476
+ }
1477
+ if (!reclaimable)
1478
+ return { state: "held-unknown" };
1479
+ const claim = join(lock, "reclaim");
1480
+ try {
1481
+ mkdirSync(claim);
1482
+ }
1483
+ catch {
1484
+ return { state: "held-unknown" };
1485
+ }
1486
+ const current = commitLockOwner(lock);
1487
+ if ((observed && (!current || current.name !== observed.name || processIsAlive(current.pid)))
1488
+ || (!observed && current)) {
1489
+ try {
1490
+ rmSync(claim, { recursive: true, force: true });
1491
+ }
1492
+ catch { /* best effort */ }
1493
+ return current && processIsAlive(current.pid)
1494
+ ? { state: "held-live", ownerPid: current.pid }
1495
+ : { state: "held-unknown" };
1496
+ }
1497
+ try {
1498
+ rmSync(lock, { recursive: true, force: true });
1499
+ }
1500
+ catch {
1501
+ return { state: "held-unknown" };
1502
+ }
1503
+ return createOwnedCommitLock(lock) ? { state: "acquired" } : { state: "held-unknown" };
1504
+ }
1505
+ function waitForCommitLockHandoff(lock, first, timeoutMs) {
1506
+ if (first.state !== "held-live" || first.ownerPid === process.pid)
228
1507
  return false;
1508
+ const deadline = Date.now() + timeoutMs;
1509
+ const sleeper = new Int32Array(new SharedArrayBuffer(4));
1510
+ let attempt = first;
1511
+ while (Date.now() < deadline) {
1512
+ if (attempt.state === "acquired")
1513
+ return true;
1514
+ if (attempt.state !== "held-live" || attempt.ownerPid === process.pid)
1515
+ return false;
1516
+ Atomics.wait(sleeper, 0, 0, Math.min(25, deadline - Date.now()));
1517
+ attempt = acquireCommitLock(lock);
229
1518
  }
1519
+ return false;
230
1520
  }
231
1521
  export function headSha(cwd) {
232
1522
  return gitSafe(["rev-parse", "HEAD"], cwd);
233
1523
  }
1524
+ /** HEAD for a repository that is not the invocation repository. Unlike
1525
+ * headSha, this deliberately ignores code-repo GIT_DIR/GIT_INDEX_FILE state
1526
+ * inherited from hooks. */
1527
+ export function isolatedHeadSha(cwd) {
1528
+ return gitSafeIsolated(["rev-parse", "HEAD"], cwd);
1529
+ }
234
1530
  /** Stop tracking `paths` in git (remove from the INDEX only — keep the working-tree
235
1531
  * files). Used by `hunch private --migrate` to un-publish the .hunch memory tree
236
1532
  * without deleting it locally. `--ignore-unmatch` makes an already-untracked path a
@@ -319,16 +1615,266 @@ export function pushCurrentBranch(root) {
319
1615
  return false;
320
1616
  }
321
1617
  }
322
- /** Revert a single memory move locally (no push). Returns true on success. A
323
- * conflicting revert is aborted so the working tree is never left half-reverted. */
1618
+ const REVERTABLE_RECORD_DIRS = new Set([
1619
+ "components",
1620
+ "edges",
1621
+ "symbols",
1622
+ "decisions",
1623
+ "bugs",
1624
+ "constraints",
1625
+ "runbooks",
1626
+ "evidence",
1627
+ "corpora",
1628
+ "policies",
1629
+ "proofs",
1630
+ "plans",
1631
+ "dispositions",
1632
+ "shadow",
1633
+ ]);
1634
+ const REVERTABLE_AUXILIARY_PATHS = new Set([
1635
+ ".hunch/manifest.json",
1636
+ ".hunch/config.json",
1637
+ ]);
1638
+ const PARTIALLY_MANAGED_GROUNDING_PATHS = new Set([
1639
+ "AGENTS.md",
1640
+ "CLAUDE.md",
1641
+ ".github/copilot-instructions.md",
1642
+ ]);
1643
+ const FULLY_MANAGED_GROUNDING_PATHS = new Set([
1644
+ ".cursor/rules/hunch.mdc",
1645
+ ".windsurf/rules/hunch.md",
1646
+ ]);
1647
+ const HUNCH_GROUNDING_START = Buffer.from("<!-- HUNCH:START — auto-generated, do not edit by hand -->", "utf8");
1648
+ const HUNCH_GROUNDING_END = Buffer.from("<!-- HUNCH:END -->", "utf8");
1649
+ const HUNCH_GROUNDING_SENTINEL = Buffer.from("\0HUNCH-MANAGED-REGION\0", "utf8");
1650
+ const MAX_REVERT_GROUNDING_BYTES = 8 * 1024 * 1024;
1651
+ const REVERT_TRANSFORM_ATTRIBUTES = ["filter", "working-tree-encoding", "ident", "eol", "text", "crlf", "merge"];
1652
+ function revertGitEnv() {
1653
+ const env = foreignRepoEnv(process.env);
1654
+ // A local refs/replace entry can make a safe-looking SHA behave like an
1655
+ // entirely different commit. Every inspection and the revert itself must see
1656
+ // the literal object graph the user named.
1657
+ env.GIT_NO_REPLACE_OBJECTS = "1";
1658
+ env.GIT_TERMINAL_PROMPT = "0";
1659
+ env.GIT_ATTR_NOSYSTEM = "1";
1660
+ return env;
1661
+ }
1662
+ function revertGitRaw(args, root, env) {
1663
+ try {
1664
+ return execFileSync("git", ["-C", root, ...args], {
1665
+ encoding: "utf8",
1666
+ maxBuffer: 64 * 1024 * 1024,
1667
+ stdio: ["ignore", "pipe", "ignore"],
1668
+ env,
1669
+ timeout: 5_000,
1670
+ });
1671
+ }
1672
+ catch {
1673
+ return null;
1674
+ }
1675
+ }
1676
+ function revertGit(args, root, env) {
1677
+ return revertGitRaw(args, root, env)?.trim() ?? "";
1678
+ }
1679
+ function regularBlobAt(ref, path, root, env) {
1680
+ const raw = revertGitRaw(["ls-tree", "-z", ref, "--", path], root, env);
1681
+ if (raw == null)
1682
+ return null;
1683
+ const tab = raw.indexOf("\t");
1684
+ if (tab < 0 || raw.slice(tab + 1) !== `${path}\0`)
1685
+ return null;
1686
+ const header = raw.slice(0, tab).match(/^(100644|100755) blob ([a-f0-9]{40,64})$/i);
1687
+ return header ? { mode: header[1], oid: header[2] } : null;
1688
+ }
1689
+ function blobBytes(oid, root, env) {
1690
+ try {
1691
+ return execFileSync("git", ["-C", root, "cat-file", "blob", oid], {
1692
+ maxBuffer: MAX_REVERT_GROUNDING_BYTES,
1693
+ stdio: ["ignore", "pipe", "ignore"],
1694
+ env,
1695
+ timeout: 5_000,
1696
+ });
1697
+ }
1698
+ catch {
1699
+ return null;
1700
+ }
1701
+ }
1702
+ /** Preserve every byte outside Hunch's one well-formed managed region. These
1703
+ * documents contain user/team instructions too; reverting a capture must never
1704
+ * roll those bytes back merely because the same commit refreshed Hunch counts. */
1705
+ function groundingEnvelope(blob) {
1706
+ const start = blob.indexOf(HUNCH_GROUNDING_START);
1707
+ if (start < 0 || blob.indexOf(HUNCH_GROUNDING_START, start + HUNCH_GROUNDING_START.length) >= 0)
1708
+ return null;
1709
+ const end = blob.indexOf(HUNCH_GROUNDING_END, start + HUNCH_GROUNDING_START.length);
1710
+ if (end < 0 || blob.indexOf(HUNCH_GROUNDING_END, end + HUNCH_GROUNDING_END.length) >= 0)
1711
+ return null;
1712
+ return Buffer.concat([
1713
+ blob.subarray(0, start),
1714
+ HUNCH_GROUNDING_SENTINEL,
1715
+ blob.subarray(end + HUNCH_GROUNDING_END.length),
1716
+ ]);
1717
+ }
1718
+ function onlyManagedGroundingChanged(before, after, root, env) {
1719
+ if (before.mode !== after.mode)
1720
+ return false;
1721
+ const beforeBytes = blobBytes(before.oid, root, env);
1722
+ const afterBytes = blobBytes(after.oid, root, env);
1723
+ if (!beforeBytes || !afterBytes)
1724
+ return false;
1725
+ const beforeEnvelope = groundingEnvelope(beforeBytes);
1726
+ const afterEnvelope = groundingEnvelope(afterBytes);
1727
+ return !!beforeEnvelope && !!afterEnvelope && beforeEnvelope.equals(afterEnvelope);
1728
+ }
1729
+ function hunchJsonKind(path) {
1730
+ if (REVERTABLE_AUXILIARY_PATHS.has(path))
1731
+ return "auxiliary";
1732
+ const match = path.match(/^\.hunch\/([^/]+)\/([A-Za-z0-9][A-Za-z0-9._-]*\.json)$/);
1733
+ if (!match || !REVERTABLE_RECORD_DIRS.has(match[1]))
1734
+ return null;
1735
+ if ((match[1] === "symbols" || match[1] === "edges") && match[2] !== "index.json")
1736
+ return null;
1737
+ return "record";
1738
+ }
1739
+ function pathsHaveTransformAttributes(paths, root, env) {
1740
+ if (!paths.length)
1741
+ return false;
1742
+ const raw = revertGitRaw(["check-attr", "-z", ...REVERT_TRANSFORM_ATTRIBUTES, "--", ...paths], root, env);
1743
+ if (raw == null)
1744
+ return true;
1745
+ const fields = raw.split("\0");
1746
+ if (fields.at(-1) === "")
1747
+ fields.pop();
1748
+ if (fields.length !== paths.length * REVERT_TRANSFORM_ATTRIBUTES.length * 3)
1749
+ return true;
1750
+ for (let index = 0; index < fields.length; index += 3) {
1751
+ const attribute = fields[index + 1];
1752
+ const value = fields[index + 2];
1753
+ if (!attribute || value == null || !REVERT_TRANSFORM_ATTRIBUTES.includes(attribute))
1754
+ return true;
1755
+ if (value !== "unspecified" && value !== "unset")
1756
+ return true;
1757
+ }
1758
+ return false;
1759
+ }
1760
+ /** Is `sha` one exact, append-only public Hunch move that can be safely
1761
+ * reverted? The timeline is selected with a `.hunch/` pathspec, so a mixed
1762
+ * code+memory commit also appears there; validate the complete commit before
1763
+ * allowing `git revert` to touch the checkout. */
1764
+ function revertableMemoryMove(sha, root) {
1765
+ const env = revertGitEnv();
1766
+ // Accept an exact hexadecimal object id/unique abbreviation only. Resolving
1767
+ // HEAD, a branch, a rev expression, or an option-like string is outside the
1768
+ // CLI's `<sha>` contract and would make the selected target mutable/ambiguous.
1769
+ if (!/^[a-f0-9]{7,64}$/i.test(sha))
1770
+ return null;
1771
+ const commit = revertGit(["rev-parse", "--verify", "--quiet", `${sha}^{commit}`], root, env);
1772
+ if (!/^[a-f0-9]{40,64}$/i.test(commit))
1773
+ return null;
1774
+ // Revert is allowed only from a pristine checkout/index. Include untracked
1775
+ // paths: a successful revert followed by a later broad user commit must not
1776
+ // accidentally publish pre-existing local bytes as part of the undo.
1777
+ if (revertGitRaw(["status", "--porcelain=v1", "-z", "--untracked-files=all"], root, env) !== "")
1778
+ return null;
1779
+ const row = revertGit(["rev-list", "--parents", "-n", "1", commit], root, env)
1780
+ .split(/\s+/)
1781
+ .filter(Boolean);
1782
+ if (row[0] !== commit || row.length > 2)
1783
+ return null; // unknown or merge commit
1784
+ const parent = row[1] ?? null;
1785
+ try {
1786
+ execFileSync("git", ["-C", root, "merge-base", "--is-ancestor", commit, "HEAD"], { stdio: "ignore", env, timeout: 5_000 });
1787
+ }
1788
+ catch {
1789
+ return null; // never apply an unrelated/unpublished history fragment
1790
+ }
1791
+ const raw = revertGitRaw(["diff-tree", "--root", "--no-commit-id", "--name-status", "-r", "-z", commit], root, env);
1792
+ if (raw == null)
1793
+ return null;
1794
+ const fields = raw.split("\0").filter((field) => field !== "");
1795
+ let sawMemoryRecord = false;
1796
+ const paths = [];
1797
+ for (let index = 0; index < fields.length;) {
1798
+ const status = fields[index++];
1799
+ // A legitimate automatic memory move has exactly add/modify entries.
1800
+ // Deletions, renames, copies, type changes, and unknown status codes need a
1801
+ // future tombstone-aware protocol rather than an unrestricted Git revert.
1802
+ if (status !== "A" && status !== "M")
1803
+ return null;
1804
+ const path = fields[index++];
1805
+ if (!path)
1806
+ return null;
1807
+ paths.push(path);
1808
+ const parts = path.split("/");
1809
+ if (parts.some((part) => !part || part === "." || part === ".."))
1810
+ return null;
1811
+ const after = regularBlobAt(commit, path, root, env);
1812
+ if (!after)
1813
+ return null;
1814
+ const before = status === "M" && parent ? regularBlobAt(parent, path, root, env) : null;
1815
+ if (status === "M" && !before)
1816
+ return null;
1817
+ const memoryKind = hunchJsonKind(path);
1818
+ if (memoryKind) {
1819
+ sawMemoryRecord ||= memoryKind === "record";
1820
+ continue;
1821
+ }
1822
+ if (FULLY_MANAGED_GROUNDING_PATHS.has(path))
1823
+ continue;
1824
+ if (PARTIALLY_MANAGED_GROUNDING_PATHS.has(path)) {
1825
+ // Newly-created partially managed docs can contain arbitrary user prose
1826
+ // outside the Hunch block. Only a proven managed-region refresh is safe.
1827
+ if (status !== "M" || !before || !onlyManagedGroundingChanged(before, after, root, env))
1828
+ return null;
1829
+ continue;
1830
+ }
1831
+ return null;
1832
+ }
1833
+ // Manifest/config and grounding are auxiliary to a real graph mutation; they
1834
+ // can never make a routing-only or generated-doc-only commit revertable.
1835
+ if (!sawMemoryRecord || pathsHaveTransformAttributes(paths, root, env))
1836
+ return null;
1837
+ return { commit, paths };
1838
+ }
1839
+ /** Revert one validated memory-only move locally (no push). Returns true on
1840
+ * success. Unsafe targets are refused before mutation; a conflicting revert is
1841
+ * aborted so the working tree is never left half-reverted. */
324
1842
  export function revertMemoryMove(sha, root) {
1843
+ const target = revertableMemoryMove(sha, root);
1844
+ if (!target)
1845
+ return false;
1846
+ const env = revertGitEnv();
325
1847
  try {
326
- execFileSync("git", ["-C", root, "revert", "--no-edit", sha], { stdio: "ignore" });
1848
+ // Recheck the clean boundary after target inspection to narrow the race
1849
+ // between validation and Git taking its own index lock.
1850
+ if (revertGitRaw(["status", "--porcelain=v1", "-z", "--untracked-files=all"], root, env) !== "")
1851
+ return false;
1852
+ if (pathsHaveTransformAttributes(target.paths, root, env))
1853
+ return false;
1854
+ const hooksDir = disabledHooksDir(root);
1855
+ if (!hooksDir)
1856
+ return false;
1857
+ execFileSync("git", [
1858
+ "-C", root,
1859
+ "-c", `core.hooksPath=${hooksDir}`,
1860
+ "-c", "commit.gpgsign=false",
1861
+ "-c", "core.autocrlf=false",
1862
+ "-c", "core.eol=lf",
1863
+ "-c", "core.safecrlf=false",
1864
+ "revert", "--no-edit", "--no-gpg-sign", target.commit,
1865
+ ], { stdio: "ignore", env, timeout: 15_000 });
327
1866
  return true;
328
1867
  }
329
1868
  catch {
330
1869
  try {
331
- execFileSync("git", ["-C", root, "revert", "--abort"], { stdio: "ignore" });
1870
+ execFileSync("git", [
1871
+ "-C", root,
1872
+ "-c", "commit.gpgsign=false",
1873
+ "-c", "core.autocrlf=false",
1874
+ "-c", "core.eol=lf",
1875
+ "-c", "core.safecrlf=false",
1876
+ "revert", "--abort",
1877
+ ], { stdio: "ignore", env, timeout: 5_000 });
332
1878
  }
333
1879
  catch { /* nothing to abort */ }
334
1880
  return false;
@@ -494,14 +2040,14 @@ export function fileGitMetrics(cwd, want, days = 90) {
494
2040
  }
495
2041
  /** Files staged for commit (for `hunch check` pre-commit enforcement). */
496
2042
  export function stagedFiles(cwd) {
497
- const out = gitSafe(["diff", "--cached", "--name-only", "--diff-filter=ACMR"], cwd);
2043
+ const out = gitSafe(["diff", "--cached", "--no-ext-diff", "--no-textconv", "--name-only", "--diff-filter=ACMR"], cwd);
498
2044
  return out ? out.split("\n").filter(Boolean) : [];
499
2045
  }
500
2046
  /** Files changed anywhere in the working tree compared with HEAD: both staged
501
2047
  * and unstaged tracked files, plus untracked files. This powers the local,
502
2048
  * pre-commit Change Gate; it never mutates the index or asks an agent/model. */
503
2049
  export function workingFiles(cwd) {
504
- const changed = gitSafe(["diff", "HEAD", "--name-only", "--diff-filter=ACMR"], cwd).split("\n").filter(Boolean);
2050
+ const changed = gitSafe(["diff", "HEAD", "--no-ext-diff", "--no-textconv", "--name-only", "--diff-filter=ACMR"], cwd).split("\n").filter(Boolean);
505
2051
  const untracked = gitSafe(["ls-files", "--others", "--exclude-standard"], cwd).split("\n").filter(Boolean);
506
2052
  return [...new Set([...changed, ...untracked])].sort();
507
2053
  }
@@ -514,7 +2060,7 @@ export function revExists(ref, cwd) {
514
2060
  /** Files a PR/branch changes vs `base` (3-dot: changes on HEAD since the merge-base,
515
2061
  * i.e. exactly the PR's own commits — the CI Constraint Guard's surface). */
516
2062
  export function rangeFiles(base, cwd, head = "HEAD") {
517
- const out = gitSafe(["diff", "--name-only", "--diff-filter=ACMR", `${base}...${head}`], cwd);
2063
+ const out = gitSafe(["diff", "--no-ext-diff", "--no-textconv", "--name-only", "--diff-filter=ACMR", `${base}...${head}`], cwd);
518
2064
  return out ? out.split("\n").filter(Boolean) : [];
519
2065
  }
520
2066
  /** Commit subjects on `head` since `base` (2-dot: commits added by the task),
@@ -526,14 +2072,14 @@ export function rangeSubjects(base, cwd, head = "HEAD", max = 50) {
526
2072
  /** The PR's unified diff vs `base` (3-dot), for the Regression Guard's structural
527
2073
  * analysis. Same noise-exclusion + truncation budget as commit/staged diffs. */
528
2074
  export function rangeDiff(base, cwd, head = "HEAD", maxBytes = 60_000) {
529
- const out = gitSafe(["diff", "--no-color", "--unified=2", `${base}...${head}`, "--", ...DIFF_NOISE], cwd);
2075
+ const out = gitSafe(["diff", "--no-ext-diff", "--no-textconv", "--no-color", "--unified=2", `${base}...${head}`, "--", ...DIFF_NOISE], cwd);
530
2076
  return out.length > maxBytes ? out.slice(0, maxBytes) + "\n…(diff truncated)…" : out;
531
2077
  }
532
2078
  /** Unified diff of the staged changes (for the Regression Guard's structural
533
2079
  * analysis). Excludes machine-generated noise and truncates at the SAME budget as
534
2080
  * commitDiff, so the staged and `--commit` guard paths can't diverge on big diffs. */
535
2081
  export function stagedDiff(cwd, maxBytes = 60_000) {
536
- const out = gitSafe(["diff", "--cached", "--no-color", "--unified=2", "--", ...DIFF_NOISE], cwd);
2082
+ const out = gitSafe(["diff", "--cached", "--no-ext-diff", "--no-textconv", "--no-color", "--unified=2", "--", ...DIFF_NOISE], cwd);
537
2083
  return out.length > maxBytes ? out.slice(0, maxBytes) + "\n…(diff truncated)…" : out;
538
2084
  }
539
2085
  /** Unified diff of the complete local working tree vs HEAD. Git's normal diff
@@ -542,12 +2088,15 @@ export function stagedDiff(cwd, maxBytes = 60_000) {
542
2088
  * Binary/unreadable files remain in workingFiles (scope checks still apply) but
543
2089
  * intentionally contribute no synthetic content to regression analysis. */
544
2090
  export function workingDiff(cwd, maxBytes = 60_000) {
545
- let out = gitSafe(["diff", "HEAD", "--no-color", "--unified=2", "--", ...DIFF_NOISE], cwd);
546
- const tracked = new Set(gitSafe(["diff", "HEAD", "--name-only", "--diff-filter=ACMR"], cwd).split("\n").filter(Boolean));
2091
+ let out = gitSafe(["diff", "HEAD", "--no-ext-diff", "--no-textconv", "--no-color", "--unified=2", "--", ...DIFF_NOISE], cwd);
2092
+ const tracked = new Set(gitSafe(["diff", "HEAD", "--no-ext-diff", "--no-textconv", "--name-only", "--diff-filter=ACMR"], cwd).split("\n").filter(Boolean));
547
2093
  const untracked = gitSafe(["ls-files", "--others", "--exclude-standard"], cwd).split("\n").filter((f) => f && !tracked.has(f));
2094
+ const readWorkingFile = createRepoFileReader(cwd);
548
2095
  for (const file of untracked) {
549
2096
  try {
550
- const text = readFileSync(join(cwd, file), "utf8");
2097
+ const text = readWorkingFile(file);
2098
+ if (text === null)
2099
+ continue;
551
2100
  if (text.includes("\0"))
552
2101
  continue;
553
2102
  const lines = text.split("\n");