@davesheffer/hunch 1.31.1 → 1.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (220) hide show
  1. package/README.md +1 -0
  2. package/dist/cli/automaticReviewMemory.d.ts +13 -0
  3. package/dist/cli/dna.d.ts +2 -0
  4. package/dist/cli/index.d.ts +16 -0
  5. package/dist/cli/index.js +602 -43
  6. package/dist/cli/integrations.d.ts +2 -0
  7. package/dist/cli/integrations.js +5 -2
  8. package/dist/cli/invocation.d.ts +35 -0
  9. package/dist/cli/preflight.d.ts +1 -0
  10. package/dist/cli/reviewMemory.d.ts +4 -0
  11. package/dist/cli/reviewMemoryProvider.d.ts +14 -0
  12. package/dist/cli/serve.d.ts +2 -0
  13. package/dist/cli/taskReport.d.ts +6 -0
  14. package/dist/cli/taskReport.js +124 -0
  15. package/dist/cli/update.d.ts +14 -0
  16. package/dist/client/state.d.ts +248 -0
  17. package/dist/constitution/adapters.d.ts +29 -0
  18. package/dist/constitution/behaviorAttestationBinding.d.ts +3 -0
  19. package/dist/constitution/behaviorEvaluator.d.ts +8 -0
  20. package/dist/constitution/behaviorProof.d.ts +9 -0
  21. package/dist/constitution/behaviorWorkspace.d.ts +25 -0
  22. package/dist/constitution/bootstrap.d.ts +30 -0
  23. package/dist/constitution/canonical.d.ts +15 -0
  24. package/dist/constitution/card.d.ts +58 -0
  25. package/dist/constitution/compiler.d.ts +52 -0
  26. package/dist/constitution/composition.d.ts +10 -0
  27. package/dist/constitution/corpus.d.ts +7 -0
  28. package/dist/constitution/correctionPolicyMaterializer.d.ts +57 -0
  29. package/dist/constitution/delta.d.ts +4 -0
  30. package/dist/constitution/disposition.d.ts +20 -0
  31. package/dist/constitution/evaluator.d.ts +47 -0
  32. package/dist/constitution/experiment.d.ts +466 -0
  33. package/dist/constitution/experimentRunner.d.ts +7 -0
  34. package/dist/constitution/g2.d.ts +209 -0
  35. package/dist/constitution/g2BehaviorAttestation.d.ts +45 -0
  36. package/dist/constitution/g2BehaviorCandidates.d.ts +123 -0
  37. package/dist/constitution/g2BehaviorDependencies.d.ts +83 -0
  38. package/dist/constitution/g2BehaviorMaterialization.d.ts +77 -0
  39. package/dist/constitution/g2BehaviorPolicyMaterializer.d.ts +48 -0
  40. package/dist/constitution/g2CandidateAttestation.d.ts +43 -0
  41. package/dist/constitution/g2Candidates.d.ts +80 -0
  42. package/dist/constitution/g2Drills.d.ts +33 -0
  43. package/dist/constitution/g3.d.ts +311 -0
  44. package/dist/constitution/g3Conformance.d.ts +33 -0
  45. package/dist/constitution/lifecycle.d.ts +25 -0
  46. package/dist/constitution/mutation.d.ts +15 -0
  47. package/dist/constitution/nodeTestEvidence.d.ts +13 -0
  48. package/dist/constitution/plan.d.ts +18 -0
  49. package/dist/constitution/policyRuntime.d.ts +5 -0
  50. package/dist/constitution/proof.d.ts +11 -0
  51. package/dist/constitution/repairPolicies.d.ts +34 -0
  52. package/dist/constitution/replacementFreeGit.d.ts +14 -0
  53. package/dist/constitution/replay.d.ts +40 -0
  54. package/dist/constitution/replayCache.d.ts +14 -0
  55. package/dist/constitution/replayWorker.d.ts +1 -0
  56. package/dist/constitution/repository.d.ts +129 -0
  57. package/dist/constitution/safeCheckout.d.ts +9 -0
  58. package/dist/constitution/schema.d.ts +1763 -0
  59. package/dist/constitution/scorecard.d.ts +219 -0
  60. package/dist/constitution/service.d.ts +398 -0
  61. package/dist/constitution/shadow.d.ts +54 -0
  62. package/dist/constitution/sourceMutation.d.ts +15 -0
  63. package/dist/constitution/staticGraphBaseline.d.ts +13 -0
  64. package/dist/constitution/structural.d.ts +64 -0
  65. package/dist/core/agenthook.d.ts +63 -0
  66. package/dist/core/agenthook.js +1 -0
  67. package/dist/core/automaticReviewMemory.d.ts +60 -0
  68. package/dist/core/autoreview.d.ts +66 -0
  69. package/dist/core/canonicalOrder.d.ts +3 -0
  70. package/dist/core/capturetoken.d.ts +19 -0
  71. package/dist/core/changeIdentity.d.ts +22 -0
  72. package/dist/core/changeProof.d.ts +14 -0
  73. package/dist/core/checkreport.d.ts +140 -0
  74. package/dist/core/commitrepair.d.ts +171 -0
  75. package/dist/core/commitrepair.js +238 -0
  76. package/dist/core/compare.d.ts +21 -0
  77. package/dist/core/config.d.ts +20 -0
  78. package/dist/core/conformance.d.ts +35 -0
  79. package/dist/core/constraintmatch.d.ts +54 -0
  80. package/dist/core/correction.d.ts +42 -0
  81. package/dist/core/correctionStage.d.ts +224 -0
  82. package/dist/core/declarationClusters.d.ts +149 -0
  83. package/dist/core/delivery.d.ts +110 -0
  84. package/dist/core/docanchors.d.ts +32 -0
  85. package/dist/core/docscan.d.ts +26 -0
  86. package/dist/core/drift.d.ts +13 -0
  87. package/dist/core/drift.js +31 -1
  88. package/dist/core/dupdetect.d.ts +43 -0
  89. package/dist/core/escalations.d.ts +129 -0
  90. package/dist/core/escalations.js +165 -0
  91. package/dist/core/events.d.ts +29 -0
  92. package/dist/core/evidenceMap.d.ts +164 -0
  93. package/dist/core/externalImports.d.ts +8 -0
  94. package/dist/core/format.d.ts +13 -0
  95. package/dist/core/glob.d.ts +9 -0
  96. package/dist/core/groundingLag.d.ts +84 -0
  97. package/dist/core/groundingLag.js +16 -2
  98. package/dist/core/groundingMerge.d.ts +15 -0
  99. package/dist/core/groundingMerge.js +95 -0
  100. package/dist/core/hookcache.d.ts +10 -0
  101. package/dist/core/hookpolicy.d.ts +47 -0
  102. package/dist/core/ids.d.ts +36 -0
  103. package/dist/core/importReview.d.ts +30 -0
  104. package/dist/core/io.d.ts +23 -0
  105. package/dist/core/jsonc.d.ts +4 -0
  106. package/dist/core/landscapeAdoption.d.ts +66 -0
  107. package/dist/core/landscapeDelivery.d.ts +76 -0
  108. package/dist/core/memorylog.d.ts +37 -0
  109. package/dist/core/migrate.d.ts +28 -0
  110. package/dist/core/outcomeExperience.d.ts +89 -0
  111. package/dist/core/overlaySafety.d.ts +24 -0
  112. package/dist/core/overlaySafety.js +7 -1
  113. package/dist/core/paths.d.ts +31 -0
  114. package/dist/core/pipeline.d.ts +289 -0
  115. package/dist/core/premises.d.ts +43 -0
  116. package/dist/core/provenance.d.ts +21 -0
  117. package/dist/core/publication.d.ts +54 -0
  118. package/dist/core/refrepair.d.ts +24 -0
  119. package/dist/core/refrepair.js +1 -1
  120. package/dist/core/relativeImports.d.ts +11 -0
  121. package/dist/core/repair.d.ts +44 -0
  122. package/dist/core/repairqueue.d.ts +40 -0
  123. package/dist/core/repairqueue.js +133 -0
  124. package/dist/core/reviewMemory.d.ts +40 -0
  125. package/dist/core/reviewqueue.d.ts +54 -0
  126. package/dist/core/safeRepoFile.d.ts +20 -0
  127. package/dist/core/served.d.ts +57 -0
  128. package/dist/core/served.js +14 -0
  129. package/dist/core/stateContract.d.ts +979 -0
  130. package/dist/core/stateDelivery.d.ts +78 -0
  131. package/dist/core/stateRecords.d.ts +383 -0
  132. package/dist/core/stats.d.ts +88 -0
  133. package/dist/core/strictgate.d.ts +33 -0
  134. package/dist/core/taskReport.d.ts +226 -0
  135. package/dist/core/taskReport.js +514 -0
  136. package/dist/core/taskReportCapture.d.ts +8 -0
  137. package/dist/core/taskReportCapture.js +36 -0
  138. package/dist/core/taskReportEvidence.d.ts +26 -0
  139. package/dist/core/taskReportEvidence.js +268 -0
  140. package/dist/core/taskReportHook.d.ts +20 -0
  141. package/dist/core/taskReportHook.js +76 -0
  142. package/dist/core/taskReportPaths.d.ts +3 -0
  143. package/dist/core/taskReportPaths.js +23 -0
  144. package/dist/core/taskReportPublic.d.ts +31 -0
  145. package/dist/core/taskReportPublic.js +45 -0
  146. package/dist/core/taskReportRender.d.ts +6 -0
  147. package/dist/core/taskReportRender.js +97 -0
  148. package/dist/core/topics.d.ts +73 -0
  149. package/dist/core/types.d.ts +1239 -0
  150. package/dist/core/version.d.ts +10 -0
  151. package/dist/eval/guards.d.ts +45 -0
  152. package/dist/eval/harness.d.ts +58 -0
  153. package/dist/extractors/adrImport.d.ts +59 -0
  154. package/dist/extractors/comments.d.ts +7 -0
  155. package/dist/extractors/correctionSources.d.ts +11 -0
  156. package/dist/extractors/diff.d.ts +40 -0
  157. package/dist/extractors/git.d.ts +304 -0
  158. package/dist/extractors/git.js +208 -20
  159. package/dist/extractors/helm.d.ts +31 -0
  160. package/dist/extractors/indexer.d.ts +55 -0
  161. package/dist/extractors/landscapeDiscovery.d.ts +41 -0
  162. package/dist/extractors/languages.d.ts +77 -0
  163. package/dist/extractors/nativeTreeSitter.d.ts +16 -0
  164. package/dist/extractors/parse.d.ts +54 -0
  165. package/dist/extractors/php.d.ts +26 -0
  166. package/dist/extractors/repoSource.d.ts +53 -0
  167. package/dist/extractors/testreport.d.ts +36 -0
  168. package/dist/integrations/ciAction.d.ts +8 -0
  169. package/dist/integrations/claudeConfig.d.ts +44 -0
  170. package/dist/integrations/claudemd.d.ts +13 -0
  171. package/dist/integrations/claudemd.js +11 -2
  172. package/dist/integrations/gitignore.d.ts +16 -0
  173. package/dist/integrations/gitignore.js +7 -0
  174. package/dist/integrations/health.d.ts +74 -0
  175. package/dist/integrations/hooks.d.ts +41 -0
  176. package/dist/integrations/hooks.js +92 -66
  177. package/dist/integrations/madrExport.d.ts +39 -0
  178. package/dist/integrations/madrManifest.d.ts +69 -0
  179. package/dist/integrations/mergeDriver.d.ts +3 -0
  180. package/dist/integrations/mergeDriver.js +25 -8
  181. package/dist/integrations/probe.d.ts +4 -0
  182. package/dist/integrations/providers.d.ts +103 -0
  183. package/dist/integrations/scaffold.d.ts +29 -0
  184. package/dist/integrations/sync.d.ts +30 -0
  185. package/dist/integrations/sync.js +3 -2
  186. package/dist/integrations/team.d.ts +70 -0
  187. package/dist/integrations/worktree.d.ts +8 -0
  188. package/dist/mcp/roots.d.ts +16 -0
  189. package/dist/mcp/server.d.ts +36 -0
  190. package/dist/mcp/server.js +74 -22
  191. package/dist/mcp/taskReportTools.d.ts +3 -0
  192. package/dist/mcp/taskReportTools.js +106 -0
  193. package/dist/serve/app.d.ts +36 -0
  194. package/dist/serve/config.d.ts +100 -0
  195. package/dist/serve/writelock.d.ts +22 -0
  196. package/dist/store/changeLedger.d.ts +132 -0
  197. package/dist/store/compact.d.ts +33 -0
  198. package/dist/store/db.d.ts +9 -0
  199. package/dist/store/embedder.d.ts +32 -0
  200. package/dist/store/hunchStore.d.ts +624 -0
  201. package/dist/store/hunchStore.js +6 -3
  202. package/dist/store/jsonStore.d.ts +125 -0
  203. package/dist/store/merge.d.ts +39 -0
  204. package/dist/store/privateMigrate.d.ts +24 -0
  205. package/dist/store/replay.d.ts +56 -0
  206. package/dist/store/schema.d.ts +17 -0
  207. package/dist/store/stateBinding.d.ts +145 -0
  208. package/dist/store/stateCapture.d.ts +15 -0
  209. package/dist/synthesis/cliAdapter.d.ts +22 -0
  210. package/dist/synthesis/initiator.d.ts +13 -0
  211. package/dist/synthesis/provider.d.ts +307 -0
  212. package/dist/synthesis/synthesize.d.ts +117 -0
  213. package/dist/synthesis/tripwires.d.ts +9 -0
  214. package/dist/taskReports.d.ts +76 -0
  215. package/dist/taskReports.js +67 -0
  216. package/dist/wiki/adopt.d.ts +22 -0
  217. package/dist/wiki/graph.d.ts +87 -0
  218. package/dist/wiki/wiki.d.ts +242 -0
  219. package/package.json +6 -5
  220. package/server.json +2 -2
