@davesheffer/hunch 1.46.0 → 1.47.1

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 (78) hide show
  1. package/dist/cli/index.js +280 -358
  2. package/dist/cli/invocation.d.ts +8 -13
  3. package/dist/cli/invocation.js +26 -29
  4. package/dist/cli/taskReport.d.ts +6 -0
  5. package/dist/cli/taskReport.js +31 -8
  6. package/dist/constitution/delta.js +2 -2
  7. package/dist/constitution/sourceMutation.js +28 -18
  8. package/dist/core/commitrepair.d.ts +43 -138
  9. package/dist/core/commitrepair.js +76 -167
  10. package/dist/core/correction.d.ts +2 -0
  11. package/dist/core/correction.js +23 -10
  12. package/dist/core/docanchors.js +9 -9
  13. package/dist/core/drift.js +22 -27
  14. package/dist/core/escalations.d.ts +16 -58
  15. package/dist/core/escalations.js +39 -121
  16. package/dist/core/glob.d.ts +7 -7
  17. package/dist/core/glob.js +12 -11
  18. package/dist/core/groundingLag.d.ts +2 -9
  19. package/dist/core/groundingLag.js +5 -11
  20. package/dist/core/groundingMerge.d.ts +2 -7
  21. package/dist/core/groundingMerge.js +35 -54
  22. package/dist/core/paths.d.ts +17 -24
  23. package/dist/core/paths.js +37 -43
  24. package/dist/core/refrepair.d.ts +2 -1
  25. package/dist/core/refrepair.js +4 -4
  26. package/dist/core/repairqueue.d.ts +17 -34
  27. package/dist/core/repairqueue.js +48 -98
  28. package/dist/core/taskRecord.d.ts +1 -1
  29. package/dist/core/taskRecord.js +2 -2
  30. package/dist/core/taskReport.d.ts +8 -3
  31. package/dist/core/taskReport.js +7 -2
  32. package/dist/core/taskReportEvidence.d.ts +7 -0
  33. package/dist/core/taskReportEvidence.js +16 -4
  34. package/dist/core/taskReportHook.js +2 -2
  35. package/dist/core/taskReportPublic.js +2 -2
  36. package/dist/core/taskReportRender.js +2 -2
  37. package/dist/core/types.d.ts +3 -3
  38. package/dist/core/types.js +1 -1
  39. package/dist/extractors/diff.d.ts +4 -4
  40. package/dist/extractors/diff.js +25 -29
  41. package/dist/extractors/git.d.ts +18 -23
  42. package/dist/extractors/git.js +72 -64
  43. package/dist/extractors/helm.d.ts +10 -0
  44. package/dist/extractors/helm.js +43 -59
  45. package/dist/extractors/indexer.d.ts +6 -0
  46. package/dist/extractors/indexer.js +126 -178
  47. package/dist/extractors/k8sManifest.d.ts +23 -80
  48. package/dist/extractors/k8sManifest.js +319 -690
  49. package/dist/extractors/languages.d.ts +28 -37
  50. package/dist/extractors/languages.js +55 -54
  51. package/dist/extractors/nativeTreeSitter.d.ts +4 -4
  52. package/dist/extractors/nativeTreeSitter.js +17 -14
  53. package/dist/extractors/parse.d.ts +7 -13
  54. package/dist/extractors/parse.js +39 -51
  55. package/dist/integrations/claudemd.js +1 -0
  56. package/dist/integrations/gitignore.js +3 -5
  57. package/dist/integrations/health.js +1 -1
  58. package/dist/integrations/hooks.d.ts +9 -14
  59. package/dist/integrations/hooks.js +19 -28
  60. package/dist/integrations/mergeDriver.js +13 -14
  61. package/dist/integrations/probe.d.ts +11 -0
  62. package/dist/integrations/probe.js +29 -4
  63. package/dist/integrations/sync.d.ts +16 -4
  64. package/dist/integrations/sync.js +54 -3
  65. package/dist/integrations/workspaceLedger.d.ts +1 -1
  66. package/dist/mcp/server.d.ts +4 -5
  67. package/dist/mcp/server.js +71 -83
  68. package/dist/mcp/taskReportTools.js +4 -4
  69. package/dist/serve/app.js +2 -2
  70. package/dist/store/hunchStore.js +12 -18
  71. package/dist/synthesis/provider.d.ts +19 -21
  72. package/dist/synthesis/provider.js +67 -76
  73. package/dist/synthesis/synthesize.d.ts +8 -8
  74. package/dist/synthesis/synthesize.js +32 -49
  75. package/dist/taskReports.d.ts +1 -0
  76. package/dist/taskReports.js +2 -2
  77. package/package.json +2 -2
  78. package/server.json +2 -2
package/dist/cli/index.js CHANGED
@@ -46,7 +46,7 @@ import { isParserLoadError, loadNativeTreeSitter } from "../extractors/nativeTre
46
46
  import { syncCommit, recordFailure, captureTestRun } from "../synthesis/synthesize.js";
47
47
  import { parseTestReport } from "../extractors/testreport.js";
48
48
  import { readSynthesisPreference, resolveSynthesisProvider, selectProvider, SYNTH_PREFERENCES, writeSynthesisPreference, normalizeProviderName, } from "../synthesis/provider.js";
49
- import { isGitRepo, isGitRepoRoot, sameGitPublication, sameRemoteUrl, canonicalRemoteUrl, repositoryUsesRemote, headSha, isolatedHeadSha, logSince, lastChangeDate, checkFreshnessRevision, teamPullIsFresh, firstCommitForFile, stagedFiles, workingFiles, commitFiles, asOfDate, stagedGateDiff, workingGateDiff, commitGateDiff, rangeFiles, rangeGateDiff, rangeCommits, revExists, revParse, commitAndPushHunch, pullHunchStatus, pullHunchStatusDetailed, describePullFailure, syncExistingHunch, gitUntrackCached, gitCommonDir, checkoutCommonDir, separateGitDirCandidate, separateGitDirLink, hooksDir, isLinkedWorktree, mainWorktreeRoot, gitMemoryLog, memoryMoveDiff, revertMemoryMove, pushCurrentBranch, commitChanges, commitRepairStatusAgainst, commitPatchIds, mergeRangeChanges, commitsExist } from "../extractors/git.js";
49
+ import { isGitRepo, isGitRepoRoot, sameGitPublication, sameRemoteUrl, canonicalRemoteUrl, repositoryUsesRemote, headSha, isolatedHeadSha, logSince, lastChangeDate, checkFreshnessRevision, teamPullIsFresh, firstCommitForFile, stagedFiles, workingFiles, commitFiles, asOfDate, stagedGateDiff, workingGateDiff, commitGateDiff, rangeFiles, rangeGateDiff, rangeCommits, revExists, revParse, sameFilesystemEntry, commitAndPushHunch, pullHunchStatus, pullHunchStatusDetailed, describePullFailure, syncExistingHunch, gitUntrackCached, gitCommonDir, checkoutCommonDir, separateGitDirCandidate, separateGitDirLink, hooksDir, isLinkedWorktree, mainWorktreeRoot, gitMemoryLog, memoryMoveDiff, revertMemoryMove, pushCurrentBranch, commitChanges, commitRepairStatusAgainst, commitPatchIds, mergeRangeChanges, commitsExist } from "../extractors/git.js";
50
50
  import { runbookDraft } from "../core/runbookDraft.js";
51
51
  import { parseMemoryLog } from "../core/memorylog.js";
52
52
  import { renamesOf, planRepair, repairDecision, repairConstraint } from "../core/repair.js";
