@davesheffer/hunch 1.31.0 → 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.
- package/README.md +3 -6
- package/dist/cli/automaticReviewMemory.d.ts +13 -0
- package/dist/cli/dna.d.ts +2 -0
- package/dist/cli/index.d.ts +16 -0
- package/dist/cli/index.js +602 -43
- package/dist/cli/integrations.d.ts +2 -0
- package/dist/cli/integrations.js +5 -2
- package/dist/cli/invocation.d.ts +35 -0
- package/dist/cli/preflight.d.ts +1 -0
- package/dist/cli/reviewMemory.d.ts +4 -0
- package/dist/cli/reviewMemoryProvider.d.ts +14 -0
- package/dist/cli/serve.d.ts +2 -0
- package/dist/cli/taskReport.d.ts +6 -0
- package/dist/cli/taskReport.js +124 -0
- package/dist/cli/update.d.ts +14 -0
- package/dist/client/state.d.ts +248 -0
- package/dist/client/state.js +2 -0
- package/dist/constitution/adapters.d.ts +29 -0
- package/dist/constitution/behaviorAttestationBinding.d.ts +3 -0
- package/dist/constitution/behaviorEvaluator.d.ts +8 -0
- package/dist/constitution/behaviorProof.d.ts +9 -0
- package/dist/constitution/behaviorWorkspace.d.ts +25 -0
- package/dist/constitution/bootstrap.d.ts +30 -0
- package/dist/constitution/canonical.d.ts +15 -0
- package/dist/constitution/card.d.ts +58 -0
- package/dist/constitution/compiler.d.ts +52 -0
- package/dist/constitution/composition.d.ts +10 -0
- package/dist/constitution/corpus.d.ts +7 -0
- package/dist/constitution/correctionPolicyMaterializer.d.ts +57 -0
- package/dist/constitution/delta.d.ts +4 -0
- package/dist/constitution/disposition.d.ts +20 -0
- package/dist/constitution/evaluator.d.ts +47 -0
- package/dist/constitution/experiment.d.ts +466 -0
- package/dist/constitution/experimentRunner.d.ts +7 -0
- package/dist/constitution/g2.d.ts +209 -0
- package/dist/constitution/g2BehaviorAttestation.d.ts +45 -0
- package/dist/constitution/g2BehaviorCandidates.d.ts +123 -0
- package/dist/constitution/g2BehaviorDependencies.d.ts +83 -0
- package/dist/constitution/g2BehaviorMaterialization.d.ts +77 -0
- package/dist/constitution/g2BehaviorPolicyMaterializer.d.ts +48 -0
- package/dist/constitution/g2CandidateAttestation.d.ts +43 -0
- package/dist/constitution/g2Candidates.d.ts +80 -0
- package/dist/constitution/g2Drills.d.ts +33 -0
- package/dist/constitution/g3.d.ts +311 -0
- package/dist/constitution/g3Conformance.d.ts +33 -0
- package/dist/constitution/lifecycle.d.ts +25 -0
- package/dist/constitution/mutation.d.ts +15 -0
- package/dist/constitution/nodeTestEvidence.d.ts +13 -0
- package/dist/constitution/plan.d.ts +18 -0
- package/dist/constitution/policyRuntime.d.ts +5 -0
- package/dist/constitution/proof.d.ts +11 -0
- package/dist/constitution/repairPolicies.d.ts +34 -0
- package/dist/constitution/replacementFreeGit.d.ts +14 -0
- package/dist/constitution/replay.d.ts +40 -0
- package/dist/constitution/replayCache.d.ts +14 -0
- package/dist/constitution/replayWorker.d.ts +1 -0
- package/dist/constitution/repository.d.ts +129 -0
- package/dist/constitution/safeCheckout.d.ts +9 -0
- package/dist/constitution/schema.d.ts +1763 -0
- package/dist/constitution/scorecard.d.ts +219 -0
- package/dist/constitution/service.d.ts +398 -0
- package/dist/constitution/shadow.d.ts +54 -0
- package/dist/constitution/sourceMutation.d.ts +15 -0
- package/dist/constitution/staticGraphBaseline.d.ts +13 -0
- package/dist/constitution/structural.d.ts +64 -0
- package/dist/core/agenthook.d.ts +63 -0
- package/dist/core/agenthook.js +1 -0
- package/dist/core/automaticReviewMemory.d.ts +60 -0
- package/dist/core/autoreview.d.ts +66 -0
- package/dist/core/canonicalOrder.d.ts +3 -0
- package/dist/core/capturetoken.d.ts +19 -0
- package/dist/core/changeIdentity.d.ts +22 -0
- package/dist/core/changeProof.d.ts +14 -0
- package/dist/core/checkreport.d.ts +140 -0
- package/dist/core/commitrepair.d.ts +171 -0
- package/dist/core/commitrepair.js +238 -0
- package/dist/core/compare.d.ts +21 -0
- package/dist/core/config.d.ts +20 -0
- package/dist/core/conformance.d.ts +35 -0
- package/dist/core/constraintmatch.d.ts +54 -0
- package/dist/core/correction.d.ts +42 -0
- package/dist/core/correctionStage.d.ts +224 -0
- package/dist/core/declarationClusters.d.ts +149 -0
- package/dist/core/delivery.d.ts +110 -0
- package/dist/core/docanchors.d.ts +32 -0
- package/dist/core/docscan.d.ts +26 -0
- package/dist/core/drift.d.ts +13 -0
- package/dist/core/drift.js +31 -1
- package/dist/core/dupdetect.d.ts +43 -0
- package/dist/core/escalations.d.ts +129 -0
- package/dist/core/escalations.js +165 -0
- package/dist/core/events.d.ts +29 -0
- package/dist/core/evidenceMap.d.ts +164 -0
- package/dist/core/externalImports.d.ts +8 -0
- package/dist/core/format.d.ts +13 -0
- package/dist/core/glob.d.ts +9 -0
- package/dist/core/groundingLag.d.ts +84 -0
- package/dist/core/groundingLag.js +16 -2
- package/dist/core/groundingMerge.d.ts +15 -0
- package/dist/core/groundingMerge.js +95 -0
- package/dist/core/hookcache.d.ts +10 -0
- package/dist/core/hookpolicy.d.ts +47 -0
- package/dist/core/ids.d.ts +36 -0
- package/dist/core/importReview.d.ts +30 -0
- package/dist/core/io.d.ts +23 -0
- package/dist/core/jsonc.d.ts +4 -0
- package/dist/core/landscapeAdoption.d.ts +66 -0
- package/dist/core/landscapeDelivery.d.ts +76 -0
- package/dist/core/memorylog.d.ts +37 -0
- package/dist/core/migrate.d.ts +28 -0
- package/dist/core/outcomeExperience.d.ts +89 -0
- package/dist/core/overlaySafety.d.ts +24 -0
- package/dist/core/overlaySafety.js +7 -1
- package/dist/core/paths.d.ts +31 -0
- package/dist/core/pipeline.d.ts +289 -0
- package/dist/core/premises.d.ts +43 -0
- package/dist/core/provenance.d.ts +21 -0
- package/dist/core/publication.d.ts +54 -0
- package/dist/core/refrepair.d.ts +24 -0
- package/dist/core/refrepair.js +1 -1
- package/dist/core/relativeImports.d.ts +11 -0
- package/dist/core/repair.d.ts +44 -0
- package/dist/core/repairqueue.d.ts +40 -0
- package/dist/core/repairqueue.js +133 -0
- package/dist/core/reviewMemory.d.ts +40 -0
- package/dist/core/reviewqueue.d.ts +54 -0
- package/dist/core/safeRepoFile.d.ts +20 -0
- package/dist/core/served.d.ts +57 -0
- package/dist/core/served.js +14 -0
- package/dist/core/stateContract.d.ts +979 -0
- package/dist/core/stateContract.js +87 -4
- package/dist/core/stateDelivery.d.ts +78 -0
- package/dist/core/stateRecords.d.ts +383 -0
- package/dist/core/stateRecords.js +16 -1
- package/dist/core/stats.d.ts +88 -0
- package/dist/core/strictgate.d.ts +33 -0
- package/dist/core/taskReport.d.ts +226 -0
- package/dist/core/taskReport.js +514 -0
- package/dist/core/taskReportCapture.d.ts +8 -0
- package/dist/core/taskReportCapture.js +36 -0
- package/dist/core/taskReportEvidence.d.ts +26 -0
- package/dist/core/taskReportEvidence.js +268 -0
- package/dist/core/taskReportHook.d.ts +20 -0
- package/dist/core/taskReportHook.js +76 -0
- package/dist/core/taskReportPaths.d.ts +3 -0
- package/dist/core/taskReportPaths.js +23 -0
- package/dist/core/taskReportPublic.d.ts +31 -0
- package/dist/core/taskReportPublic.js +45 -0
- package/dist/core/taskReportRender.d.ts +6 -0
- package/dist/core/taskReportRender.js +97 -0
- package/dist/core/topics.d.ts +73 -0
- package/dist/core/types.d.ts +1239 -0
- package/dist/core/version.d.ts +10 -0
- package/dist/eval/guards.d.ts +45 -0
- package/dist/eval/harness.d.ts +58 -0
- package/dist/extractors/adrImport.d.ts +59 -0
- package/dist/extractors/comments.d.ts +7 -0
- package/dist/extractors/correctionSources.d.ts +11 -0
- package/dist/extractors/diff.d.ts +40 -0
- package/dist/extractors/git.d.ts +304 -0
- package/dist/extractors/git.js +208 -20
- package/dist/extractors/helm.d.ts +31 -0
- package/dist/extractors/indexer.d.ts +55 -0
- package/dist/extractors/landscapeDiscovery.d.ts +41 -0
- package/dist/extractors/languages.d.ts +77 -0
- package/dist/extractors/nativeTreeSitter.d.ts +16 -0
- package/dist/extractors/parse.d.ts +54 -0
- package/dist/extractors/php.d.ts +26 -0
- package/dist/extractors/repoSource.d.ts +53 -0
- package/dist/extractors/testreport.d.ts +36 -0
- package/dist/integrations/ciAction.d.ts +8 -0
- package/dist/integrations/claudeConfig.d.ts +44 -0
- package/dist/integrations/claudemd.d.ts +13 -0
- package/dist/integrations/claudemd.js +11 -2
- package/dist/integrations/gitignore.d.ts +16 -0
- package/dist/integrations/gitignore.js +7 -0
- package/dist/integrations/health.d.ts +74 -0
- package/dist/integrations/hooks.d.ts +41 -0
- package/dist/integrations/hooks.js +92 -66
- package/dist/integrations/madrExport.d.ts +39 -0
- package/dist/integrations/madrManifest.d.ts +69 -0
- package/dist/integrations/mergeDriver.d.ts +3 -0
- package/dist/integrations/mergeDriver.js +25 -8
- package/dist/integrations/probe.d.ts +4 -0
- package/dist/integrations/providers.d.ts +103 -0
- package/dist/integrations/scaffold.d.ts +29 -0
- package/dist/integrations/sync.d.ts +30 -0
- package/dist/integrations/sync.js +3 -2
- package/dist/integrations/team.d.ts +70 -0
- package/dist/integrations/worktree.d.ts +8 -0
- package/dist/mcp/roots.d.ts +16 -0
- package/dist/mcp/server.d.ts +36 -0
- package/dist/mcp/server.js +114 -25
- package/dist/mcp/taskReportTools.d.ts +3 -0
- package/dist/mcp/taskReportTools.js +106 -0
- package/dist/serve/app.d.ts +36 -0
- package/dist/serve/app.js +20 -0
- package/dist/serve/config.d.ts +100 -0
- package/dist/serve/writelock.d.ts +22 -0
- package/dist/store/changeLedger.d.ts +132 -0
- package/dist/store/changeLedger.js +40 -9
- package/dist/store/compact.d.ts +33 -0
- package/dist/store/db.d.ts +9 -0
- package/dist/store/embedder.d.ts +32 -0
- package/dist/store/hunchStore.d.ts +624 -0
- package/dist/store/hunchStore.js +13 -5
- package/dist/store/jsonStore.d.ts +125 -0
- package/dist/store/jsonStore.js +15 -0
- package/dist/store/merge.d.ts +39 -0
- package/dist/store/privateMigrate.d.ts +24 -0
- package/dist/store/replay.d.ts +56 -0
- package/dist/store/schema.d.ts +17 -0
- package/dist/store/stateBinding.d.ts +145 -0
- package/dist/store/stateBinding.js +110 -15
- package/dist/store/stateCapture.d.ts +15 -0
- package/dist/store/stateCapture.js +145 -0
- package/dist/synthesis/cliAdapter.d.ts +22 -0
- package/dist/synthesis/initiator.d.ts +13 -0
- package/dist/synthesis/provider.d.ts +307 -0
- package/dist/synthesis/synthesize.d.ts +117 -0
- package/dist/synthesis/tripwires.d.ts +9 -0
- package/dist/taskReports.d.ts +76 -0
- package/dist/taskReports.js +67 -0
- package/dist/wiki/adopt.d.ts +22 -0
- package/dist/wiki/graph.d.ts +87 -0
- package/dist/wiki/wiki.d.ts +242 -0
- package/package.json +6 -5
- 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 {
|
|
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("\
|
|
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
|
-
|
|
920
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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"
|
|
4460
|
-
|
|
4461
|
-
|
|
4462
|
-
|
|
4463
|
-
|
|
4464
|
-
|
|
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
|
-
|
|
4620
|
-
|
|
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
|
-
|
|
4638
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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.
|