package/dist/cli/index.js CHANGED
@@ -25,6 +25,7 @@ import { writeFileAtomic } from "../core/io.js";
25
25
  import { looksLikeCorrection, CORRECTION_NUDGE } from "../core/correction.js";
26
26
  import { HUNCH_VERSION } from "../core/version.js";
27
27
  import { registerIntegrationCommands } from "./integrations.js";
28
+ import { registerTaskReportCommands } from "./taskReport.js";
28
29
  import { registerServeCommands } from "./serve.js";
29
30
  import { registerUpdateCommand } from "./update.js";
30
31
  import { registerReviewMemoryCommands } from "./reviewMemory.js";
@@ -37,9 +38,11 @@ import { assertCompleteRepoScan, indexRepo, scanRepo } from "../extractors/index
37
38
  import { syncCommit, recordFailure, captureTestRun } from "../synthesis/synthesize.js";
38
39
  import { parseTestReport } from "../extractors/testreport.js";
39
40
  import { readSynthesisPreference, resolveSynthesisProvider, selectProvider, SYNTH_PREFERENCES, writeSynthesisPreference, normalizeProviderName, } from "../synthesis/provider.js";
40
- import { isGitRepo, isGitRepoRoot, sameGitPublication, sameRemoteUrl, canonicalRemoteUrl, repositoryUsesRemote, headSha, isolatedHeadSha, logSince, lastChangeDate, firstCommitForFile, stagedFiles, workingFiles, commitFiles, asOfDate, stagedDiff, workingDiff, commitDiff, rangeFiles, rangeDiff, rangeSubjects, revExists, revParse, commitAndPushHunch, pullHunchStatus, syncExistingHunch, gitUntrackCached, gitCommonDir, hooksDir, isLinkedWorktree, mainWorktreeRoot, gitMemoryLog, memoryMoveDiff, revertMemoryMove, pushCurrentBranch, commitChanges } from "../extractors/git.js";
41
+ import { isGitRepo, isGitRepoRoot, sameGitPublication, sameRemoteUrl, canonicalRemoteUrl, repositoryUsesRemote, headSha, isolatedHeadSha, logSince, lastChangeDate, firstCommitForFile, stagedFiles, workingFiles, commitFiles, asOfDate, stagedDiff, workingDiff, commitDiff, rangeFiles, rangeDiff, rangeSubjects, revExists, revParse, commitAndPushHunch, pullHunchStatus, syncExistingHunch, gitUntrackCached, gitCommonDir, hooksDir, isLinkedWorktree, mainWorktreeRoot, gitMemoryLog, memoryMoveDiff, revertMemoryMove, pushCurrentBranch, commitChanges, commitRepairStatus, mergeRangeChanges, commitsExist } from "../extractors/git.js";
41
42
  import { parseMemoryLog } from "../core/memorylog.js";
42
43
  import { renamesOf, planRepair, repairDecision, repairConstraint } from "../core/repair.js";
44
+ import { orphanedCommitDecisions, planCommitRepair, repairDecisionCommit, pickRewrite, commitRepairReviewHash, mergeRewrites, firstFor, deadRewrites, resolvedRewriteIds, withoutDropped, addDropped, withheldForUnresolvableTo } from "../core/commitrepair.js";
45
+ import { readPendingRepairs, writePendingRepairs, readDroppedRepairs, writeDroppedRepairs, readActivePendingRepairs, withheldRewrites } from "../core/repairqueue.js";
43
46
  import { planPolicyRepair, repairPolicySpec } from "../constitution/repairPolicies.js";
44
47
  import { writeTeamConfig, ensureTeamOverlay, readTeamConfig, safeGitUrl, safeTeamRef, overlayMatchesTeamRemote, advertisedTeamRemoteContract, boundedTeamGitEnv, cloneValidatedTeamOverlay, explicitTeamRemoteContract, teamRemoteContract } from "../integrations/team.js";
45
48
  import { runbookId, decisionId } from "../core/ids.js";
@@ -47,7 +50,7 @@ import { deriveForbids, effectiveForbids } from "../core/constraintmatch.js";
47
50
  import { extractInlineIntent } from "../extractors/comments.js";
48
51
  import { renderText, renderMarkdown, renderSarif, renderImpact, reportFailsStrict } from "../core/checkreport.js";
49
52
  import { partitionReview, isReviewDraft, READY_MIN_GROUNDED } from "../core/reviewqueue.js";
50
- import { installPostCommitHook, installPreCommitHook, installPostMergeHook } from "../integrations/hooks.js";
53
+ import { installPostCommitHook, installPreCommitHook, installPostMergeHook, hookStatus } from "../integrations/hooks.js";
51
54
  import { ensureSharedOverlayPointer } from "../integrations/worktree.js";
52
55
  import { flushCapture, flushMemoryHome, flushMemoryHomes, pinSharedRemote, sharedRemoteFor } from "../integrations/sync.js";
53
56
  import { installMergeDriver } from "../integrations/mergeDriver.js";
@@ -55,10 +58,11 @@ import { ensureGitignore, ignoreHunchMemory, HUNCH_MEMORY_DIRS } from "../integr
55
58
  import { writeCiWorkflow } from "../integrations/ciAction.js";
56
59
  import { updateClaudeMd, renderHunchSection } from "../integrations/claudemd.js";