@@ -61,7 +61,7 @@ import { renderText, renderMarkdown, renderSarif, renderImpact, reportFailsStric
61
61
  import { partitionReview, isReviewDraft, READY_MIN_GROUNDED } from "../core/reviewqueue.js";
62
62
  import { installPostCommitHook, installPreCommitHook, installPostMergeHook, installPostCheckoutHook, hookStatus, hookReport, hookInvocationLines, formatHookInstall, sharedHooksNote } from "../integrations/hooks.js";
63
63
  import { ensureSharedOverlayPointer } from "../integrations/worktree.js";
64
- import { flushCapture, flushMemoryHome, flushMemoryHomes, pinSharedRemote, sharedRemoteFor } from "../integrations/sync.js";
64
+ import { flushCapture, flushMemoryHome, flushMemoryHomes, pinSharedRemote, sharedRemoteFor, holdsOnDefaultBranch, HELD_ON_DEFAULT_BRANCH } from "../integrations/sync.js";
65
65
  import { installMergeDriver } from "../integrations/mergeDriver.js";
66
66
  import { ensureGitignore, ignoreHunchMemory, HUNCH_MEMORY_DIRS, upgradeManagedGitignore, describeGitignoreUpgrade, memoryIgnoreRule, describeMemoryIgnoreRule } from "../integrations/gitignore.js";
67
67
  import { writeCiWorkflow } from "../integrations/ciAction.js";
@@ -210,7 +210,7 @@ registerReviewMemoryCommands(program, (records, repository, privateOnly) => {
210
210
  for (const record of records)
211
211
  store.putCapture("constraints", record, privateOnly);
212
212
  store.reindex();
213
- if (home === "public" && !store.autoCommit)
213
+ if (home === "public" && !publicFlushCommitsGrounding(store, root))
214
214
  refreshExistingGrounding(root, store);
215
215
  pumpMemoryHome(store, root, home, `hunch: capture ${records.length} sourced review rule(s)`);
216
216
  }, (repository, privateOnly) => {
@@ -447,8 +447,25 @@ function storeFor(opts = {}) {
447
447
  const { store, teamPullStatus, teamPullDetail } = openTeamStore(root, opts);
448
448
  return { store, root, teamPullStatus, teamPullDetail };
449
449
  }
450
+ /** Whether a public memory flush commits the tracked grounding docs itself. When it
451
+ * won't (auto-commit off, or held on the default branch), a by-hand command refreshes
452
+ * them on disk instead, so CLAUDE.md never keeps enforcing what the command just changed. */
453
+ function publicFlushCommitsGrounding(store, root) {
454
+ return store.autoCommit && !holdsOnDefaultBranch(hunchPaths(root).hunch);
455
+ }
456
+ // A held public flush says so once per command, on stderr: stdout may be a --json payload.
457
+ let heldNoticePrinted = false;
458
+ function noteHeld() {
459
+ if (heldNoticePrinted)
460
+ return;
461
+ heldNoticePrinted = true;
462
+ console.error(` ↳ ${HELD_ON_DEFAULT_BRANCH}`);
463
+ }
450
464
  function pumpMemoryHome(store, root, home, message) {
451
- flushMemoryHome(store, hunchPaths(root).hunch, home, message);
465
+ const flush = flushMemoryHome(store, hunchPaths(root).hunch, home, message);
466
+ if (flush === "held")
467
+ noteHeld();
468
+ return flush;
452
469
  }
453
470
  function pumpPolicyHome(store, root, service, policyId, message) {
454
471
  const home = service.repository.homeOfPolicy(policyId);
@@ -457,7 +474,9 @@ function pumpPolicyHome(store, root, service, policyId, message) {
457
474
  pumpMemoryHome(store, root, home, message);
458
475
  }
459
476
  function pumpMemoryHomes(store, root, homes, message) {
460
- flushMemoryHomes(store, hunchPaths(root).hunch, homes, message);
477
+ const flushes = flushMemoryHomes(store, hunchPaths(root).hunch, homes, message);
478
+ if (flushes.public === "held")
479
+ noteHeld();
461
480
  }
462
481
  // ---- init -----------------------------------------------------------------
463
482
  program
@@ -746,12 +765,11 @@ program
746
765
  // never rewrites an existing one: `index` runs in CI/release gates on a clean
747
766
  // checkout. Older blocks are upgraded by `hunch update` / init / private setup.
748
767
  ensureGitignore(root, { upgradeExisting: false });
749
- // The post-merge hook only ever got installed by `hunch init`/`hunch
750
- // private`/`hunch shared` — a repo that already ran init before this hook
751
- // existed never receives it. `hunch index` already self-heals gitignore
752
- // the same way; do the same for the hook so an upgrade doesn't require
753
- // re-running init by hand. Gated on already having post-commit: `index`
754
- // is not a setup command (it runs in CI, on any git repo), so it must
768
+ // The post-merge hook used to be installed only by init / private / shared, so a
769
+ // repo initialized before it existed never got it. `index` already self-heals the
770
+ // gitignore and does the same for the hooks, so upgrading needs no re-init. The
771
+ // heal is gated on post-commit already being present: `index` is not a setup
772
+ // command (it runs in CI on arbitrary repos) and so it must
755
773
  // never be what FIRST hooks a repo that never ran init at all. A STALE
756
774
  // post-commit block counts too (issue #315): it proves init ran, so the
757
775
  // missing post-merge/post-checkout hooks are still this repo's to heal.
@@ -768,7 +786,7 @@ program
768
786
  // while the docs are still Git-clean so it can stage them atomically with
769
787
  // the graph. Pre-refreshing would make the safety filter treat generated
770
788
  // docs as user-dirty and leave them stranded outside the memory commit.
771
- const healed = shouldAutoCommit ? [] : refreshExistingGrounding(root, store);
789
+ const healed = shouldAutoCommit && publicFlushCommitsGrounding(store, root) ? [] : refreshExistingGrounding(root, store);
772
790
  console.log(`Indexed ${res.files} files:`);
773
791
  console.log(` ${counts.symbols} symbols, ${counts.edges} edges, ${counts.components} components`);
774
792
  for (const item of res.coverage) {
@@ -825,9 +843,9 @@ program
825
843
  const commits = logSince(opts.since, root, Number(opts.max));
826
844
  const conc = Math.max(1, Math.min(16, Number(opts.concurrency) || 4));
827
845
  console.log(`Backfilling from ${commits.length} commit(s) since ${opts.since} (concurrency ${conc})…`);
828
- // Best-effort context-window advisory (issue #11): printed ONCE, before any
829
- // commit is drafted — not per-commit, and not under --deep (an ensemble may
830
- // fan out to several distinct workers, each with its own configuration).
846
+ // One-time, best-effort context-window advisory: printed once before any commit is
847
+ // drafted (never per commit), and skipped under --deep since an ensemble may fan out
848
+ // to several workers with differing configurations.
831
849
  if (!opts.deep && commits.length > 0) {
832
850
  // { root }: the advisory must resolve the SAME provider the synthesis
833
851
  // inside syncCommit resolves (persisted local.json preference included) —
@@ -835,7 +853,7 @@ program
835
853
  // num_ctx warning for an actual Ollama run (issue #46).
836
854
  const ctxProvider = await selectProvider({ root });
837
855
  const ctxWarning = await maybeWarnOllamaContext(ctxProvider.name, process.env);
838
- if (ctxWarning)
856
+ if (ctxWarning !== null)
839
857
  console.log(ctxWarning);
840
858
  }
841
859
  let written = 0, skipped = 0, llm = 0, heuristic = 0;
@@ -996,7 +1014,13 @@ program
996
1014
  // each home's artifacts into at most one memory commit.
997
1015
  const publicCapture = r.status === "written" && !toOverlay;
998
1016
  const privateCapture = r.status === "written" && toOverlay;
999
- const groundingToStage = publicCapture || graphRefreshed || publicRepairTouched ? refreshCommittableGrounding(root, store) : [];
1017
+ const publicNeedsFlush = publicCapture || graphRefreshed || publicRepairTouched;
1018
+ // The default-branch hold covers the post-commit hook too. Its baked-in --commit is
1019
+ // the installed auto-commit default, so only a --commit typed by hand overrides it.
1020
+ // Decided before the grounding refresh: a held capture leaves tracked docs at HEAD,
1021
+ // so `git commit -a` cannot commit counts whose record it leaves untracked.
1022
+ const publicHeld = doCommit && publicNeedsFlush && (opts.fromHook || opts.commit === undefined) && holdsOnDefaultBranch(hunchPaths(root).hunch);
1023
+ const groundingToStage = publicNeedsFlush && !publicHeld ? refreshCommittableGrounding(root, store) : [];
1000
1024
  if (!opts.fromHook && (r.status === "written" || graphRefreshed || publicRepairTouched)) {
1001
1025
  const healed = refreshExistingGrounding(root, store);
1002
1026
  const refreshed = [...new Set([...groundingToStage.map((file) => relative(root, file)), ...healed])];
@@ -1004,9 +1028,10 @@ program
1004
1028
  console.log(` ↳ grounding refreshed: ${refreshed.join(", ")}`);
1005
1029
  }
1006
1030
  if (doCommit) {
1007
- const publicNeedsFlush = publicCapture || graphRefreshed || publicRepairTouched;
1008
1031
  const privateNeedsFlush = privateCapture || (privateCorrectionQueued && graphRefreshed) || g2Recorded > 0 || privateRepairTouched;
1009
- const publicResult = publicNeedsFlush
1032
+ if (publicHeld && !opts.quiet)
1033
+ console.log(` ↳ ${HELD_ON_DEFAULT_BRANCH}`);
1034
+ const publicResult = publicNeedsFlush && !publicHeld
1010
1035
  ? commitAndPushHunch(hunchPaths(root).hunch, publicCapture
1011
1036
  ? `hunch: capture ${r.decision?.id ?? "decision"}`
1012
1037
  : graphRefreshed
@@ -1254,9 +1279,8 @@ function beginFreshOverlaySetup(root, dest, existingLocal, includeHook) {
1254
1279
  const backLink = commonDir ? separateGitDirLink(commonDir) : "";
1255
1280
  const configuredHooks = includeHook ? hooksDir(root) : "";
1256
1281
  const hookDir = configuredHooks ? (isAbsolute(configuredHooks) ? configuredHooks : join(root, configuredHooks)) : "";
1257
- // Setup installs post-commit AND post-merge together, so both must be in the
1258
- // ledger — restoring one while leaving the other pointing at a just-deleted
1259
- // overlay is not a rollback.
1282
+ // Setup installs both hooks together, so both must be snapshotted: restoring only one
1283
+ // while the other still points at a deleted overlay is not a rollback.
1260
1284
  const hookFiles = hookDir ? [join(hookDir, "post-commit"), join(hookDir, "post-merge")] : [];
1261
1285
  const paths = [localFile, codeGitignore, teamFile, ...(sharedPointer ? [sharedPointer, backLink] : []), ...hookFiles];
1262
1286
  const snapshots = new Map(paths.map((path) => [path, setupPathSnapshot(path)]));
@@ -1278,7 +1302,8 @@ function beginFreshOverlaySetup(root, dest, existingLocal, includeHook) {
1278
1302
  markGitignoreWrite: () => mark(codeGitignore),
1279
1303
  markTeamWrite: () => mark(teamFile),
1280
1304
  markSharedPointerWrite: () => { mark(backLink); mark(sharedPointer); },
1281
- markHookWrite: () => hookFiles.forEach(mark),
1305
+ markHookWrite: () => { for (const f of hookFiles)
1306
+ mark(f); },
1282
1307
  // Migration is a one-way ownership handoff. Once public records have been
1283
1308
  // durably copied into this clone, a later setup failure may restore routing
1284
1309
  // files but must not delete the clone that now holds their surviving copy.
@@ -2284,12 +2309,13 @@ program
2284
2309
  }
2285
2310
  }
2286
2311
  store.reindex();
2287
- if (dec || con)
2288
- flushCapture(store, hunchPaths(root).hunch, !!opts.private, `hunch: capture ${dec + con} inline intent(s)`);
2312
+ const flush = dec || con ? flushCapture(store, hunchPaths(root).hunch, !!opts.private, `hunch: capture ${dec + con} inline intent(s)`) : null;
2289
2313
  if (!intents.length)
2290
2314
  console.log("No `hunch-why:` / `hunch-rule:` comments found.");
2291
2315
  else
2292
2316
  console.log(`✓ captured ${dec} decision(s) + ${con} constraint(s) from inline comments${opts.private ? " [private overlay]" : ""}`);
2317
+ if (flush === "held")
2318
+ console.log(` ↳ ${HELD_ON_DEFAULT_BRANCH}`);
2293
2319
  store.close();
2294
2320
  });
2295
2321
  // ---- conform (Architectural Conformance: does the code still satisfy recorded intent) ----
@@ -2331,7 +2357,7 @@ program
2331
2357
  valid_from: now,
2332
2358
  });
2333
2359
  store.reindex();
2334
- if (home === "public" && !store.autoCommit)
2360
+ if (home === "public" && !publicFlushCommitsGrounding(store, root))
2335
2361
  refreshExistingGrounding(root, store);
2336
2362
  pumpMemoryHome(store, root, home, `hunch: record architectural invariant ${d.id}`);
2337
2363
  console.log(`✓ recorded architectural invariant ${d.id}: "${opts.add}"`);
@@ -4048,7 +4074,7 @@ program
4048
4074
  }
4049
4075
  store.reindex();
4050
4076
  const home = store.captureHome(!!opts.private);
4051
- if (home === "public" && !store.autoCommit)
4077
+ if (home === "public" && !publicFlushCommitsGrounding(store, root))
4052
4078
  refreshExistingGrounding(root, store);
4053
4079
  const flush = flushCapture(store, hunchPaths(root).hunch, !!opts.private, `hunch: import ${decisions.length} ADR(s) from ${dir}`);
4054
4080
  const live = decisions.filter((d) => d.status === "accepted").length;
@@ -4067,6 +4093,8 @@ program
4067
4093
  }
4068
4094
  if (flush === "pushed")
4069
4095
  console.log(" ↳ private memory committed + pushed");
4096
+ if (flush === "held")
4097
+ console.log(` ↳ ${HELD_ON_DEFAULT_BRANCH}`);
4070
4098
  }
