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