57
60
  import { classifyGroundingBlock, describeGroundingFreshness } from "../core/groundingLag.js";
61
+ import { mergeGroundingFile } from "../core/groundingMerge.js";
58
62
  import { writeMcpJson, writeSlashCommands, installClaudeHooks } from "../integrations/scaffold.js";
59
63
  import { scaffoldProviders, regenerateGrounding, refreshExistingGrounding, refreshCommittableGrounding, GROUNDING_DOC_PATHS } from "../integrations/providers.js";
60
64
  import { healClaudeConfigCaseSplit } from "../integrations/claudeConfig.js";
61
- import { formatContext, formatSearchHit, formatStructure } from "../core/format.js";
65
+ import { formatSearchHit, formatStructure } from "../core/format.js";
62
66
  import { isStateKind, renderStateLine, stateSupplements } from "../core/stateDelivery.js";
63
67
  import { diagnoseIssueCorrectionStage, formatCorrectionStageDiagnostic } from "../core/correctionStage.js";
64
68
  import { compileVerifiedEvidenceMap, formatVerifiedEvidenceMap } from "../core/evidenceMap.js";
@@ -76,6 +80,9 @@ import { appendEvent, readEvents } from "../core/events.js";
76
80
  import { computeStats, formatStats } from "../core/stats.js";
77
81
  import { injectionMode, resetSessionInjections } from "../core/hookcache.js";
78
82
  import { recordServed, servedSummary } from "../core/served.js";
83
+ import { recordTaskDelivery, reportActivity } from "../core/taskReport.js";
84
+ import { snapshotDeliveredRecords } from "../core/taskReportEvidence.js";
85
+ import { hookReportTaskId, startHookReport, stopHookReport, observeHookDenial } from "../core/taskReportHook.js";
79
86
  import { contextHookOutput, denyHookOutput, hookProvider, normalizeHookEvent, stopHookOutput } from "../core/agenthook.js";
80
87
  import { PIPELINE_LOOP, armExecutionObligations, beforeEditProbeVerdict, compileExecutableProbes, environmentExecutableProbes, environmentExecutionObligations, executionObligationBrief, isProductPath, loadPipelineState, onCommand, onEdit, onPrompt, onSkill, pipelineEnabled, proofCheckpoint, savePipelineState, stopVerdict, unverifiedNag, } from "../core/pipeline.js";
81
88
  import { draftDuplicateOf, isAcceptedDuplicateAnchor } from "../core/dupdetect.js";
@@ -91,7 +98,7 @@ import { ADR_DIR_CANDIDATES, ADR_FILE_RE, mapAdrCorpus } from "../extractors/adr
91
98
  import { applyImportedAdrReview, carryImportedAdrReview, importedAdrReviewHash, importedAdrSourceHash, isImportedAdrDecision, pendingImportedAdrReviews } from "../core/importReview.js";
92
99
  import { exportMadrCorpus, isRegenerableMadr } from "../integrations/madrExport.js";
93
100
  import { buildMadrManifest, writeMadrManifest, refreshMadrCorpus } from "../integrations/madrManifest.js";
94
- import { pendingEscalations, policyEscalations } from "../core/escalations.js";
101
+ import { pendingEscalations, policyEscalations, commitRepairEscalations, actionableEscalations, escalationHeadline } from "../core/escalations.js";
95
102
  import { premiseEscalations } from "../core/premises.js";
96
103
  import { parseDocAnchors, renderDocGrounding } from "../core/docanchors.js";
97
104
  import { compareCandidates } from "../core/compare.js";
@@ -142,7 +149,16 @@ program.option("--initiator <name>", "bind agent launches to the originating CLI
142
149
  if (options.cliConfig)
143
150
  process.env.HUNCH_CLI_CONFIG = options.cliConfig;
144
151
  });
145
- registerIntegrationCommands(program);
152
+ registerIntegrationCommands(program, () => {
153
+ const { store, root } = storeFor();
154
+ try {
155
+ return refreshExistingGrounding(root, store);
156
+ }
157
+ finally {
158
+ store.close();
159
+ }
160
+ });
161
+ registerTaskReportCommands(program, () => { const { store, root } = storeFor(); return { store, root }; });
146
162
  registerServeCommands(program);
147
163
  registerUpdateCommand(program);
148
164
  registerReviewMemoryCommands(program, (records, repository, privateOnly) => {
@@ -341,10 +357,10 @@ program
341
357
  const syncToOverlay = !!(opts.privateSync || opts.sharedSync);
342
358
  const h = installPostCommitHook(root, inv.shell, { private: syncToOverlay, commit: opts.autoCommit, localOnly: syncToOverlay });
343
359
  console.log(` ✓ post-commit hook ${h.action} (learning loop)${syncToOverlay ? " — syncs to the shared overlay" : ""}${opts.autoCommit ? " — auto-commit on" : ""}`);
360
+ const pm = installPostMergeHook(root, inv.shell);
361
+ console.log(` ✓ post-merge hook ${pm.action} (squash-merge provenance repair + re-syncs grounding docs after a merge that brought memory in)`);
344
362
  const m = installMergeDriver(root, inv.shell);
345
363
  console.log(` ✓ team merge driver ${m.action}`);
346
- const pm = installPostMergeHook(root, inv.shell);
347
- console.log(` ✓ post-merge hook ${pm.action} (re-syncs grounding docs after a merge that brought memory in)`);
348
364
  // Auto-install the pre-commit guard by default (advisory: flags invariants
349
365
  // touched directly OR via blast radius, never blocks). Opt out with
350
366
  // --no-enforce; --enforce-strict makes blocking near/direct hits fail the commit.
@@ -410,7 +426,8 @@ program
410
426
  }
411
427
  store.close();
412
428
  console.log("\n" + formatIntegrationHealth(inspectIntegrations(root)));
413
- console.log("\nNext: make a commit (the hook captures a decision), then ask your coding assistant \"why is X built this way?\"");
429
+ console.log("\nTask reporting is configured through Hunch's agent instructions; actual agent activity has not been verified by setup. Reconnect the agent, then work normally. Completed task reports are available with `hunch report`.");
430
+ console.log(reportActivity(root));
414
431
  console.log("Cold start? Seed from history: hunch backfill --since 90d");
415
432
  console.log("\n⭐ If Hunch earns its keep, a star helps others find it → https://github.com/davesheffer/hunch");
416
433
  });
@@ -423,6 +440,15 @@ program
423
440
  const { store, root } = storeFor();
424
441
  store.json.ensureDirs();
425
442
  ensureGitignore(root); // keep the derived SQLite index out of git (idempotent)
443
+ // The post-merge hook only ever got installed by `hunch init`/`hunch
444
+ // private`/`hunch shared` — a repo that already ran init before this hook
445
+ // existed never receives it. `hunch index` already self-heals gitignore
446
+ // the same way; do the same for the hook so an upgrade doesn't require
447
+ // re-running init by hand. Gated on already having post-commit: `index`
448
+ // is not a setup command (it runs in CI, on any git repo), so it must
449
+ // never be what FIRST hooks a repo that never ran init at all.
450
+ if (isGitRepo(root) && hookStatus(root).postCommit)
451
+ installPostMergeHook(root, resolveInvocation().shell);
426
452
  const res = indexRepo(store, root, { requireClean: true });
427
453
  const { counts } = store.reindex();
428
454
  const correctionSweep = new ConstitutionService(store, root).upgradeCorrections();