4071
4099
  finally {
4072
4100
  store.close();
@@ -4204,7 +4232,7 @@ program
4204
4232
  store.reindex();
4205
4233
  // Public grounding is a publishable artifact. Private rules stay local and
4206
4234
  // are surfaced by local checks/MCP, never copied into committed agent docs.
4207
- if (home === "public" && !store.autoCommit)
4235
+ if (home === "public" && !publicFlushCommitsGrounding(store, root))
4208
4236
  refreshExistingGrounding(root, store);
4209
4237
  const flush = flushCapture(store, hunchPaths(root).hunch, !!opts.private, `hunch: capture ${c.id}`);
4210
4238
  console.log(`✓ recorded ${c.severity} constraint ${c.id}: "${c.statement}" (scope: ${scope.join(", ") || "repo"})${opts.private ? " [private overlay]" : ""}`);
@@ -4218,6 +4246,8 @@ program
4218
4246
  }
4219
4247
  if (flush === "pushed")
4220
4248
  console.log(" ↳ private memory committed + pushed");
4249
+ if (flush === "held")
4250
+ console.log(` ↳ ${HELD_ON_DEFAULT_BRANCH}`);
4221
4251
  store.close();
4222
4252
  });
4223
4253
  // ---- test (failure-learning loop) -----------------------------------------
@@ -4910,20 +4940,21 @@ program
4910
4940
  .requiredOption("--by <new>", "decision id that supersedes it")
4911
4941
  .action((oldId, opts) => {
4912
4942
  const { store, root } = storeFor();
4913
- // `old`'s home decides which store the close is written to; `--by` must resolve in
4914
- // that SAME store, or a private `by` linked against a public `old` would write the
4915
- // private id straight into the committed public store.
4943
+ // The old decision's own memory home decides which store both ids must live in.
4916
4944
  const home = decisionMemoryHome(store, oldId);
4917
- if (!store.decisionInStore(oldId, home === "private")) {
4945
+ const priv = home === "private";
4946
+ // Check the old id first so a missing old decision is never blamed on --by.
4947
+ if (!store.decisionInStore(oldId, priv)) {
4918
4948
  store.close();
4919
4949
  return fail(`decision "${oldId}" not found`);
4920
4950
  }
4921
- const by = store.decisionInStore(opts.by, home === "private");
4951
+ // Resolve --by in the SAME store: a private --by must never be linked into a committed public record.
4952
+ const by = store.decisionInStore(opts.by, priv);
4922
4953
  if (!by) {
4923
4954
  store.close();
4924
4955
  return fail(`--by decision "${opts.by}" not found in the ${home} store that holds "${oldId}"`);
4925
4956
  }
4926
- const closed = home === "private" ? store.supersedePrivate(oldId, by) : store.supersede(oldId, by);
4957
+ const closed = priv ? store.supersedePrivate(oldId, by) : store.supersede(oldId, by);
4927
4958
  if (!closed) {
4928
4959
  store.close();
4929
4960
  return fail(`decision "${oldId}" cannot supersede itself`);
@@ -4960,7 +4991,7 @@ program
4960
4991
  // is on, but no commit happens to carry that refresh when it's off, so do it here
4961
4992
  // too — otherwise a retired constraint keeps showing as enforced on disk (the same
4962
4993
  // gap record-constraint and `conform --add` close for their own capture path).
4963
- if (home === "public" && !store.autoCommit)
4994
+ if (home === "public" && !publicFlushCommitsGrounding(store, root))
4964
4995
  refreshExistingGrounding(root, store);
4965
4996
  // `--reason` goes in the commit BODY, never the subject: `hunch log`'s move
4966
4997
  // classifier (src/core/memorylog.ts) regexes the commit SUBJECT for keywords
@@ -4968,11 +4999,14 @@ program
4968
4999
  // in the subject could accidentally match one and misclassify the move.
4969
5000
  const willCommit = home === "private" ? store.privateAutoCommit : store.autoCommit;
4970
5001
  const message = `hunch: retire constraint ${id}${opts.reason ? `\n\n${opts.reason}` : ""}`;
4971
- pumpMemoryHome(store, root, home, message);
5002
+ const flush = pumpMemoryHome(store, root, home, message);
4972
5003
  console.log(`✓ ${retired.id} retired — window closed at ${retired.valid_to?.slice(0, 10)}.`);
4973
5004
  if (opts.reason && !willCommit) {
4974
5005
  console.log(" ⚠ --reason is not recorded anywhere: auto-commit is off, so no commit message captured it.");
4975
5006
  }
5007
+ else if (opts.reason && flush === "held") {
5008
+ console.log(" ⚠ --reason is not recorded anywhere: the commit was held on the default branch, so no commit message captured it.");
5009
+ }
4976
5010
  store.close();
4977
5011
  });
4978
5012
  // ---- firmness (agent-hook enforcement level) ------------------------------
@@ -5006,7 +5040,8 @@ program
5006
5040
  .action(async (value) => {
5007
5041
  const root = findRoot();
5008
5042
  if (value != null) {
5009
- const preference = normalizeProviderName(value.trim()) ?? value.trim();
5043
+ const trimmed = value.trim();
5044
+ const preference = normalizeProviderName(trimmed) || trimmed;
5010
5045
  if (!SYNTH_PREFERENCES.includes(preference)) {
5011
5046
  return fail(`provider must be one of: ${SYNTH_PREFERENCES.join(", ")}`);
5012
5047
  }
@@ -5019,7 +5054,7 @@ program
5019
5054
  console.log(`✓ local synthesis preference set to ${preference} (gitignored; it never changes a teammate's billing choice).`);
5020
5055
  }
5021
5056
  const resolution = await resolveSynthesisProvider({ root });
5022
- const envValue = normalizeProviderName(process.env.HUNCH_SYNTH_PROVIDER?.trim());
5057
+ const envValue = normalizeProviderName((process.env.HUNCH_SYNTH_PROVIDER ?? "").trim()) || undefined;
5023
5058
  const local = readSynthesisPreference(root);
5024
5059
  const hasValidEnv = !!envValue && SYNTH_PREFERENCES.includes(envValue);
5025
5060
  console.log(`selected: ${resolution.provider.name} (${resolution.source})`);
@@ -5197,10 +5232,14 @@ program
5197
5232
  if (!evt)
5198
5233
  return;
5199
5234
  eventName = evt.hook_event_name;
5200
- const root = findRoot();
5235
+ // Grounding reads the store of the checkout the event's file is in; the prompt's
5236
+ // task and the hook evidence stay with the process root that opened them.
5237
+ const processRoot = findRoot();
5238
+ const routed = hookEventRoot(processRoot, evt);
5239
+ const root = routed.root;
5201
5240
  // The host delivered this event: runtime evidence for `hunch integrations check`,
5202
5241
  // recorded before any policy decision so firmness never hides delivery itself.
5203
- recordHookObservation(root, provider, evt.hook_event_name, evt.tool_outcome?.status);
5242
+ recordHookObservation(processRoot, provider, evt.hook_event_name, evt.tool_outcome?.status);
5204
5243
  const paths = hunchPaths(root);
5205
5244
  const firmness = readConfig(paths).firmness;
5206
5245
  if (firmness === "off")
@@ -5224,7 +5263,7 @@ program
5224
5263
  // Same rule as the pre-edit path: strict never grounds from stale team rules.
5225
5264
  const stale = firmness === "strict" && opened.teamPullStatus && !teamPullIsFresh(opened.teamPullStatus);
5226
5265
  if (!stale)
5227
- shellGround = shellWriteGrounding(root, store, provider, evt, written);
5266
+ shellGround = shellWriteGrounding(root, store, provider, evt, written, processRoot);
5228
5267
  }
5229
5268
  }
5230
5269
  else {
@@ -5536,8 +5575,8 @@ program
5536
5575
  // so its orientation event keeps asking every actionable entry.
5537
5576
  const actionableEsc = deliversPromptEvent(provider) ? [] : (await actionableEscalationsWithAnchors(paths.root, s)).actionable;
5538
5577
  if (!decisions.length && !actionableEsc.length) {
5539
- // Fresh graph and nothing else to raise: nothing to orient on, but
5540
- // the operating loop still ships. A queued commit-repair escalation
5578
+ // A fresh graph with nothing to raise has nothing to orient on, but the
5579
+ // operating loop is still delivered. A queued commit-repair escalation
5541
5580
  // (checked against the full store) is enough reason NOT to bail here
5542
5581
  // even when the visible decisions list is empty.
5543
5582
  if (pipelineEnabled())
@@ -5581,9 +5620,13 @@ program
5581
5620
  return;
5582
5621
  }
5583
5622
  const targets = editTargets(root, evt.tool_input);
5584
- // Nothing inside the repo → nothing for Hunch to say.
5585
- if (!targets.length)
5623
+ // Nothing inside the repo → nothing for Hunch to say, except for a file in a
5624
+ // linked worktree of this repository Hunch could not ground (never silently).
5625
+ if (!targets.length) {
5626
+ if (routed.ungrounded)
5627
+ emitContext(provider, "PreToolUse", routed.ungrounded);
5586
5628
  return;
5629
+ }
5587
5630
  // Grounding below stays about the first in-repo file; the strict gate checks every target.
5588
5631
  const { abs, target } = targets[0];
5589
5632
  // A compiled red→green probe is only meaningful if its red receipt exists
@@ -5645,12 +5688,12 @@ program
5645
5688
  : `Hunch: this patch is denied for ${denials.length} files (${denials.map((d) => d.target).join(", ")}).\n${denials.map((d) => d.hit.reason).join("\n")}`;
5646
5689
  emitDeny(provider, reason);
5647
5690
  for (const d of denials)
5648
- observeHookDenial(root, provider, evt, d.target, d.hit);
5691
+ observeHookDenial(processRoot, provider, evt, d.target, d.hit);
5649
5692
  return;
5650
5693
  }
5651
5694
  }
5652
5695
  // advisory / firm / strict(non-blocking): inject the relevant Hunch slice.
5653
- const grounded = fileGrounding(root, store, provider, evt, target, abs);
5696
+ const grounded = fileGrounding(root, store, provider, evt, target, abs, processRoot);
5654
5697
  if (!grounded)
5655
5698
  return; // no noise on files Hunch hasn't learned yet
5656
5699
  emitContext(provider, "PreToolUse", grounded.text, grounded.mode === "full" ? grounded.recalled ?? undefined : undefined);
@@ -5829,7 +5872,7 @@ program
5829
5872
  store.reindex();
5830
5873
  if (home === "public") {
5831
5874
  publicGroundingChanged = true;
5832
- if (!store.autoCommit)
5875
+ if (!publicFlushCommitsGrounding(store, root))
5833
5876
  refreshExistingGrounding(root, store);
5834
5877
  }
5835
5878
  console.log(opts.approveImport
@@ -5889,7 +5932,7 @@ program
5889
5932
  store.reindex();
5890
5933
  if (home === "public") {
5891
5934
  publicGroundingChanged = true;
5892
- if (!store.autoCommit)
5935
+ if (!publicFlushCommitsGrounding(store, root))
5893
5936
  refreshExistingGrounding(root, store);
5894
5937
  }
5895
5938
  }
@@ -5908,7 +5951,7 @@ program
5908
5951
  store.reindex();
5909
5952
  if (home === "public") {
5910
5953
  publicGroundingChanged = true;
5911
- if (!store.autoCommit)
5954
+ if (!publicFlushCommitsGrounding(store, root))
5912
5955
  refreshExistingGrounding(root, store);
5913
5956
  }
5914
5957
  console.log(`✓ accepted ${opts.accept} (now ${source}, confidence 0.95${armed ? `, ${armed} tripwire(s) now blocking` : ""})`);
@@ -5967,7 +6010,7 @@ program
5967
6010
  armedTotal += accepted.armed;
5968
6011
  }
5969
6012
  store.reindex();
5970
- if (publicGroundingChanged && !store.autoCommit)
6013
+ if (publicGroundingChanged && !publicFlushCommitsGrounding(store, root))
5971
6014
  refreshExistingGrounding(root, store);
5972
6015
  console.log(`✓ accepted ${ready.length} verified draft(s); ${armedTotal} tripwire(s) now blocking.`);
5973
6016
  for (const it of ready)
@@ -6127,7 +6170,7 @@ program
6127
6170
  }
6128
6171
  if (accepted || rejected) {
6129
6172
  store.reindex();
6130
- if (publicAccepted && !store.autoCommit)
6173
+ if (publicAccepted && !publicFlushCommitsGrounding(store, root))
6131
6174
  refreshExistingGrounding(root, store);
6132
6175
  pumpMemoryHomes(store, root, touchedHomes, "hunch: auto-review decision lifecycle");
6133
6176
  }
@@ -6237,7 +6280,7 @@ program
6237
6280
  // ---- escalations (the inline "ask the human" surface) ---------------------
6238
6281
  program
6239
6282
  .command("escalations")
6240
- .description("The decisions a human must make NOW — surfaced to be asked INLINE (in the prompt), never a background queue. Captured memory auto-trusts on landing; this lists only what the graph genuinely can't resolve itself: topic conflicts, premise-stale decisions, one exact imported ADR at a time, a queued commit-provenance repair awaiting `--apply`, Constitution candidates awaiting review, and proposed policies whose activation is a human call. Normally empty. Exits non-zero when any are open, so an assistant/CI knows to raise them.")
6283
+ .description("The decisions a human must make now — ask them inline in the prompt, never queued in the background. Captured memory auto-trusts on landing, so this lists only what the graph cannot resolve itself: topic conflicts, premise-stale decisions, one exact imported ADR at a time, a queued commit-provenance repair awaiting `--apply`, Constitution candidates awaiting review, and proposed policies whose activation is a human call. Normally empty; exits non-zero when any are open so an assistant or CI knows to raise them.")
6241
6284
  .option("--json", "emit the escalation entries as JSON (the VS Code panel's data source)")
6242
6285
  .action(async (opts) => {
6243
6286
  const { store, root } = storeFor();
@@ -6247,8 +6290,8 @@ program
6247
6290
  // Premise decay rides the same inline surface: a decision whose recorded
6248
6291
  // reason died is a QUESTION for the human — authority never changes here.
6249
6292
  items.push(...premiseEscalations(decisionsForEsc, { now: new Date().toISOString(), exists: (p) => existsSync(join(root, p)) }));
6250
- const escalationsQueue = readActivePendingRepairs(root);
6251
- items.push(...commitRepairEscalations(escalationsQueue, decisionsForEsc, decisionsForEsc, withheldRewrites(root, escalationsQueue)));
6293
+ const queuedRepairs = readActivePendingRepairs(root);
6294
+ items.push(...commitRepairEscalations(queuedRepairs, decisionsForEsc, decisionsForEsc, withheldRewrites(root, queuedRepairs)));
6252
6295
  // Constitution moments ride the same inline surface (§59.5.3) — never a queue.
6253
6296
  // Fail open: a broken policy store must not take the memory escalations down.
6254
6297
  try {
@@ -6256,10 +6299,10 @@ program
6256
6299
  items.push(...policyEscalations(new CS(store, root).list().map((p) => ({ ...p, last_action: p.audit.at(-1)?.action ?? null }))));
6257
6300
  }
6258
6301
  catch { /* constitution unavailable — memory escalations still surface */ }
6259
- // Gate (exit code + tallies) on the ACTIONABLE subset only — a duplicate-id
6260
- // commit-repair follower whose own resolution says "act on a different
6261
- // entry first" still surfaces below for transparency, but must not count
6262
- // as its own thing needing a decision (#61). --json keeps the full list.
6302
+ // The exit code and tallies gate only on the actionable subset: a duplicate-id
6303
+ // commit-repair follower whose own resolution says to act on a different entry
6304
+ // first still surfaces for transparency, but is not its own decision. --json
6305
+ // keeps the full list.
6263
6306
  const actionable = actionableEscalations(items);
6264
6307
  if (opts.json) {
6265
6308
  console.log(JSON.stringify(items));
@@ -6271,18 +6314,12 @@ program
6271
6314
  console.log("✓ Nothing needs your decision — memory is auto-trusted and self-consistent.");
6272
6315
  return;
6273
6316
  }
6274
- // escalationHeadline's own "nothing actionable, N shown for context"
6275
- // fallback is currently unreachable here: every escalation-producing
6276
- // function today guarantees at least one actionable entry whenever it
6277
- // emits anything at all — see commitRepairEscalations' own docstring on
6278
- // `firstFor`/dropTarget. Kept anyway (not assumed away) because that
6279
- // guarantee lives in the PRODUCERS, not in `Escalation.actionable`'s own
6280
- // contract, which a future producer could legitimately violate.
6281
- console.log(escalationHeadline(items, "cli") + "\n");
6317
+ // The headline's "nothing actionable, N shown for context" fallback is currently
6318
+ // unreachable (every producer guarantees at least one actionable entry), but that
6319
+ // guarantee lives in the producers, not in Escalation.actionable's contract.
6320
+ console.log(`${escalationHeadline(items, "cli")}\n`);
6282
6321
  for (const e of items) {
6283
- // A non-actionable row (a duplicate-id follower) still surfaces for
6284
- // transparency, but must not read like its own question — marked
6285
- // distinctly so a skim doesn't mistake it for one of the tally above.
6322
+ // A non-actionable follower is shown for context only, so it gets a quieter marker.
6286
6323
  console.log(` ${e.actionable === false ? "·" : "⚖"} ${e.question}`);
6287
6324
  console.log(` ${dim(e.detail)}`);
6288
6325
  console.log(` ${dim("→ " + e.resolution)}\n`);
@@ -6425,17 +6462,17 @@ program
6425
6462
  store.close();
6426
6463
  }
6427
6464
  });
6428
- // ---- repair-provenance (squash-merge commit provenance repair) ------------
6465
+ // ---- repair-provenance (squash-merge commit-provenance self-repair) -------
6429
6466
  program
6430
6467
  .command("repair-provenance")
6431
6468
  .description("Self-repair: detect a decision's commit provenance going orphaned by a squash-merge, matched by exact related_files overlap against the newly merged commit range — zero guessing beyond that. A fresh match not already rejected via --drop is queued (.hunch/pending-commit-repairs.json, local-only); --apply/--drop require the --expect hash printed by the preview or escalation; --only <dec_id> limits approval to one decision. Approval never scans for fresh matches. The post-merge hook runs detection automatically in the background but never passes --apply — the match signal isn't strong enough to trust an unattended write into shared team memory.")
6432
6469
  .option("--apply", "apply the reviewed queued candidates (requires --expect; does not scan for new matches)")
6433
6470
  .option("--expect <hash>", "exact queue review hash printed by the preview or escalation; required for --apply/--drop")
6434
- .option("--only <dec_id>", "with --apply, rewrite only this decision id — everything else stays queued untouched")
6435
- .option("--drop <dec_id>", "reject the queued match for this decision id, without applying it — tombstoned durably, so an identical future match for the same still-orphaned commit won't resurface (a genuinely different candidate still can)")
6471
+ .option("--only <dec_id>", "with --apply, rewrite only this decision id; everything else stays queued untouched")
6472
+ .option("--drop <dec_id>", "reject the queued match for this decision id without applying it; tombstoned so an identical future match for the same still-orphaned commit does not resurface (a genuinely different candidate still can)")
6436
6473
  .option("--from-hook", "invoked by the git post-merge hook")
6437
6474
  .option("--quiet", "minimal output")