@@ -916,8 +942,11 @@ function beginFreshOverlaySetup(root, dest, existingLocal, includeHook) {
916
942
  const sharedPointer = commonDir ? join(commonDir, "hunch", "local.json") : "";
917
943
  const configuredHooks = includeHook ? hooksDir(root) : "";
918
944
  const hookDir = configuredHooks ? (isAbsolute(configuredHooks) ? configuredHooks : join(root, configuredHooks)) : "";
919
- const hookFile = hookDir ? join(hookDir, "post-commit") : "";
920
- const paths = [localFile, codeGitignore, teamFile, ...(sharedPointer ? [sharedPointer] : []), ...(hookFile ? [hookFile] : [])];
945
+ // Setup installs post-commit AND post-merge together, so both must be in the
946
+ // ledger restoring one while leaving the other pointing at a just-deleted
947
+ // overlay is not a rollback.
948
+ const hookFiles = hookDir ? [join(hookDir, "post-commit"), join(hookDir, "post-merge")] : [];
949
+ const paths = [localFile, codeGitignore, teamFile, ...(sharedPointer ? [sharedPointer] : []), ...hookFiles];
921
950
  const snapshots = new Map(paths.map((path) => [path, setupPathSnapshot(path)]));
922
951
  const parentExisted = new Map([
923
952
  [dirname(localFile), existsSync(dirname(localFile))],
@@ -937,7 +966,7 @@ function beginFreshOverlaySetup(root, dest, existingLocal, includeHook) {
937
966
  markGitignoreWrite: () => mark(codeGitignore),
938
967
  markTeamWrite: () => mark(teamFile),
939
968
  markSharedPointerWrite: () => mark(sharedPointer),
940
- markHookWrite: () => mark(hookFile),
969
+ markHookWrite: () => hookFiles.forEach(mark),
941
970
  // Migration is a one-way ownership handoff. Once public records have been
942
971
  // durably copied into this clone, a later setup failure may restore routing
943
972
  // files but must not delete the clone that now holds their surviving copy.
@@ -1180,6 +1209,8 @@ function configureOverlay(dir, opts, mode) {
1180
1209
  freshSetup?.markHookWrite();
1181
1210
  const h = installPostCommitHook(root, inv.shell, { private: true, commit: opts.autoCommit, localOnly: mode === "private" });
1182
1211
  hookNote = ` ✓ post-commit hook ${h.action} — captured decisions route here${opts.autoCommit ? " (auto-commit+push on)" : ""}\n`;
1212
+ const pm = installPostMergeHook(root, inv.shell);
1213
+ hookNote += ` ✓ post-merge hook ${pm.action} (squash-merge provenance repair + re-syncs grounding docs after a merge that brought memory in)\n`;
1183
1214
  }
1184
1215
  // 5) one-time migration: MOVE existing public memory INTO the overlay, then make
1185
1216
  // THIS repo code-only. Records are absorbed (union by id) BEFORE the public
@@ -4102,6 +4133,7 @@ program
4102
4133
  .option("--budget <n>", "rough token budget", "1500")
4103
4134
  .option("--profile <profile>", "delivery role: builder, reviewer, or architect", "builder")
4104
4135
  .option("--as-of <ref>", "time-travel: assemble the slice as it stood at a commit/tag/branch")
4136
+ .option("--task <id>", "retain the exact context delivery for this task's contribution report")
4105
4137
  .action(async (target, opts) => {
4106
4138
  if (!DELIVERY_PROFILES.includes(opts.profile)) {
4107
4139
  return fail(`--profile must be one of: ${DELIVERY_PROFILES.join(", ")}`);
@@ -4111,7 +4143,7 @@ program
4111
4143
  if (opts.asOf && !asOf)
4112
4144
  return fail(`could not resolve --as-of "${opts.asOf}" to a commit`);
4113
4145
  store.reindex(); // reflect any out-of-band JSON edits before assembling
4114
- const ctx = store.assembleContext(target, Number(opts.budget), { asOf });
4146
+ let ctx = store.assembleContext(target, Number(opts.budget), { asOf });
4115
4147
  // A task PHRASE ("improve retrieval ranking") resolves no file/symbol target and
4116
4148
  // used to come back empty while the graph held the answer one FTS query away —
4117
4149
  // the task-shaped entry point must not whiff on task-shaped input. Fall back to
@@ -4127,7 +4159,16 @@ program
4127
4159
  // receipts matching the target — the same slice and render as hunch_context.
4128
4160
  const slice = asOf ? null : store.stateSlice(target);
4129
4161
  const stateGrounding = slice ? stateSupplements(slice, target) : [];
4130
- if (empty && !asOf) {
4162
+ if (empty && !asOf && opts.task) {
4163
+ const resolved = store.rankedSearch(target, 8).map(hit => ({ hit, record: store.resolve(hit.ref)?.record }));
4164
+ ctx = { ...ctx,
4165
+ constraints: resolved.filter(x => x.hit.kind === "constraints" && x.record).map(x => x.record),
4166
+ decisions: resolved.filter(x => x.hit.kind === "decisions" && x.record).map(x => x.record),
4167
+ bugs: resolved.filter(x => x.hit.kind === "bugs" && x.record).map(x => x.record),
4168
+ findings: resolved.filter(x => x.hit.kind === "findings" && x.record).map(x => x.record),
4169
+ };
4170
+ }
4171
+ if (empty && !asOf && !opts.task) {
4131
4172
  const hits = store.rankedSearch(target, 8).filter((h) => !isStateKind(h.kind));
4132
4173
  if (hits.length || stateGrounding.length) {
4133
4174
  console.log(`No file/symbol resolves for "${target}" — closest graph matches instead:\n`);
@@ -4146,7 +4187,7 @@ program
4146
4187
  return;
4147
4188
  }
4148
4189
  }
4149
- process.stdout.write(formatContext(ctx, {
4190
+ const envelope = buildDeliveryEnvelope(ctx, {
4150
4191
  root,
4151
4192
  symbols: store.recs("symbols"),
4152
4193
  components: store.recs("components"),
@@ -4154,7 +4195,18 @@ program
4154
4195
  historical: !!asOf,
4155
4196
  profile: opts.profile,
4156
4197
  supplements: stateGrounding,
4157
- }));
4198
+ });
4199
+ process.stdout.write(envelope.text);
4200
+ if (opts.task) {
4201
+ try {
4202
+ const records = asOf ? [] : snapshotDeliveredRecords(store, envelope);
4203
+ const occurrence = recordTaskDelivery(root, opts.task, envelope, records);
4204
+ console.log(`\nTask evidence: ${opts.task} · occurrence ${occurrence}`);
4205
+ }
4206
+ catch {
4207
+ console.error(`Task evidence could not be recorded for ${opts.task}; context remains available but report attribution is unverified.`);
4208
+ }
4209
+ }
4158
4210
  store.close();
4159
4211
  });
4160
4212
  // ---- timeline -------------------------------------------------------------
@@ -4289,6 +4341,7 @@ program
4289
4341
  strict: "edit-time DENY + CI guard — the teeth are on",
4290
4342
  };
4291
4343
  console.log(`\nHunch — enforcement status (${basename(root)})\n`);
4344
+ console.log(` ${reportActivity(root)}\n`);
4292
4345
  console.log(` firmness: ${firmness} ← ${fnote[firmness] ?? ""}\n`);
4293
4346
  console.log(` ✓ ARMED ${blocking.length} confirmed blocking invariant(s) — held against every assistant`);
4294
4347
  if (blocking.length) {
@@ -4456,13 +4509,19 @@ program
4456
4509
  savePipelineState(evt.session_id, st);
4457
4510
  return;
4458
4511
  }
4459
- if (evt.hook_event_name === "Stop" && evt.session_id && pipelineEnabled()) {
4460
- const st = loadPipelineState(evt.session_id);
4461
- const verdict = stopVerdict(st, firmness);
4462
- if (verdict.block) {
4463
- savePipelineState(evt.session_id, verdict.state);
4464
- emitStop(provider, verdict.reason);
4512
+ if (evt.hook_event_name === "Stop") {
4513
+ if (evt.session_id && pipelineEnabled()) {
4514
+ const st = loadPipelineState(evt.session_id);
4515
+ const verdict = stopVerdict(st, firmness);
4516
+ if (verdict.block) {
4517
+ savePipelineState(evt.session_id, verdict.state);
4518
+ emitStop(provider, verdict.reason);
4519
+ return;
4520
+ }
4465
4521
  }
4522
+ const report = stopHookReport(root, provider, evt);
4523
+ if (report)
4524
+ console.log(JSON.stringify(report));
4466
4525
  return;
4467
4526
  }
4468
4527
  if (evt.hook_event_name === "UserPromptSubmit") {
@@ -4478,6 +4537,15 @@ program
4478
4537
  // nag, which is documented as the one nag that must repeat but rode the same
4479
4538
  // deduped payload and so fired once per streak.
4480
4539
  let mustDeliver = isCorrection;
4540
+ // Reporting failure must not suppress the existing correction/policy reminder.
4541
+ try {
4542
+ const report = startHookReport(root, provider, evt);
4543
+ if (report) {
4544
+ text += `\n\n${report}`;
4545
+ mustDeliver = true;
4546
+ }
4547
+ }
4548
+ catch { /* passive reporting remains fail-open */ }
4481
4549
  // Pipeline turn bookkeeping (fresh block budget) + the one nag that must
4482
4550
  // repeat: edits from an earlier turn still unverified.
4483
4551
  if (evt.session_id && pipelineEnabled()) {
@@ -4616,8 +4684,36 @@ program
4616
4684
  // further than a terminal.
4617
4685
  const decisions = s.advisoryRecs("decisions");
4618
4686
  const { recent, roadmap, pendingReview } = nowData(decisions, 3);
4619
- if (!decisions.length) {
4620
- // Fresh graph: nothing to orient on, but the operating loop still ships.
4687
+ const escalations = pendingEscalations(decisions);
4688
+ escalations.push(...premiseEscalations(decisions, { now: new Date().toISOString(), exists: (p) => existsSync(join(paths.root, p)) }));
4689
+ // liveness checked against the full store even in private mode — a
4690
+ // private-overlay decision's repair is fully answerable via
4691
+ // `hunch repair-provenance` (which reads the full store), so it
4692
+ // must not go silently unanswerable just because its title stays
4693
+ // out of session transcripts. Only the id and commit shas surface.
4694
+ const sessionStartQueue = readActivePendingRepairs(paths.root);
4695
+ escalations.push(...commitRepairEscalations(sessionStartQueue, decisions, s.recs("decisions"), withheldRewrites(paths.root, sessionStartQueue)));
4696
+ try {
4697
+ // Constitution human moments ride the same line; a broken policy store
4698
+ // must never take session-start orientation down (fail open). Public
4699
+ // store only — session transcripts travel further than a terminal.
4700
+ const { ConstitutionService: CS } = await import("../constitution/service.js");
4701
+ escalations.push(...policyEscalations(new CS(s, paths.root).list({ publicOnly: true }).map((p) => ({ ...p, last_action: p.audit.at(-1)?.action ?? null }))));
4702
+ }
4703
+ catch { /* constitution unavailable */ }
4704
+ // Only ACTIONABLE entries are worth asking inline — a duplicate-id
4705
+ // commit-repair follower whose own resolution says "act on a
4706
+ // different entry first" isn't a question the assistant can put to
4707
+ // the human directly (#61). Filtered BEFORE the bail check below so
4708
+ // "is there anything to say" and "what do we say" share one
4709
+ // predicate — an escalations list that's entirely non-actionable
4710
+ // must bail exactly like an empty one would.
4711
+ const actionableEsc = actionableEscalations(escalations);
4712
+ if (!decisions.length && !actionableEsc.length) {
4713
+ // Fresh graph and nothing else to raise: nothing to orient on, but
4714
+ // the operating loop still ships. A queued commit-repair escalation
4715
+ // (checked against the full store above) is enough reason NOT to
4716
+ // bail here even when the visible decisions list is empty.
4621
4717
  if (pipelineEnabled())
4622
4718
  emitContext(provider, "SessionStart", [PIPELINE_LOOP, controllerBrief].filter(Boolean).join("\n\n"));
4623
4719
  return;
@@ -4634,18 +4730,8 @@ program
4634
4730
  }
4635
4731
  if (pendingReview > 0)
4636
4732
  L.push(`${pendingReview} legacy un-vouched draft(s) — adopt as advisory memory with \`hunch adopt-drafts\` (new captures auto-trust).`);
4637
- const escalations = pendingEscalations(decisions);
4638
- escalations.push(...premiseEscalations(decisions, { now: new Date().toISOString(), exists: (p) => existsSync(join(paths.root, p)) }));
4639
- try {
4640
- // Constitution human moments ride the same line; a broken policy store
4641
- // must never take session-start orientation down (fail open). Public
4642
- // store only — session transcripts travel further than a terminal.
4643
- const { ConstitutionService: CS } = await import("../constitution/service.js");
4644
- escalations.push(...policyEscalations(new CS(s, paths.root).list({ publicOnly: true }).map((p) => ({ ...p, last_action: p.audit.at(-1)?.action ?? null }))));
4645
- }
4646
- catch { /* constitution unavailable */ }
4647
- if (escalations.length) {
4648
- L.push(`⚖ ${escalations.length} decision(s) need YOUR call — ASK the user inline (don't queue): ${escalations.map((e) => e.question).join(" · ")}`);
4733
+ if (actionableEsc.length) {
4734
+ L.push(`⚖ ${actionableEsc.length} decision(s) need YOUR call — ASK the user inline (don't queue): ${actionableEsc.map((e) => e.question).join(" · ")}`);
4649
4735
  }
4650
4736
  L.push("Orient further: hunch_context(task) · hunch_structure() · `hunch now`.");
4651
4737
  // The operating loop rides session start — guaranteed delivery, once
@@ -4717,6 +4803,7 @@ program
4717
4803
  if (deny) {
4718
4804
  appendEvent(paths, { at: new Date().toISOString(), file: target, ...deny.event });
4719
4805
  emitDeny(provider, deny.reason);
4806
+ observeHookDenial(root, provider, evt, target, deny);
4720
4807
  return;
4721
4808
  }
4722
4809
  // Veto Guard (live): the proposed edit text re-introduces an approach an
@@ -4726,6 +4813,7 @@ program
4726
4813
  if (vetoDeny) {
4727
4814
  appendEvent(paths, { at: new Date().toISOString(), file: target, ...vetoDeny.event });
4728
4815
  emitDeny(provider, vetoDeny.reason);
4816
+ observeHookDenial(root, provider, evt, target, vetoDeny);
4729
4817
  return;
4730
4818
  }
4731
4819
  }
@@ -4792,13 +4880,26 @@ program
4792
4880
  delivery_profile: envelope.profile,
4793
4881
  ranking_policy: envelope.ranking_policy,
4794
4882
  })));
4795
- if (injectionMode(evt.session_id, `pre:${target}`, text) === "delta") {
4883
+ const reportTaskId = hookReportTaskId(root, provider, evt);
4884
+ // A new authoritative prompt gets its own full delivery. An earlier
4885
+ // prompt's session-level delta cannot establish this task's receipt.
4886
+ if (injectionMode(evt.session_id, `pre:${target}${reportTaskId ? `:${reportTaskId}` : ""}`, text) === "delta") {
4796
4887
  receipts("refreshed");
4797
4888
  emitContext(provider, "PreToolUse", `Hunch grounding for ${target}: unchanged this session (${envelope.delivered.filter((item) => item.kind === "decisions").length} decision(s), ${envelope.delivered.filter((item) => item.kind === "constraints").length} invariant(s) shown earlier — still current; hunch_why("${target}") to re-expand).`);
4798
4889
  return;
4799
4890
  }
4800
4891
  receipts("served");
4801
- emitContext(provider, "PreToolUse", text);
4892
+ let reportNotice = "";
4893
+ if (reportTaskId) {
4894
+ try {
4895
+ const occurrence = recordTaskDelivery(root, reportTaskId, envelope, snapshotDeliveredRecords(store, envelope));
4896
+ reportNotice = `\n\nHunch task ${reportTaskId} · delivery ${occurrence}. Inspect exact application references with hunch_report(task_id).`;
4897
+ }
4898
+ catch {
4899
+ reportNotice = "\n\nTask report observation unavailable; this delivery's task contribution remains unverified.";
4900
+ }
4901
+ }
4902
+ emitContext(provider, "PreToolUse", text + reportNotice);
4802
4903
  }
4803
4904
  catch {
4804
4905
  // swallow — never block an edit on a hook failure
@@ -5311,7 +5412,7 @@ program
5311
5412
  // ---- escalations (the inline "ask the human" surface) ---------------------
5312
5413
  program
5313
5414
  .command("escalations")
5314
- .description("The decisions a human must make NOW — surfaced to be asked INLINE, never inferred: one exact imported ADR at a time, topic conflicts, stale premises, and Constitution activation calls. Normally empty. Exits non-zero when any are open.")
5415
+ .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.")
5315
5416
  .option("--json", "emit the escalation entries as JSON (the VS Code panel's data source)")
5316
5417
  .action(async (opts) => {
5317
5418
  const { store, root } = storeFor();
@@ -5321,6 +5422,8 @@ program
5321
5422
  // Premise decay rides the same inline surface: a decision whose recorded
5322
5423
  // reason died is a QUESTION for the human — authority never changes here.
5323
5424
  items.push(...premiseEscalations(decisionsForEsc, { now: new Date().toISOString(), exists: (p) => existsSync(join(root, p)) }));
5425
+ const escalationsQueue = readActivePendingRepairs(root);
5426
+ items.push(...commitRepairEscalations(escalationsQueue, decisionsForEsc, decisionsForEsc, withheldRewrites(root, escalationsQueue)));
5324
5427
  // Constitution moments ride the same inline surface (§59.5.3) — never a queue.
5325
5428
  // Fail open: a broken policy store must not take the memory escalations down.
5326
5429
  try {
@@ -5328,9 +5431,14 @@ program
5328
5431
  items.push(...policyEscalations(new CS(store, root).list().map((p) => ({ ...p, last_action: p.audit.at(-1)?.action ?? null }))));
5329
5432
  }
5330
5433
  catch { /* constitution unavailable — memory escalations still surface */ }
5434
+ // Gate (exit code + tallies) on the ACTIONABLE subset only — a duplicate-id
5435
+ // commit-repair follower whose own resolution says "act on a different
5436
+ // entry first" still surfaces below for transparency, but must not count
5437
+ // as its own thing needing a decision (#61). --json keeps the full list.
5438
+ const actionable = actionableEscalations(items);
5331
5439
  if (opts.json) {
5332
5440
  console.log(JSON.stringify(items));
5333
- if (items.length)
5441
+ if (actionable.length)
5334
5442
  process.exitCode = 1;
5335
5443
  return;
5336
5444
  }
@@ -5338,13 +5446,23 @@ program
5338
5446
  console.log("✓ Nothing needs your decision — memory is auto-trusted and self-consistent.");
5339
5447
  return;
5340
5448
  }
5341
- console.log(`${items.length} decision(s) need your call (ask inline; nothing is queued):\n`);
5449
+ // escalationHeadline's own "nothing actionable, N shown for context"
5450
+ // fallback is currently unreachable here: every escalation-producing
5451
+ // function today guarantees at least one actionable entry whenever it
5452
+ // emits anything at all — see commitRepairEscalations' own docstring on
5453
+ // `firstFor`/dropTarget. Kept anyway (not assumed away) because that
5454
+ // guarantee lives in the PRODUCERS, not in `Escalation.actionable`'s own
5455
+ // contract, which a future producer could legitimately violate.
5456
+ console.log(escalationHeadline(items, "cli") + "\n");
5342
5457
  for (const e of items) {
5343
- console.log(` ⚖ ${e.question}`);
5458
+ // A non-actionable row (a duplicate-id follower) still surfaces for
5459
+ // transparency, but must not read like its own question — marked
5460
+ // distinctly so a skim doesn't mistake it for one of the tally above.
5461
+ console.log(` ${e.actionable === false ? "·" : "⚖"} ${e.question}`);
5344
5462
  console.log(` ${dim(e.detail)}`);
5345
5463
  console.log(` ${dim("→ " + e.resolution)}\n`);
5346
5464
  }
5347
- process.exitCode = 1;
5465
+ process.exitCode = actionable.length ? 1 : 0;
5348
5466
  }
5349
5467
  finally {
5350
5468
  store.close();
@@ -5482,6 +5600,407 @@ program
5482
5600
  store.close();
5483
5601
  }
5484
5602
  });
5603
+ // ---- repair-provenance (squash-merge commit provenance repair) ------------
5604
+ program
5605
+ .command("repair-provenance")
5606
+ .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.")
5607
+ .option("--apply", "apply the reviewed queued candidates (requires --expect; does not scan for new matches)")
5608
+ .option("--expect <hash>", "exact queue review hash printed by the preview or escalation; required for --apply/--drop")
5609
+ .option("--only <dec_id>", "with --apply, rewrite only this decision id — everything else stays queued untouched")
5610
+ .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)")
5611
+ .option("--from-hook", "invoked by the git post-merge hook")
5612
+ .option("--quiet", "minimal output")
5613
+ .option("--range <old..new>", "commit range to scan for replacement commits (default: ORIG_HEAD..HEAD)")
5614
+ .action((opts) => {
5615
+ const { store, root } = storeFor();
5616
+ try {
5617
+ if (!isGitRepo(root)) {
5618
+ if (!opts.fromHook)
5619
+ fail("repair-provenance needs a git repo.");
5620
+ return;
5621
+ }
5622
+ let oldRef = "ORIG_HEAD";
5623
+ let newRef = "HEAD";
5624
+ if (opts.range) {
5625
+ const parts = opts.range.split("..");
5626
+ if (parts.length !== 2 || !parts[0] || !parts[1]) {
5627
+ if (!opts.fromHook)
5628
+ fail('--range must look like "old..new"');
5629
+ return;
5630
+ }
5631
+ [oldRef, newRef] = parts;
5632
+ }
5633
+ const rangeResolves = revExists(oldRef, root) && revExists(newRef, root);
5634
+ if (!rangeResolves && opts.range) {
5635
+ // An explicitly-given range that doesn't resolve is a usage mistake, not
5636
+ // "no merge happened yet" — surface it. (The installed hook never passes
5637
+ // --range, so --from-hook never reaches this branch in practice.)
5638
+ if (!opts.fromHook)
5639
+ fail(`range "${oldRef}..${newRef}" does not resolve in this repository`);
5640
+ return;
5641
+ }
5642
+ const decisions = store.recs("decisions");
5643
+ // One save path for every queue mutation below — a future change to
5644
+ // queue semantics has exactly one write call to touch, not several
5645
+ // independently-reasoned ones. This file has twice shipped the same
5646
+ // failure class from that fragmentation: one write site's "is this
5647
+ // entry resolved" logic gets fixed while a sibling write site quietly
5648
+ // keeps reasoning about the queue differently, deleting an entry the
5649
+ // fixed site would have left alone.
5650
+ let queue = readPendingRepairs(root);
5651
+ const save = (next) => {
5652
+ queue = [...next];
5653
+ writePendingRepairs(root, queue);
5654
+ };
5655
+ // Tombstones for exact {id, from, to} triples a human already rejected
5656
+ // via --drop — durable across runs so detection re-deriving the
5657
+ // identical match on a later merge doesn't re-queue what was already
5658
+ // rejected. A genuinely different `to` for the same {id, from} is a new
5659
+ // proposal and is unaffected.
5660
+ let dropped = readDroppedRepairs(root);
5661
+ const saveDropped = (next) => {
5662
+ dropped = [...next];
5663
+ writeDroppedRepairs(root, dropped);
5664
+ };
5665
+ // Review commands act only on the queue that was presented. Validate
5666
+ // before pruning or writing either queue file; stale approval is inert.
5667
+ if (opts.apply || opts.drop) {
5668
+ const reviewed = withoutDropped(queue, dropped);
5669
+ const expected = commitRepairReviewHash(reviewed, withheldRewrites(root, reviewed));
5670
+ if (!opts.expect || opts.expect !== expected) {
5671
+ return fail("repair review is missing or stale; run hunch repair-provenance and use its --expect hash to review the current queue.");
5672
+ }
5673
+ if (opts.fromHook)
5674
+ return fail("the post-merge hook may only detect repairs, never approve them.");
5675
+ }
5676
+ // The queue file itself must never carry a tombstoned entry, regardless
5677
+ // of how it got there — the fresh-match filtering further down only
5678
+ // covers the merge this run performs. A concurrently-racing writer (the
5679
+ // post-merge hook's backgrounded detection can read the queue/dropped
5680
+ // files independently of a human's --drop landing in between) could
5681
+ // still leave a tombstoned entry sitting in the queue file; sweeping on
5682
+ // every load makes "never contains a rejected triple" a property of the
5683
+ // queue file itself, not of one write site. Same fragmentation lesson
5684
+ // as the comment on `save` above. Every OTHER reader of the queue
5685
+ // (escalations, SessionStart, the MCP tools) calls
5686
+ // readActivePendingRepairs instead of reading the raw file, so a
5687
+ // tombstoned entry can never surface as a question even before this
5688
+ // sweep next runs.
5689
+ //
5690
+ // Deletes unconditionally, without the prune's visibility caveats below
5691
+ // — safe here because the swept entry's exact triple is preserved in
5692
+ // dropped-commit-repairs.json, so nothing about it is unrecoverable
5693
+ // (unlike the prune, which could otherwise destroy the queue's only
5694
+ // durable record of a match).
5695
+ const activeInQueue = withoutDropped(queue, dropped);
5696
+ const swept = queue.filter((r) => !activeInQueue.includes(r));
5697
+ // Remembered for --only below, same reasoning as onlyWasPruned: targeting
5698
+ // an id whose only queued entry was just swept as already-rejected is not
5699
+ // the same usage error as targeting an id that never existed.
5700
+ const onlyWasSwept = !!opts.only && swept.some((r) => r.id === opts.only);
5701
+ if (swept.length) {
5702
+ save(activeInQueue);
5703
+ if (!opts.quiet)
5704
+ console.log(`Swept ${swept.length} already-rejected match${swept.length === 1 ? "" : "es"} from the queue: ${swept.map((r) => r.id).join(", ")}`);
5705
+ }
5706
+ // Prune only PROVEN-dead entries (deadRewrites: the decision is present
5707
+ // and demonstrably moved on/superseded/rejected) — never an id merely
5708
+ // absent from `decisions` right now, which can just mean this run
5709
+ // checked out a branch that predates the decision, or a private
5710
+ // overlay isn't mounted. That's the reader's transient view, not proof
5711
+ // the match is stale, and the queue file is the one durable record of
5712
+ // it (see repairqueue.ts) — deleting on absence would destroy it
5713
+ // unrecoverably. This keeps the queue from accumulating an
5714
+ // actually-dead entry that outlives any number of --only runs
5715
+ // targeting other ids, without risking a false prune. Deferred until
5716
+ // after --range validation so a pure usage error changes nothing.
5717
+ const deadEntries = deadRewrites(queue, decisions);
5718
+ // Remembered for --only below: targeting an id that was pruned THIS
5719
+ // run is not the same usage error as targeting an id that never
5720
+ // existed — the human's target was real and is now resolved, not
5721
+ // unrecognized.
5722
+ const onlyWasPruned = !!opts.only && deadEntries.some((r) => r.id === opts.only);
5723
+ if (deadEntries.length) {
5724
+ // Object identity, not id — a corrupted queue file can carry a dead
5725
+ // entry and a still-live one sharing an id (#53); deadRewrites
5726
+ // already preserves identity via a plain filter, so pruning by id
5727
+ // here would destroy the live sibling right alongside its dead
5728
+ // namesake instead of leaving it for repairDecisionCommit to apply.
5729
+ const deadSet = new Set(deadEntries);
5730
+ save(queue.filter((r) => !deadSet.has(r)));
5731
+ if (!opts.quiet)
5732
+ 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(", ")}`);
5733
+ }
5734
+ const candidates = rangeResolves && !opts.apply && !opts.drop ? mergeRangeChanges(oldRef, newRef, root) : [];
5735
+ const orphaned = candidates.length ? orphanedCommitDecisions(decisions, (sha) => commitRepairStatus(sha, newRef, root)) : [];
5736
+ const freshPlan = candidates.length ? planCommitRepair(orphaned, candidates) : { rewrites: [], records: [] };
5737
+ // Detection always queues a fresh match — local-only, never committed, so
5738
+ // it's safe to persist unconditionally. This is what makes running
5739
+ // opportunistically (from the hook) worth anything: the match survives
5740
+ // past this process exiting and past ORIG_HEAD getting overwritten by the
5741
+ // next merge, so a human can confirm it later with `--apply` alone.
5742
+ // A match whose exact {id, from, to} triple was already tombstoned by
5743
+ // an earlier --drop is filtered out first — the human already rejected
5744
+ // exactly this proposed replacement, and a later merge re-deriving it
5745
+ // independently isn't a reason to ask again. A different `to` for the
5746
+ // same still-orphaned commit is a new proposal and passes through.
5747
+ const freshRewrites = withoutDropped(freshPlan.rewrites, dropped);
5748
+ if (freshRewrites.length)
5749
+ save(mergeRewrites(freshRewrites, queue));
5750
+ if (opts.drop) {
5751
+ const before = queue.length;
5752
+ // firstFor is the single first-match-by-id rule — the same one
5753
+ // pickRewrite and mergeRewrites go through (#53, #56, #58) — to
5754
+ // decide which same-id entry would actually apply. A corrupted
5755
+ // queue file can carry two entries sharing an id, and dropping by
5756
+ // id would destroy the untargeted sibling too, with no tombstone
5757
+ // recording what it was. Filtering by object identity (not
5758
+ // `r.id !== opts.drop`) removes only the one entry actually
5759
+ // dropped; any other entry sharing the id is untouched and stays
5760
+ // queued.
5761
+ const target = firstFor(queue, opts.drop);
5762
+ save(queue.filter((r) => r !== target));
5763
+ // Tombstone only when something real was actually queued for this id —
5764
+ // an id that was never queued has no {from, to} to record, and
5765
+ // "nothing queued" is a usage-mistake signal that shouldn't quietly
5766
+ // create a tombstone file.
5767
+ if (target) {
5768
+ saveDropped(addDropped([{ id: target.id, from: target.from, to: target.to }], dropped));
5769
+ // An identical {id, from, to} sibling (a corrupted queue file, a
5770
+ // hand edit) is now tombstoned but still sitting in the queue this
5771
+ // run just saved above — the load-time sweep near the top of this
5772
+ // action already promises the queue file can never carry a
5773
+ // tombstoned entry, so re-run it here. Without this, --apply later
5774
+ // in this same invocation would happily write the exact rewrite
5775
+ // the human just rejected (#53 follow-up).
5776
+ save(withoutDropped(queue, dropped));
5777
+ }
5778
+ if (!opts.quiet) {
5779
+ console.log(queue.length === before
5780
+ ? `Nothing queued or matched for "${opts.drop}" to drop.`
5781
+ : `Dropped "${opts.drop}" from the queue — the same match won't resurface on its own; a genuinely different candidate for this decision still can.`);
5782
+ }
5783
+ if (!opts.apply)
5784
+ return;
5785
+ }
5786
+ // Deliberately checked before --only ever gets a chance to consult
5787
+ // onlyWasPruned/onlyWasSwept below: when the prune or the sweep just
5788
+ // emptied the queue entirely, --only's own explanatory messages never
5789
+ // get a chance to fire — this generic message covers that case too,
5790
+ // and still exits 0 either way, so no usage-error bug survives here.
5791
+ if (!queue.length) {
5792
+ if (!opts.quiet)
5793
+ console.log("✓ Nothing to repair — no orphaned commit reference matched unambiguously, and nothing queued from an earlier run.");
5794
+ return;
5795
+ }
5796
+ // --only restricts the apply (or dry-run preview) to one decision id,
5797
+ // leaving every other queued/matched candidate exactly as it was — the
5798
+ // escalation asks a per-decision question, so the answer surface should
5799
+ // let a human accept one without also accepting everything else queued.
5800
+ const toApply = opts.only ? queue.filter((r) => r.id === opts.only) : queue;
5801
+ if (opts.only && !toApply.length) {
5802
+ if (onlyWasSwept) {
5803
+ // Not a usage error either: the human already rejected exactly this
5804
+ // match via an earlier --drop, and this run's sweep just confirmed
5805
+ // the rejection still holds — nothing left to apply.
5806
+ if (!opts.quiet)
5807
+ console.log(`"${opts.only}" was already rejected via --drop — nothing left to do.`);
5808
+ return;
5809
+ }
5810
+ if (onlyWasPruned) {
5811
+ // Not a usage error: the human's target was real, and this run
5812
+ // already resolved it above (see the prune's own message for why).
5813
+ if (!opts.quiet)
5814
+ console.log(`"${opts.only}" was pruned earlier in this run — nothing left to do.`);
5815
+ return;
5816
+ }
5817
+ if (!opts.fromHook)
5818
+ fail(`no queued or matched entry for "${opts.only}"`);
5819
+ return;
5820
+ }
5821
+ // Whether a listed entry's decision is visible at all this run — a row
5822
+ // that isn't can't actually be resolved by --apply (see
5823
+ // resolvedRewriteIds below), so dry-run/apply output must say so
5824
+ // instead of listing it as though --apply would act on it. Same
5825
+ // reasoning applies to a `to` that doesn't resolve to a real commit
5826
+ // here (issue #48): never write an unverified `to` into a decision's
5827
+ // commit field, and never claim --apply would either. Computed before
5828
+ // the dry-run branch below so the preview and the real run agree on
5829
+ // what --apply can actually do. `null` from commitsExist means the
5830
+ // check itself failed to run (not a git repo, git missing, timeout)
5831
+ // and is treated as fail-open — same discipline as drift.ts's own use
5832
+ // of commitsExist, though the asymmetry is worth naming: drift.ts only
5833
+ // decides what to REPORT on a `null`, while this gates a WRITE that
5834
+ // gets auto-committed into shared team memory. Fail-open is still the
5835
+ // right call here: `isGitRepo(root)` already passed above, so `null`
5836
+ // in practice means the batched `cat-file` call itself timed out —
5837
+ // rare, and a corrupted `to` that slips through is fully recoverable
5838
+ // (the entry survives durably in the queue either way; a bad write
5839
+ // is a revert). Fail-closed's cost — treating every entry as withheld
5840
+ // on a transient git error, indefinitely, on every run until the
5841
+ // error clears — is the worse failure mode to default to.
5842
+ const visibleIds = new Set(decisions.map((d) => d.id));
5843
+ const existingTargets = commitsExist(toApply.map((r) => r.to), root);
5844
+ const { applicable, withheld } = withheldForUnresolvableTo(toApply, existingTargets);
5845
+ const withheldSet = new Set(withheld);
5846
+ // The plan --apply would act on if it ran right now — built here, before
5847
+ // the dry-run branch, so the preview reasons about the exact same plan
5848
+ // repairDecisionCommit would. `pickRewrite` (src/core/commitrepair.ts, a
5849
+ // plan-scoped `firstFor`) decides which entry wins when `applicable`
5850
+ // carries two sharing a decision id (a corrupted queue file, a hand
5851
+ // edit): first match, by construction. `wasChosen` asks that same
5852
+ // question by object identity rather than re-deriving it, so this file
5853
+ // and commitrepair.ts can never independently drift on which entry
5854
+ // "won" (#51, #58).
5855
+ const plan = { rewrites: applicable, records: [...new Set(applicable.map((r) => r.id))] };
5856
+ const applicableSet = new Set(applicable);
5857
+ const wasChosen = (r) => pickRewrite(plan, r.id) === r;
5858
+ // Whether an entry will still be sitting in the queue after this run,
5859
+ // and why — computed ONCE and shared by the dry-run preview, the queue
5860
+ // sweep, and the post-apply "left queued" report, so the three can
5861
+ // never independently drift out of sync about the same entry (they
5862
+ // did, twice, across two review rounds, before this was unified). The
5863
+ // sweep walks the FULL `queue`, not just `toApply` — under --only, an
5864
+ // untargeted id's entries never entered `applicable` at all, so
5865
+ // `wasChosen` would read "not chosen" for them too. Gating "duplicate"
5866
+ // on `applicableSet.has(r)` keeps the label meaning what it says: a
5867
+ // SIBLING actually competed for this exact id and lost, not merely
5868
+ // "this id wasn't this run's `applicable` set for some other reason."
5869
+ //
5870
+ // Precedence, most to least eclipsing: invisible, then withheld, then
5871
+ // duplicate. Invisible and withheld can each resolve on their own (a
5872
+ // later run sees the decision, or its `to` starts existing) or never
5873
+ // will (withheld, permanently, unless dropped) — "duplicate" is neither:
5874
+ // it means a SIBLING entry for the same id already won and got applied,
5875
+ // which repairDecisionCommit's own from-mismatch bail already makes true
5876
+ // by the time anything downstream would check it, so it's only worth
5877
+ // naming for entries that are otherwise resolvable.
5878
+ const reasonQueued = (r) => !visibleIds.has(r.id) ? "invisible"
5879
+ : withheldSet.has(r) ? "withheld"
5880
+ : applicableSet.has(r) && !wasChosen(r) ? "duplicate"
5881
+ : null;
5882
+ const staysQueued = (r) => reasonQueued(r) !== null;
5883
+ if (!opts.apply) {
5884
+ if (!opts.quiet) {
5885
+ const unresolvedCount = toApply.filter(staysQueued).length;
5886
+ console.log(unresolvedCount
5887
+ ? `Would repair ${toApply.length - unresolvedCount} of ${toApply.length} listed:`
5888
+ : `Would repair ${toApply.length} commit reference(s):`);
5889
+ for (const r of toApply) {
5890
+ const reason = reasonQueued(r);
5891
+ const label = reason === "invisible" ? "not visible this run — --apply would leave it queued"
5892
+ : reason === "withheld" ? "proposed replacement commit doesn't resolve in this repository — --apply would leave it queued"
5893
+ : reason === "duplicate" ? "another queued entry for this same decision would be applied instead — --apply would leave this one queued"
5894
+ : null;
5895
+ console.log(` ${r.id} ${r.from} → ${r.to}${label ? ` (${label})` : ""}`);
5896
+ }
5897
+ const reviewHash = commitRepairReviewHash(queue, withheldRewrites(root, queue));
5898
+ const only = opts.only ? ` --only ${opts.only}` : "";
5899
+ console.log(dim(`\nDry run — no decision was rewritten. Review this queue, then run hunch repair-provenance --apply${only} --expect ${reviewHash}.`));
5900
+ for (const id of new Set(toApply.map((r) => r.id))) {
5901
+ console.log(dim(`Reject: hunch repair-provenance --drop ${id} --expect ${reviewHash}`));
5902
+ }
5903
+ }
5904
+ return;
5905
+ }
5906
+ const touchedHomes = new Set();
5907
+ const appliedIds = new Set();
5908
+ for (const d of decisions) {
5909
+ const healed = repairDecisionCommit(d, plan);
5910
+ if (healed === d)
5911
+ continue;
5912
+ const home = decisionMemoryHome(store, d.id);
5913
+ store.putWhereItLives("decisions", healed);
5914
+ touchedHomes.add(home);
5915
+ appliedIds.add(d.id);
5916
+ }
5917
+ // Only a candidate whose decision was actually VISIBLE this run AND
5918
+ // whose `to` resolved in this repository AND wasn't beaten out by a
5919
+ // same-id sibling is resolved (applied, or found stale by
5920
+ // repairDecisionCommit's own from-mismatch bail) — resolvedRewriteIds
5921
+ // shares deadRewrites' own reasoning: an id we could not see this run,
5922
+ // or whose replacement doesn't exist, is not proof of anything, so it
5923
+ // stays queued rather than being swept up just because it was in
5924
+ // `toApply`. This also covers --only: toApply is just the one targeted
5925
+ // id, so an invisible, unresolvable, or beaten-out target is left
5926
+ // queued instead of deleted unresolved.
5927
+ //
5928
+ // `staysQueued` (OBJECT identity for both the withheld half, via
5929
+ // `withheldSet.has(r)`, and the duplicate half, via `wasChosen`) is
5930
+ // OR'd in ahead of the id-keyed `!resolvedIds.has(r.id)`: a corrupted
5931
+ // queue file could carry two entries sharing an id — one resolvable,
5932
+ // one not, or both resolvable — and an id-keyed check alone would treat
5933
+ // the pair as one unit, deleting/misreporting whichever entry didn't
5934
+ // actually get applied (#48, #51).
5935
+ const resolvedIds = resolvedRewriteIds(applicable, decisions);
5936
+ save(queue.filter((r) => staysQueued(r) || !resolvedIds.has(r.id)));
5937
+ if (!appliedIds.size) {
5938
+ // deadRewrites already pruned any VISIBLE decision that moved on or
5939
+ // was superseded/rejected, so a targeted, resolvable id that IS
5940
+ // visible always reaches repairDecisionCommit with a matching
5941
+ // `from` and is always healed into a new object (appliedIds.add
5942
+ // fires unconditionally for every visible match) — reaching here
5943
+ // means every targeted candidate stayed queued (invisible or
5944
+ // withheld, reported independently so neither reason disappears).
5945
+ const invisibleIds = toApply.filter((r) => reasonQueued(r) === "invisible").map((r) => r.id);
5946
+ const withheldIds = toApply.filter((r) => reasonQueued(r) === "withheld").map((r) => r.id);
5947
+ if (!opts.quiet) {
5948
+ if (invisibleIds.length) {
5949
+ 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.`);
5950
+ }
5951
+ if (withheldIds.length) {
5952
+ const subject = withheldIds.length === 1
5953
+ ? "an entry whose proposed replacement commit doesn't"
5954
+ : "entries whose proposed replacement commits don't";
5955
+ 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.`);
5956
+ }
5957
+ }
5958
+ return;
5959
+ }
5960
+ store.reindex();
5961
+ const rangeLabel = rangeResolves ? `${oldRef}..${newRef}` : "queued";
5962
+ pumpMemoryHomes(store, root, touchedHomes, `hunch: repair ${appliedIds.size} commit reference(s) after squash-merge (${rangeLabel})`);
5963
+ if (!opts.quiet) {
5964
+ console.log(`✓ Repaired ${appliedIds.size} commit reference(s):`);
5965
+ // `wasChosen` restricts this to the one entry per id that
5966
+ // repairDecisionCommit actually wrote — `appliedIds` alone is keyed by
5967
+ // decision id, so without it a same-id sibling that never got written
5968
+ // would print here too (#51).
5969
+ for (const r of applicable)
5970
+ if (appliedIds.has(r.id) && wasChosen(r))
5971
+ console.log(` ${r.id} ${r.from} → ${r.to}`);
5972
+ const stillQueued = toApply.filter((r) => staysQueued(r) || !resolvedIds.has(r.id));
5973
+ const stillInvisible = stillQueued.filter((r) => reasonQueued(r) === "invisible");
5974
+ const stillWithheld = stillQueued.filter((r) => reasonQueued(r) === "withheld");
5975
+ const stillDuplicate = stillQueued.filter((r) => reasonQueued(r) === "duplicate");
5976
+ if (stillInvisible.length) {
5977
+ 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.`);
5978
+ }
5979
+ if (stillWithheld.length) {
5980
+ 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.`);
5981
+ }
5982
+ if (stillDuplicate.length) {
5983
+ // Every entry here shares its id with the sibling that won (that's
5984
+ // what "duplicate" means) — listing ids would just repeat one
5985
+ // value, so name the `to` each one proposed instead. No
5986
+ // `--drop <dec_id>` pointer, unlike the other two buckets: `--drop`
5987
+ // targets the first queued match for an id (the same one
5988
+ // pickRewrite would apply), which by now is the winning sibling
5989
+ // that's already gone from the queue, not the losing one named
5990
+ // here — pointing at `--drop <dec_id>` would read as an offer to
5991
+ // reject this exact entry when it can't (#53).
5992
+ 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(", ")}`);
5993
+ }
5994
+ }
5995
+ }
5996
+ catch (err) {
5997
+ if (!opts.fromHook)
5998
+ throw err;
5999
+ }
6000
+ finally {
6001
+ store.close();
6002
+ }
6003
+ });
5485
6004
  program
5486
6005
  .command("revert-move <sha>")
5487
6006
  .description("Undo one validated memory-only move from a clean checkout (LOCAL only, never pushed). Powers the Hunch view's 'reject move'.")
@@ -5508,7 +6027,7 @@ program
5508
6027
  // ---- drift (doc≠graph detector; advisory + CI-gateable) -------------------
5509
6028
  program
5510
6029
  .command("drift")
5511
- .description("Detect memory drift: dead refs, dangling supersedes, stale 'proposed' docs, doc≠graph anchor-stale (a file still anchored to a superseded decision), markdown sections whose <!-- hunch:topic … dec_id --> pin points at a superseded or missing decision (AGENTS.md/CLAUDE.md as a drift surface), and ledger≠records replay divergence when this partition has a change ledger. Exits non-zero on any anchor-stale drift, topic collision or replay divergence — the doc≠graph and ledger≠records gate.")
6030
+ .description("Detect memory drift: dead refs, dangling supersedes, stale 'proposed' docs, commit-unresolvable (a decision cites a commit that no longer resolves in this repository), doc≠graph anchor-stale (a file still anchored to a superseded decision), markdown sections whose <!-- hunch:topic … dec_id --> pin points at a superseded or missing decision (AGENTS.md/CLAUDE.md as a drift surface), and ledger≠records replay divergence when this partition has a change ledger. Exits non-zero on any anchor-stale drift, topic collision or replay divergence — the doc≠graph and ledger≠records gate.")
5512
6031
  .action(() => {
5513
6032
  const { store, root } = storeFor();
5514
6033
  try {
@@ -5970,6 +6489,14 @@ program
5970
6489
  console.log(`· ${f.id} — ${f.detail}`);
5971
6490
  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`);
5972
6491
  }
6492
+ // Every drift kind heals here — see bug_drift_heal_asymmetry above.
6493
+ const commitUnresolvable = kind("commit-unresolvable");
6494
+ if (commitUnresolvable.length) {
6495
+ console.log(`${commitUnresolvable.length} decision(s) cite a commit that no longer resolves in this repository:\n`);
6496
+ for (const f of commitUnresolvable)
6497
+ console.log(`· ${f.id} — ${f.detail}`);
6498
+ 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`);
6499
+ }
5973
6500
  console.log(`Hunch never rewrites prose for you; this is a read-only reconciliation report.`);
5974
6501
  }
5975
6502
  finally {
@@ -6072,6 +6599,21 @@ program
6072
6599
  process.exitCode = 1; // conflict markers remain → block the commit for review
6073
6600
  }
6074
6601
  });
6602
+ // ---- merge-driver-grounding (internal; git invokes this) ------------------
6603
+ program
6604
+ .command("merge-driver-grounding")
6605
+ .description("(internal) git merge driver for the generated grounding docs — auto-resolves a hard conflict confined to the record-counts sentence.")
6606
+ .argument("<base>", "%O — common ancestor")
6607
+ .argument("<ours>", "%A — current branch (also the OUTPUT file)")
6608
+ .argument("<theirs>", "%B — other branch")
6609
+ .argument("[path]", "%P — pathname being merged")
6610
+ .action((base, ours, theirs) => {
6611
+ const res = mergeGroundingFile(base, ours, theirs);
6612
+ if (res.write !== null)
6613
+ writeFileSync(ours, res.write);
6614
+ if (res.conflict)
6615
+ process.exitCode = 1; // real conflict, or git itself errored → leave for a human
6616
+ });
6075
6617
  // ---- doctor ---------------------------------------------------------------
6076
6618
  program
6077
6619
  .command("doctor")
@@ -6086,6 +6628,23 @@ program
6086
6628
  const { store, root } = storeFor();
6087
6629
  console.log(`Hunch root: ${root}`);
6088
6630
  console.log(`git repo: ${isGitRepo(root) ? "yes" : "no"} ${isGitRepo(root) ? `(HEAD ${headSha(root).slice(0, 8)})` : ""}`);
6631
+ // post-commit/post-merge are the two hooks every setup path installs; a
6632
+ // repo that ran `hunch init` before the post-merge hook existed never
6633
+ // received it, and there was no way to discover that short of noticing a
6634
+ // squash-merge went unrepaired. pre-commit is opt-out (`--no-enforce`),
6635
+ // so its absence is informational only, never a warning.
6636
+ if (isGitRepo(root)) {
6637
+ const hooks = hookStatus(root);
6638
+ const missing = [!hooks.postCommit && "post-commit", !hooks.postMerge && "post-merge"].filter((h) => !!h);
6639
+ // `hunch index` only ever installs post-merge onto an existing
6640
+ // post-commit install (it never hooks an un-hooked repo — see
6641
+ // isGitRepo(root) && hookStatus(root).postCommit above) — so the fix
6642
+ // hint must not point there when post-commit itself is missing.
6643
+ const fix = !hooks.postCommit ? "run `hunch init` for the full setup" : "run `hunch index` to install it";
6644
+ console.log(`hooks: ${missing.length
6645
+ ? `⚠ missing ${missing.join(", ")} — ${fix}`
6646
+ : `post-commit, post-merge installed${hooks.preCommit ? " (+ pre-commit)" : ""}`}`);
6647
+ }
6089
6648
  // In unified mode the public .hunch directory is only a routing shell.
6090
6649
  // Report the same effective manifest that `hunch migrate` reads and stamps,
6091
6650
  // or every healthy code-only team clone looks permanently out of date.