6438
- .option("--range <old..new>", "commit range to scan for replacement commits (default: ORIG_HEAD..HEAD)")
6475
+ .option("--range <old..new>", "commit range to scan for replacement commits (default ORIG_HEAD..HEAD)")
6439
6476
  .action((opts) => {
6440
6477
  const { store, root } = storeFor();
6441
6478
  try {
@@ -6450,41 +6487,32 @@ program
6450
6487
  const parts = opts.range.split("..");
6451
6488
  if (parts.length !== 2 || !parts[0] || !parts[1]) {
6452
6489
  if (!opts.fromHook)
6453
- fail('--range must look like "old..new"');
6490
+ fail(`--range must look like "old..new"`);
6454
6491
  return;
6455
6492
  }
6456
- [oldRef, newRef] = parts;
6493
+ oldRef = parts[0];
6494
+ newRef = parts[1];
6457
6495
  }
6458
6496
  const rangeResolves = revExists(oldRef, root) && revExists(newRef, root);
6459
6497
  if (!rangeResolves && opts.range) {
6460
- // An explicitly-given range that doesn't resolve is a usage mistake, not
6461
- // "no merge happened yet" — surface it. (The installed hook never passes
6462
- // --range, so --from-hook never reaches this branch in practice.)
6463
6498
  if (!opts.fromHook)
6464
6499
  fail(`range "${oldRef}..${newRef}" does not resolve in this repository`);
6465
6500
  return;
6466
6501
  }
6502
+ // An unresolvable DEFAULT range just means no merge happened: detection is skipped below.
6467
6503
  const decisions = store.recs("decisions");
6468
- // One save path for every queue mutation below — a future change to
6469
- // queue semantics has exactly one write call to touch, not several
6470
- // independently-reasoned ones. This file has twice shipped the same
6471
- // failure class from that fragmentation: one write site's "is this
6472
- // entry resolved" logic gets fixed while a sibling write site quietly
6473
- // keeps reasoning about the queue differently, deleting an entry the
6474
- // fixed site would have left alone.
6504
+ // One save path per file, so queue semantics cannot drift across write sites.
6475
6505
  let queue = readPendingRepairs(root);
6476
- const save = (next) => {
6477
- queue = [...next];
6506
+ const saveQueue = (next) => {
6507
+ queue = next;
6478
6508
  writePendingRepairs(root, queue);
6479
6509
  };
6480
- // Tombstones for exact {id, from, to} triples a human already rejected
6481
- // via --drop — durable across runs so detection re-deriving the
6482
- // identical match on a later merge doesn't re-queue what was already
6483
- // rejected. A genuinely different `to` for the same {id, from} is a new
6484
- // proposal and is unaffected.
6510
+ // Tombstones hold the exact {id, from, to} triples a human rejected, so a later
6511
+ // merge re-deriving the identical match does not re-queue it; a different `to`
6512
+ // is a new proposal.
6485
6513
  let dropped = readDroppedRepairs(root);
6486
6514
  const saveDropped = (next) => {
6487
- dropped = [...next];
6515
+ dropped = next;
6488
6516
  writeDroppedRepairs(root, dropped);
6489
6517
  };
6490
6518
  // Review commands act only on the queue that was presented. Validate
@@ -6498,63 +6526,27 @@ program
6498
6526
  if (opts.fromHook)
6499
6527
  return fail("the post-merge hook may only detect repairs, never approve them.");
6500
6528
  }
6501
- // The queue file itself must never carry a tombstoned entry, regardless
6502
- // of how it got there — the fresh-match filtering further down only
6503
- // covers the merge this run performs. A concurrently-racing writer (the
6504
- // post-merge hook's backgrounded detection can read the queue/dropped
6505
- // files independently of a human's --drop landing in between) could
6506
- // still leave a tombstoned entry sitting in the queue file; sweeping on
6507
- // every load makes "never contains a rejected triple" a property of the
6508
- // queue file itself, not of one write site. Same fragmentation lesson
6509
- // as the comment on `save` above. Every OTHER reader of the queue
6510
- // (escalations, SessionStart, the MCP tools) calls
6511
- // readActivePendingRepairs instead of reading the raw file, so a
6512
- // tombstoned entry can never surface as a question even before this
6513
- // sweep next runs.
6514
- //
6515
- // Deletes unconditionally, without the prune's visibility caveats below
6516
- // — safe here because the swept entry's exact triple is preserved in
6517
- // dropped-commit-repairs.json, so nothing about it is unrecoverable
6518
- // (unlike the prune, which could otherwise destroy the queue's only
6519
- // durable record of a match).
6520
- const activeInQueue = withoutDropped(queue, dropped);
6521
- const swept = queue.filter((r) => !activeInQueue.includes(r));
6522
- // Remembered for --only below, same reasoning as onlyWasPruned: targeting
6523
- // an id whose only queued entry was just swept as already-rejected is not
6524
- // the same usage error as targeting an id that never existed.
6525
- const onlyWasSwept = !!opts.only && swept.some((r) => r.id === opts.only);
6529
+ // Sweep: entries whose exact triple is tombstoned. Deleted unconditionally since
6530
+ // the triple survives in the tombstone file.
6531
+ const activeQueue = withoutDropped(queue, dropped);
6532
+ const activeSet = new Set(activeQueue);
6533
+ const swept = queue.filter((r) => !activeSet.has(r));
6534
+ const onlySwept = !!opts.only && swept.some((r) => r.id === opts.only);
6526
6535
  if (swept.length) {
6527
- save(activeInQueue);
6536
+ saveQueue(activeQueue);
6528
6537
  if (!opts.quiet)
6529
6538
  console.log(`Swept ${swept.length} already-rejected match${swept.length === 1 ? "" : "es"} from the queue: ${swept.map((r) => r.id).join(", ")}`);
6530
6539
  }
6531
- // Prune only PROVEN-dead entries (deadRewrites: the decision is present
6532
- // and demonstrably moved on/superseded/rejected) — never an id merely
6533
- // absent from `decisions` right now, which can just mean this run
6534
- // checked out a branch that predates the decision, or a private
6535
- // overlay isn't mounted. That's the reader's transient view, not proof
6536
- // the match is stale, and the queue file is the one durable record of
6537
- // it (see repairqueue.ts) — deleting on absence would destroy it
6538
- // unrecoverably. This keeps the queue from accumulating an
6539
- // actually-dead entry that outlives any number of --only runs
6540
- // targeting other ids, without risking a false prune. Deferred until
6541
- // after --range validation so a pure usage error changes nothing.
6542
- const deadEntries = deadRewrites(queue, decisions);
6543
- // Remembered for --only below: targeting an id that was pruned THIS
6544
- // run is not the same usage error as targeting an id that never
6545
- // existed — the human's target was real and is now resolved, not
6546
- // unrecognized.
6547
- const onlyWasPruned = !!opts.only && deadEntries.some((r) => r.id === opts.only);
6548
- if (deadEntries.length) {
6549
- // Object identity, not id — a corrupted queue file can carry a dead
6550
- // entry and a still-live one sharing an id (#53); deadRewrites
6551
- // already preserves identity via a plain filter, so pruning by id
6552
- // here would destroy the live sibling right alongside its dead
6553
- // namesake instead of leaving it for repairDecisionCommit to apply.
6554
- const deadSet = new Set(deadEntries);
6555
- save(queue.filter((r) => !deadSet.has(r)));
6540
+ // Prune: proven-dead entries only (never ones whose decision is merely absent this run).
6541
+ // Removed by object identity, not id: a corrupted queue may hold a dead and a live
6542
+ // entry sharing an id, and the live sibling must survive.
6543
+ const dead = deadRewrites(queue, decisions);
6544
+ const onlyPruned = !!opts.only && dead.some((r) => r.id === opts.only);
6545
+ if (dead.length) {
6546
+ const deadSet = new Set(dead);
6547
+ saveQueue(queue.filter((r) => !deadSet.has(r)));
6556
6548
  if (!opts.quiet)
6557
- console.log(`Pruned ${deadEntries.length} dead queue entr${deadEntries.length === 1 ? "y" : "ies"} (decision moved on, has no commit on record, or was superseded/rejected): ${deadEntries.map((r) => r.id).join(", ")}`);
6549
+ console.log(`Pruned ${dead.length} dead queue ${dead.length === 1 ? "entry" : "entries"} (decision moved on, has no commit on record, or was superseded/rejected): ${dead.map((r) => r.id).join(", ")}`);
6558
6550
  }
6559
6551
  const candidates = rangeResolves && !opts.apply && !opts.drop ? mergeRangeChanges(oldRef, newRef, root) : [];
6560
6552
  // Orphaned means HEAD no longer reaches the commit either: a historical
@@ -6562,82 +6554,42 @@ program
6562
6554
  const orphaned = candidates.length ? orphanedCommitDecisions(decisions, (sha) => commitRepairStatusAgainst(sha, [newRef, "HEAD"], root)) : [];
6563
6555
  const patchIds = orphaned.length ? commitPatchIds([...orphaned.map((d) => d.commit), ...candidates.map((c) => c.sha)], root) : new Map();
6564
6556
  const freshPlan = candidates.length ? planCommitRepair(orphaned, candidates, (sha) => patchIds.get(sha) ?? null) : { rewrites: [], records: [] };
6565
- // Detection always queues a fresh match — local-only, never committed, so
6566
- // it's safe to persist unconditionally. This is what makes running
6567
- // opportunistically (from the hook) worth anything: the match survives
6568
- // past this process exiting and past ORIG_HEAD getting overwritten by the
6569
- // next merge, so a human can confirm it later with `--apply` alone.
6570
- // A match whose exact {id, from, to} triple was already tombstoned by
6571
- // an earlier --drop is filtered out first — the human already rejected
6572
- // exactly this proposed replacement, and a later merge re-deriving it
6573
- // independently isn't a reason to ask again. A different `to` for the
6574
- // same still-orphaned commit is a new proposal and passes through.
6575
- const freshRewrites = withoutDropped(freshPlan.rewrites, dropped);
6576
- if (freshRewrites.length)
6577
- save(mergeRewrites(freshRewrites, queue));
6557
+ // Always persisted (local-only file): a hook-time detection survives for a later human --apply.
6558
+ const freshMatches = withoutDropped(freshPlan.rewrites, dropped);
6559
+ if (freshMatches.length)
6560
+ saveQueue(mergeRewrites(freshMatches, queue));
6578
6561
  if (opts.drop) {
6579
6562
  const before = queue.length;
6580
- // firstFor is the single first-match-by-id rule — the same one
6581
- // pickRewrite and mergeRewrites go through (#53, #56, #58) — to
6582
- // decide which same-id entry would actually apply. A corrupted
6583
- // queue file can carry two entries sharing an id, and dropping by
6584
- // id would destroy the untargeted sibling too, with no tombstone
6585
- // recording what it was. Filtering by object identity (not
6586
- // `r.id !== opts.drop`) removes only the one entry actually
6587
- // dropped; any other entry sharing the id is untouched and stays
6588
- // queued.
6589
6563
  const target = firstFor(queue, opts.drop);
6590
- save(queue.filter((r) => r !== target));
6591
- // Tombstone only when something real was actually queued for this id —
6592
- // an id that was never queued has no {from, to} to record, and
6593
- // "nothing queued" is a usage-mistake signal that shouldn't quietly
6594
- // create a tombstone file.
6564
+ saveQueue(queue.filter((r) => r !== target));
6595
6565
  if (target) {
6596
6566
  saveDropped(addDropped([{ id: target.id, from: target.from, to: target.to }], dropped));
6597
- // An identical {id, from, to} sibling (a corrupted queue file, a
6598
- // hand edit) is now tombstoned but still sitting in the queue this
6599
- // run just saved above — the load-time sweep near the top of this
6600
- // action already promises the queue file can never carry a
6601
- // tombstoned entry, so re-run it here. Without this, --apply later
6602
- // in this same invocation would happily write the exact rewrite
6603
- // the human just rejected (#53 follow-up).
6604
- save(withoutDropped(queue, dropped));
6567
+ // Re-sweep so an identical sibling cannot be applied later in this invocation.
6568
+ saveQueue(withoutDropped(queue, dropped));
6605
6569
  }
6606
6570
  if (!opts.quiet) {
6607
- console.log(queue.length === before
6608
- ? `Nothing queued or matched for "${opts.drop}" to drop.`
6609
- : `Dropped "${opts.drop}" from the queue — the same match won't resurface on its own; a genuinely different candidate for this decision still can.`);
6571
+ if (queue.length === before && !target)
6572
+ console.log(`Nothing queued or matched for "${opts.drop}" to drop.`);
6573
+ else
6574
+ console.log(`Dropped "${opts.drop}" from the queue — the same match won't resurface on its own; a genuinely different candidate for this decision still can.`);
6610
6575
  }
6611
6576
  if (!opts.apply)
6612
6577
  return;
6613
6578
  }
6614
- // Deliberately checked before --only ever gets a chance to consult
6615
- // onlyWasPruned/onlyWasSwept below: when the prune or the sweep just
6616
- // emptied the queue entirely, --only's own explanatory messages never
6617
- // get a chance to fire — this generic message covers that case too,
6618
- // and still exits 0 either way, so no usage-error bug survives here.
6579
+ // Deliberately before the --only messages.
6619
6580
  if (!queue.length) {
6620
6581
  if (!opts.quiet)
6621
6582
  console.log("✓ Nothing to repair — no orphaned commit reference matched unambiguously, and nothing queued from an earlier run.");
6622
6583
  return;
6623
6584
  }
6624
- // --only restricts the apply (or dry-run preview) to one decision id,
6625
- // leaving every other queued/matched candidate exactly as it was — the
6626
- // escalation asks a per-decision question, so the answer surface should
6627
- // let a human accept one without also accepting everything else queued.
6628
6585
  const toApply = opts.only ? queue.filter((r) => r.id === opts.only) : queue;
6629
6586
  if (opts.only && !toApply.length) {
6630
- if (onlyWasSwept) {
6631
- // Not a usage error either: the human already rejected exactly this
6632
- // match via an earlier --drop, and this run's sweep just confirmed
6633
- // the rejection still holds — nothing left to apply.
6587
+ if (onlySwept) {
6634
6588
  if (!opts.quiet)
6635
6589
  console.log(`"${opts.only}" was already rejected via --drop — nothing left to do.`);
6636
6590
  return;
6637
6591
  }
6638
- if (onlyWasPruned) {
6639
- // Not a usage error: the human's target was real, and this run
6640
- // already resolved it above (see the prune's own message for why).
6592
+ if (onlyPruned) {
6641
6593
  if (!opts.quiet)
6642
6594
  console.log(`"${opts.only}" was pruned earlier in this run — nothing left to do.`);
6643
6595
  return;
@@ -6646,79 +6598,32 @@ program
6646
6598
  fail(`no queued or matched entry for "${opts.only}"`);
6647
6599
  return;
6648
6600
  }
6649
- // Whether a listed entry's decision is visible at all this run — a row
6650
- // that isn't can't actually be resolved by --apply (see
6651
- // resolvedRewriteIds below), so dry-run/apply output must say so
6652
- // instead of listing it as though --apply would act on it. Same
6653
- // reasoning applies to a `to` that doesn't resolve to a real commit
6654
- // here (issue #48): never write an unverified `to` into a decision's
6655
- // commit field, and never claim --apply would either. Computed before
6656
- // the dry-run branch below so the preview and the real run agree on
6657
- // what --apply can actually do. `null` from commitsExist means the
6658
- // check itself failed to run (not a git repo, git missing, timeout)
6659
- // and is treated as fail-open — same discipline as drift.ts's own use
6660
- // of commitsExist, though the asymmetry is worth naming: drift.ts only
6661
- // decides what to REPORT on a `null`, while this gates a WRITE that
6662
- // gets auto-committed into shared team memory. Fail-open is still the
6663
- // right call here: `isGitRepo(root)` already passed above, so `null`
6664
- // in practice means the batched `cat-file` call itself timed out —
6665
- // rare, and a corrupted `to` that slips through is fully recoverable
6666
- // (the entry survives durably in the queue either way; a bad write
6667
- // is a revert). Fail-closed's cost — treating every entry as withheld
6668
- // on a transient git error, indefinitely, on every run until the
6669
- // error clears — is the worse failure mode to default to.
6601
+ // Apply-ability is computed before the dry-run branch so preview and apply agree.
6670
6602
  const visibleIds = new Set(decisions.map((d) => d.id));
6671
- const existingTargets = commitsExist(toApply.map((r) => r.to), root);
6672
- const { applicable, withheld } = withheldForUnresolvableTo(toApply, existingTargets);
6603
+ // A null existence result is fail-open (treated as existing): isGitRepo already
6604
+ // passed, so null is a rare timeout, and a bad write is recoverable whereas
6605
+ // failing closed would withhold every entry indefinitely.
6606
+ const { applicable, withheld } = withheldForUnresolvableTo(toApply, commitsExist(toApply.map((r) => r.to), root));
6607
+ const applicableSet = new Set(applicable);
6673
6608
  const withheldSet = new Set(withheld);
6674
- // The plan --apply would act on if it ran right now — built here, before
6675
- // the dry-run branch, so the preview reasons about the exact same plan
6676
- // repairDecisionCommit would. `pickRewrite` (src/core/commitrepair.ts, a
6677
- // plan-scoped `firstFor`) decides which entry wins when `applicable`
6678
- // carries two sharing a decision id (a corrupted queue file, a hand
6679
- // edit): first match, by construction. `wasChosen` asks that same
6680
- // question by object identity rather than re-deriving it, so this file
6681
- // and commitrepair.ts can never independently drift on which entry
6682
- // "won" (#51, #58).
6683
6609
  const plan = { rewrites: applicable, records: [...new Set(applicable.map((r) => r.id))] };
6684
- const applicableSet = new Set(applicable);
6685
- const wasChosen = (r) => pickRewrite(plan, r.id) === r;
6686
- // Whether an entry will still be sitting in the queue after this run,
6687
- // and why — computed ONCE and shared by the dry-run preview, the queue
6688
- // sweep, and the post-apply "left queued" report, so the three can
6689
- // never independently drift out of sync about the same entry (they
6690
- // did, twice, across two review rounds, before this was unified). The
6691
- // sweep walks the FULL `queue`, not just `toApply` — under --only, an
6692
- // untargeted id's entries never entered `applicable` at all, so
6693
- // `wasChosen` would read "not chosen" for them too. Gating "duplicate"
6694
- // on `applicableSet.has(r)` keeps the label meaning what it says: a
6695
- // SIBLING actually competed for this exact id and lost, not merely
6696
- // "this id wasn't this run's `applicable` set for some other reason."
6697
- //
6698
- // Precedence, most to least eclipsing: invisible, then withheld, then
6699
- // duplicate. Invisible and withheld can each resolve on their own (a
6700
- // later run sees the decision, or its `to` starts existing) or never
6701
- // will (withheld, permanently, unless dropped) — "duplicate" is neither:
6702
- // it means a SIBLING entry for the same id already won and got applied,
6703
- // which repairDecisionCommit's own from-mismatch bail already makes true
6704
- // by the time anything downstream would check it, so it's only worth
6705
- // naming for entries that are otherwise resolvable.
6706
- const reasonQueued = (r) => !visibleIds.has(r.id) ? "invisible"
6610
+ const isChosen = (r) => pickRewrite(plan, r.id) === r;
6611
+ const stayReason = (r) => !visibleIds.has(r.id) ? "invisible"
6707
6612
  : withheldSet.has(r) ? "withheld"
6708
- : applicableSet.has(r) && !wasChosen(r) ? "duplicate"
6613
+ : applicableSet.has(r) && !isChosen(r) ? "duplicate"
6709
6614
  : null;
6710
- const staysQueued = (r) => reasonQueued(r) !== null;
6615
+ const staysQueued = (r) => stayReason(r) !== null;
6711
6616
  if (!opts.apply) {
6712
6617
  if (!opts.quiet) {
6713
- const unresolvedCount = toApply.filter(staysQueued).length;
6714
- console.log(unresolvedCount
6715
- ? `Would repair ${toApply.length - unresolvedCount} of ${toApply.length} listed:`
6618
+ const stayingCount = toApply.filter(staysQueued).length;
6619
+ console.log(stayingCount > 0
6620
+ ? `Would repair ${toApply.length - stayingCount} of ${toApply.length} listed:`
6716
6621
  : `Would repair ${toApply.length} commit reference(s):`);
6717
6622
  // Match strength, so a reviewer can tell an identical change from a
6718
6623
  // file-overlap guess before applying it into shared memory (#450).
6719
6624
  const previewPatchIds = commitPatchIds(toApply.flatMap((r) => [r.from, r.to]), root);
6720
6625
  for (const r of toApply) {
6721
- const reason = reasonQueued(r);
6626
+ const reason = stayReason(r);
6722
6627
  const samePatch = !!previewPatchIds.get(r.from) && previewPatchIds.get(r.from) === previewPatchIds.get(r.to);
6723
6628
  const label = reason === "invisible" ? "not visible this run — --apply would leave it queued"
6724
6629
  : reason === "withheld" ? "proposed replacement commit doesn't resolve in this repository — --apply would leave it queued"
@@ -6736,7 +6641,7 @@ program
6736
6641
  }
6737
6642
  return;
6738
6643
  }
6739
- const touchedHomes = new Set();
6644
+ const homes = new Set();
6740
6645
  const appliedIds = new Set();
6741
6646
  for (const d of decisions) {
6742
6647
  const healed = repairDecisionCommit(d, plan);
@@ -6744,47 +6649,25 @@ program
6744
6649
  continue;
6745
6650
  const home = decisionMemoryHome(store, d.id);
6746
6651
  store.putWhereItLives("decisions", healed);
6747
- touchedHomes.add(home);
6652
+ homes.add(home);
6748
6653
  appliedIds.add(d.id);
6749
6654
  }
6750
- // Only a candidate whose decision was actually VISIBLE this run AND
6751
- // whose `to` resolved in this repository AND wasn't beaten out by a
6752
- // same-id sibling is resolved (applied, or found stale by
6753
- // repairDecisionCommit's own from-mismatch bail) — resolvedRewriteIds
6754
- // shares deadRewrites' own reasoning: an id we could not see this run,
6755
- // or whose replacement doesn't exist, is not proof of anything, so it
6756
- // stays queued rather than being swept up just because it was in
6757
- // `toApply`. This also covers --only: toApply is just the one targeted
6758
- // id, so an invisible, unresolvable, or beaten-out target is left
6759
- // queued instead of deleted unresolved.
6760
- //
6761
- // `staysQueued` (OBJECT identity for both the withheld half, via
6762
- // `withheldSet.has(r)`, and the duplicate half, via `wasChosen`) is
6763
- // OR'd in ahead of the id-keyed `!resolvedIds.has(r.id)`: a corrupted
6764
- // queue file could carry two entries sharing an id — one resolvable,
6765
- // one not, or both resolvable — and an id-keyed check alone would treat
6766
- // the pair as one unit, deleting/misreporting whichever entry didn't
6767
- // actually get applied (#48, #51).
6768
- const resolvedIds = resolvedRewriteIds(applicable, decisions);
6769
- save(queue.filter((r) => staysQueued(r) || !resolvedIds.has(r.id)));
6655
+ // Invisible / unresolvable / beaten-out entries are never deleted just because they
6656
+ // were targeted. Identity-keyed checks come first so same-id pairs are handled per entry.
6657
+ const resolved = resolvedRewriteIds(applicable, decisions);
6658
+ const keep = (r) => staysQueued(r) || !resolved.has(r.id);
6659
+ saveQueue(queue.filter(keep));
6770
6660
  if (!appliedIds.size) {
6771
- // deadRewrites already pruned any VISIBLE decision that moved on or
6772
- // was superseded/rejected, so a targeted, resolvable id that IS
6773
- // visible always reaches repairDecisionCommit with a matching
6774
- // `from` and is always healed into a new object (appliedIds.add
6775
- // fires unconditionally for every visible match) — reaching here
6776
- // means every targeted candidate stayed queued (invisible or
6777
- // withheld, reported independently so neither reason disappears).
6778
- const invisibleIds = toApply.filter((r) => reasonQueued(r) === "invisible").map((r) => r.id);
6779
- const withheldIds = toApply.filter((r) => reasonQueued(r) === "withheld").map((r) => r.id);
6661
+ // Counted per entry (not per distinct id), the same way the post-apply
6662
+ // "left queued" summary below counts, so two same-id entries read as two.
6663
+ const invisibleIds = toApply.filter((r) => stayReason(r) === "invisible").map((r) => r.id);
6664
+ const withheldIds = toApply.filter((r) => stayReason(r) === "withheld").map((r) => r.id);
6780
6665
  if (!opts.quiet) {
6781
6666
  if (invisibleIds.length) {
6782
6667
  console.log(`Nothing applied — none of the targeted decision(s) are visible this run (branch checkout, or a private overlay not mounted?): ${invisibleIds.join(", ")}. Left queued for a run where they are — or \`hunch repair-provenance --drop <dec_id>\` if a decision is gone for good.`);
6783
6668
  }
6784
6669
  if (withheldIds.length) {
6785
- const subject = withheldIds.length === 1
6786
- ? "an entry whose proposed replacement commit doesn't"
6787
- : "entries whose proposed replacement commits don't";
6670
+ const subject = withheldIds.length === 1 ? "an entry whose proposed replacement commit doesn't" : "entries whose proposed replacement commits don't";
6788
6671
  console.log(`${invisibleIds.length ? "Also nothing" : "Nothing"} applied for ${subject} resolve in this repository: ${withheldIds.join(", ")}. Left queued — this won't self-heal on its own; \`hunch repair-provenance --drop <dec_id>\` to reject it.`);
6789
6672
  }
6790
6673
  }
@@ -6792,37 +6675,28 @@ program
6792
6675
  }
6793
6676
  store.reindex();
6794
6677
  const rangeLabel = rangeResolves ? `${oldRef}..${newRef}` : "queued";
6795
- pumpMemoryHomes(store, root, touchedHomes, `hunch: repair ${appliedIds.size} commit reference(s) after squash-merge (${rangeLabel})`);
6678
+ pumpMemoryHomes(store, root, homes, `hunch: repair ${appliedIds.size} commit reference(s) after squash-merge (${rangeLabel})`);
6796
6679
  if (!opts.quiet) {
6797
6680
  console.log(`✓ Repaired ${appliedIds.size} commit reference(s):`);
6798
- // `wasChosen` restricts this to the one entry per id that
6799
- // repairDecisionCommit actually wrote — `appliedIds` alone is keyed by
6800
- // decision id, so without it a same-id sibling that never got written
6801
- // would print here too (#51).
6802
- for (const r of applicable)
6803
- if (appliedIds.has(r.id) && wasChosen(r))
6681
+ for (const r of applicable) {
6682
+ // A losing same-id sibling must not be listed.
6683
+ if (appliedIds.has(r.id) && isChosen(r))
6804
6684
  console.log(` ${r.id} ${r.from} → ${r.to}`);
6805
- const stillQueued = toApply.filter((r) => staysQueued(r) || !resolvedIds.has(r.id));
6806
- const stillInvisible = stillQueued.filter((r) => reasonQueued(r) === "invisible");
6807
- const stillWithheld = stillQueued.filter((r) => reasonQueued(r) === "withheld");
6808
- const stillDuplicate = stillQueued.filter((r) => reasonQueued(r) === "duplicate");
6809
- if (stillInvisible.length) {
6810
- console.log(`\n${stillInvisible.length} entr${stillInvisible.length === 1 ? "y" : "ies"} not visible this run, left queued: ${stillInvisible.map((r) => r.id).join(", ")} — or \`hunch repair-provenance --drop <dec_id>\` if a decision is gone for good.`);
6811
6685
  }
6812
- if (stillWithheld.length) {
6813
- console.log(`\n${stillWithheld.length} entr${stillWithheld.length === 1 ? "y" : "ies"} left queued — the proposed replacement commit doesn't resolve in this repository: ${stillWithheld.map((r) => r.id).join(", ")} — \`hunch repair-provenance --drop <dec_id>\` to reject it.`);
6686
+ const left = toApply.filter(keep);
6687
+ const plural = (n) => (n === 1 ? "entry" : "entries");
6688
+ const invisibleLeft = left.filter((r) => stayReason(r) === "invisible");
6689
+ const withheldLeft = left.filter((r) => stayReason(r) === "withheld");
6690
+ const duplicateLeft = left.filter((r) => stayReason(r) === "duplicate");
6691
+ if (invisibleLeft.length) {
6692
+ console.log(`\n${invisibleLeft.length} ${plural(invisibleLeft.length)} not visible this run, left queued: ${invisibleLeft.map((r) => r.id).join(", ")} — or \`hunch repair-provenance --drop <dec_id>\` if a decision is gone for good.`);
6693
+ }
6694
+ if (withheldLeft.length) {
6695
+ console.log(`\n${withheldLeft.length} ${plural(withheldLeft.length)} left queued — the proposed replacement commit doesn't resolve in this repository: ${withheldLeft.map((r) => r.id).join(", ")} — \`hunch repair-provenance --drop <dec_id>\` to reject it.`);
6814
6696
  }
6815
- if (stillDuplicate.length) {
6816
- // Every entry here shares its id with the sibling that won (that's
6817
- // what "duplicate" means) — listing ids would just repeat one
6818
- // value, so name the `to` each one proposed instead. No
6819
- // `--drop <dec_id>` pointer, unlike the other two buckets: `--drop`
6820
- // targets the first queued match for an id (the same one
6821
- // pickRewrite would apply), which by now is the winning sibling
6822
- // that's already gone from the queue, not the losing one named
6823
- // here — pointing at `--drop <dec_id>` would read as an offer to
6824
- // reject this exact entry when it can't (#53).
6825
- console.log(`\n${stillDuplicate.length} entr${stillDuplicate.length === 1 ? "y" : "ies"} left queued — a sibling entry for the same decision was applied instead (only the first queued match per decision is ever applied): ${stillDuplicate.map((r) => `${r.id} (${r.to})`).join(", ")}`);
6697
+ if (duplicateLeft.length) {
6698
+ // No --drop pointer: drop targets the first match, which is the winner already gone.
6699
+ console.log(`\n${duplicateLeft.length} ${plural(duplicateLeft.length)} left queued — a sibling entry for the same decision was applied instead (only the first queued match per decision is ever applied): ${duplicateLeft.map((r) => `${r.id} (${r.to})`).join(", ")}`);
6826
6700
  }
6827
6701
  }
6828
6702
  }
@@ -7362,13 +7236,13 @@ program
7362
7236
  console.log(`· ${f.id} — ${f.detail}`);
7363
7237
  console.log(`\nHeal: this is a HUMAN call — the decision's authority is unchanged until you make it. Re-attest (update the premise's review_by/attested), supersede via /capture, or retire the decision. Keeping it for consistency is a valid answer.\n`);
7364
7238
  }
7365
- // Every drift kind heals here — see bug_drift_heal_asymmetry above.
7239
+ // Every drift kind must heal here.
7366
7240
  const commitUnresolvable = kind("commit-unresolvable");
7367
7241
  if (commitUnresolvable.length) {
7368
7242
  console.log(`${commitUnresolvable.length} decision(s) cite a commit that no longer resolves in this repository:\n`);
7369
7243
  for (const f of commitUnresolvable)
7370
7244
  console.log(`· ${f.id} — ${f.detail}`);
7371
- console.log(`\nHeal: this is a HUMAN call — check \`hunch repair-provenance\` for a queued match (the post-merge hook detects these automatically while the commit is still resolvable, but never applies one unattended); run \`hunch repair-provenance --apply\` to confirm it, or if the commit is already gone, manually correct the decision's provenance or leave it as historical record.\n`);
7245
+ console.log(`\nHeal: this is a HUMAN call. Check \`hunch repair-provenance\` for a queued match (the post-merge hook detects them while the commit is still resolvable, but never applies one unattended), then run \`hunch repair-provenance --apply\` to confirm it; if the commit is already gone, correct the provenance by hand or keep it as historical record.\n`);
7372
7246
  }
7373
7247
  const fenceUnclosed = kind("doc-fence-unclosed");
7374
7248
  if (fenceUnclosed.length) {
@@ -7479,20 +7353,21 @@ program
7479
7353
  process.exitCode = 1; // conflict markers remain → block the commit for review
7480
7354
  }
7481
7355
  });
7482
- // ---- merge-driver-grounding (internal; git invokes this) ------------------
7356
+ // ---- merge-driver-grounding (internal) -----------------------------------
7483
7357
  program
7484
7358
  .command("merge-driver-grounding")
7485
- .description("(internal) git merge driver for the generated grounding docs — auto-resolves a hard conflict confined to the record-counts sentence.")
7486
- .argument("<base>", "%O — common ancestor")
7487
- .argument("<ours>", "%A — current branch (also the OUTPUT file)")
7488
- .argument("<theirs>", "%B — other branch")
7489
- .argument("[path]", "%P — pathname being merged")
7359
+ .description("(internal) git's merge driver for the generated grounding docs: auto-resolves a hard conflict confined to the record-counts sentence.")
7360
+ .argument("<base>", "common ancestor (%O)")
7361
+ .argument("<ours>", "current branch version, and the output file (%A)")
7362
+ .argument("<theirs>", "other branch version (%B)")
7363
+ .argument("[path]", "pathname (%P)")
7490
7364
  .action((base, ours, theirs) => {
7491
- const res = mergeGroundingFile(base, ours, theirs);
7492
- if (res.write !== null)
7493
- writeFileSync(ours, res.write);
7494
- if (res.conflict)
7495
- process.exitCode = 1; // real conflict, or git itself errored → leave for a human
7365
+ const result = mergeGroundingFile(base, ours, theirs);
7366
+ if (result.write !== null)
7367
+ writeFileSync(ours, result.write);
7368
+ // A real conflict or a git error: leave it for a human.
7369
+ if (result.conflict)
7370
+ process.exitCode = 1;
7496
7371
  });
7497
7372
  // ---- doctor ---------------------------------------------------------------
7498
7373
  program
@@ -7535,11 +7410,9 @@ program
7535
7410
  const { store, root } = storeFor();
7536
7411
  console.log(`Hunch root: ${root}`);
7537
7412
  console.log(`git repo: ${isGitRepo(root) ? "yes" : "no"} ${isGitRepo(root) ? `(HEAD ${headSha(root).slice(0, 8)})` : ""}`);
7538
- // post-commit/post-merge are the two hooks every setup path installs; a
7539
- // repo that ran `hunch init` before the post-merge hook existed never
7540
- // received it, and there was no way to discover that short of noticing a
7541
- // squash-merge went unrepaired. pre-commit is opt-out (`--no-enforce`),
7542
- // so its absence is informational only, never a warning.
7413
+ // post-commit and post-merge are the two hooks every setup path installs; a repo
7414
+ // initialized before post-merge existed never got it and had no way to notice.
7415
+ // pre-commit is opt-out (--no-enforce), so its absence is informational only.
7543
7416
  if (isGitRepo(root)) {
7544
7417
  const report = hookReport(root);
7545
7418
  const hooks = hookStatus(root);
@@ -7621,7 +7494,7 @@ program
7621
7494
  for (const line of synthesisStatusLines(resolution, process.env))
7622
7495
  console.log(line);
7623
7496
  const ctxWarning = await maybeWarnOllamaContext(provider.name, process.env);
7624
- if (ctxWarning)
7497
+ if (ctxWarning !== null)
7625
7498
  console.log(ctxWarning);
7626
7499
  // The native addons load on first PARSE, not at import, so no other command
7627
7500
  // that merely starts up proves the parser works. Doctor is the one place
@@ -7757,27 +7630,75 @@ function readStdin() {
7757
7630
  });
7758
7631
  }
7759
7632
  /** Absolute edit path → repo-relative, forward-slash (constraint scopes are
7760
- * forward-slash globs even on Windows), or "" when `abs` isn't (resolvably)
7761
- * inside `root`. Thin adapter over the shared `repoRelativeTarget` (core/paths.ts,
7762
- * which realpath-normalizes both ends first — see its docstring for why, incl.
7763
- * dec_e0a36efbf5): that function passes an unresolvable absolute path through
7764
- * UNCHANGED (still absolute) rather than signaling failure directly, since other
7765
- * callers want the original target back to fail their own match safely. This
7766
- * adapter converts that "still absolute" signal to "" — the sentinel `onEdit`
7767
- * and `editTargets` below already treat as "skip, not in this repo". */
7633
+ * forward-slash globs even on Windows). Returns "" when the path isn't resolvably
7634
+ * inside `root`. A thin adapter over the shared `repoRelativeTarget` in core/paths.ts,
7635
+ * which realpath-normalizes both ends but passes an unresolvable absolute path through
7636
+ * unchanged; this converts that "still absolute" result into "", the sentinel that
7637
+ * `onEdit` and `editTargets` treat as "skip, not in this repo". */
7768
7638
  function toRepoRel(root, abs) {
7769
7639
  const rel = repoRelativeTarget(abs, root);
7770
- return isAbsolute(rel) || /^[a-zA-Z]:/.test(rel) ? "" : rel;
7640
+ if (isAbsolute(rel) || /^[A-Za-z]:/.test(rel))
7641
+ return "";
7642
+ return rel;
7643
+ }
7644
+ /** The repository root a hook event belongs to. Normally the process root, but an
7645
+ * edit to a file in another LINKED worktree of the same repository (or a subagent
7646
+ * started in one) belongs to that worktree's memory: grounded against the process
7647
+ * root it was dropped as "outside the repo", with no output at all. Re-roots only
7648
+ * to a worktree sharing this repository's git common dir that carries its own
7649
+ * .hunch/; one without it is reported as ungrounded rather than skipped silently.
7650
+ * Checkouts are told apart by their `.git` entry, never by a .hunch/ hit, so a
7651
+ * nested package .hunch/ inside this checkout (a monorepo) never displaces the
7652
+ * process root and its blocking constraints. Git is spawned only for a file in a
7653
+ * different checkout than the process root's. */
7654
+ function hookEventRoot(processRoot, evt) {
7655
+ const toolEvent = evt.hook_event_name === "PreToolUse" || evt.hook_event_name === "PostToolUse" || evt.hook_event_name === "PostToolUseFailure";
7656
+ const subject = evt.hook_event_name === "SubagentStart" ? evt.cwd
7657
+ : toolEvent ? (evt.tool_input?.patch_files?.[0]?.path ?? evt.tool_input?.file_path) : undefined;
7658
+ if (!subject || !isAbsolute(subject))
7659
+ return { root: processRoot };
7660
+ try {
7661
+ const ownerTop = checkoutTop(evt.hook_event_name === "SubagentStart" ? subject : dirname(subject));
7662
+ const processTop = checkoutTop(processRoot);
7663
+ if (!ownerTop || !processTop || sameFilesystemEntry(ownerTop, processTop))
7664
+ return { root: processRoot };
7665
+ const common = checkoutCommonDir(processRoot);
7666
+ const ownerCommon = common ? checkoutCommonDir(ownerTop) : "";
7667
+ if (!common || !ownerCommon || !sameFilesystemEntry(common, ownerCommon))
7668
+ return { root: processRoot };
7669
+ // The same Hunch root inside the other worktree: its top, or the package dir a
7670
+ // subdirectory-rooted process uses.
7671
+ const owner = join(ownerTop, relative(processTop, processRoot));
7672
+ if (isDir(join(owner, ".hunch")))
7673
+ return { root: owner };
7674
+ return { root: processRoot, ungrounded: `Hunch: ${toPosixTarget(subject)} is in linked worktree ${toPosixTarget(ownerTop)}, which has no .hunch/ — this edit proceeds ungrounded.` };
7675
+ }
7676
+ catch {
7677
+ return { root: processRoot };
7678
+ }
7679
+ }
7680
+ /** The nearest directory at or above `start` holding a `.git` entry (a checkout's top;
7681
+ * a linked worktree's `.git` is a file), or "" outside every checkout. Pure fs. */
7682
+ function checkoutTop(start) {
7683
+ let cur = resolve(start);
7684
+ for (;;) {
7685
+ if (existsSync(join(cur, ".git")))
7686
+ return cur;
7687
+ const parent = dirname(cur);
7688
+ if (parent === cur)
7689
+ return "";
7690
+ cur = parent;
7691
+ }
7771
7692
  }
7772
7693
  /** Files a shell command wrote get the grounding the edit tools would have
7773
7694
  * delivered before the edit — late, but while the agent can still revise.
7774
7695
  * Already-served grounding (a delta) is not repeated. */
7775
7696
  const MAX_SHELL_GROUNDED = 3;
7776
- function shellWriteGrounding(root, store, provider, evt, written) {
7697
+ function shellWriteGrounding(root, store, provider, evt, written, ledgerRoot = root) {
7777
7698
  const parts = [];
7778
7699
  const grounded = [];
7779
7700
  for (const target of written.slice(0, MAX_SHELL_GROUNDED)) {
7780
- const g = fileGrounding(root, store, provider, evt, target, join(root, target));
7701
+ const g = fileGrounding(root, store, provider, evt, target, join(root, target), ledgerRoot);
7781
7702
  if (g?.mode === "full") {
7782
7703
  parts.push(g.text);
7783
7704
  grounded.push(target);
@@ -7896,8 +7817,9 @@ const SIBLING_MEMORY_BUDGET_TOKENS = 800;
7896
7817
  * retired code, doc anchors, recent tasks and sibling-fix lessons. `delta` when
7897
7818
  * identical grounding was already served this session, null when Hunch knows
7898
7819
  * nothing about the file. Serves the pre-edit hook and files a shell command
7899
- * wrote (which never pass through the edit tools). */
7900
- function fileGrounding(root, store, provider, evt, target, abs) {
7820
+ * wrote (which never pass through the edit tools). `ledgerRoot` is where the prompt's
7821
+ * task lives — the process root when the event was re-rooted to a linked worktree. */
7822
+ function fileGrounding(root, store, provider, evt, target, abs, ledgerRoot = root) {
7901
7823
  // Decision-grounding for PROSE (doc≠graph): a markdown target that declares
7902
7824
  // <!-- hunch:topic … --> anchors gets each topic's CURRENT decision — the
7903
7825
  // graph outranks the prose being edited, and a stale pin is called out inline.
@@ -7914,7 +7836,7 @@ function fileGrounding(root, store, provider, evt, target, abs) {
7914
7836
  // at warning, not relevance guesses. No selection (a host without a prompt
7915
7837
  // hook, a legacy session, a prompt that selected nothing) keeps the unfiltered
7916
7838
  // grounding.
7917
- const selection = loadTaskSelection(hookReportTaskId(root, provider, evt));
7839
+ const selection = loadTaskSelection(hookReportTaskId(ledgerRoot, provider, evt));
7918
7840
  const assembled = store.assembleContext(target);
7919
7841
  const selected = selection ? new Set(selection.qualifying) : null;
7920
7842
  const ctx = selected ? {
@@ -7949,7 +7871,7 @@ function fileGrounding(root, store, provider, evt, target, abs) {
7949
7871
  const retired = store.retiredForFile(target).filter((r) => r.symbols.length || r.deps.length);
7950
7872
  // Recent-task history is advisory; under a task selection it is dropped
7951
7873
  // (`hunch task list` still has it).
7952
- const recentTasks = selected ? [] : taskSelectionSupplements(store.selectTasksAuto(target, buildTaskRankingQuery(root, hookReportTaskId(root, provider, evt), target, { excludeTargetDeliveries: true })), target);
7874
+ const recentTasks = selected ? [] : taskSelectionSupplements(store.selectTasksAuto(target, buildTaskRankingQuery(ledgerRoot, hookReportTaskId(ledgerRoot, provider, evt), target, { excludeTargetDeliveries: true })), target);
7953
7875
  const hasContent = ctx.constraints.length ||
7954
7876
  ctx.decisions.length ||
7955
7877
  ctx.bugs.length ||
@@ -8013,7 +7935,7 @@ function fileGrounding(root, store, provider, evt, target, abs) {
8013
7935
  delivery_profile: envelope.profile, ranking_policy: envelope.ranking_policy,
8014
7936
  })),
8015
7937
  ]);
8016
- const reportTaskId = hookReportTaskId(root, provider, evt);
7938
+ const reportTaskId = hookReportTaskId(ledgerRoot, provider, evt);
8017
7939
  // A new authoritative prompt gets its own full delivery. An earlier
8018
7940
  // prompt's session-level delta cannot establish this task's receipt.
8019
7941
  // A subagent reports to the prompt's task but starts with FRESH context:
@@ -8040,8 +7962,8 @@ function fileGrounding(root, store, provider, evt, target, abs) {
8040
7962
  const snapshots = snapshotDeliveredRecords(store, envelope);
8041
7963
  // The first time a lesson reaches this prompt's task, tell the USER in one
8042
7964
  // line (systemMessage); repeats of the same revision stay silent.
8043
- recalled = reportPresentationEnabled(root) ? renderRecalledLine(unseenLessons(root, reportTaskId, snapshots)) : null;
8044
- const occurrence = recordTaskDelivery(root, reportTaskId, envelope, snapshots, undefined, target);
7965
+ recalled = reportPresentationEnabled(ledgerRoot) ? renderRecalledLine(unseenLessons(ledgerRoot, reportTaskId, snapshots)) : null;
7966
+ const occurrence = recordTaskDelivery(ledgerRoot, reportTaskId, envelope, snapshots, undefined, target);
8045
7967
  reportNotice = `\n\nHunch task ${reportTaskId} · delivery ${occurrence}. Inspect exact application references with hunch_report(task_id).`;
8046
7968
  }
8047
7969
  catch {