@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.
- package/README.md +2 -1
- 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 +606 -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 +128 -0
- package/dist/cli/update.d.ts +14 -0
- package/dist/client/state.d.ts +248 -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/hookObservations.d.ts +9 -0
- package/dist/core/hookObservations.js +33 -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/stateDelivery.d.ts +78 -0
- package/dist/core/stateRecords.d.ts +383 -0
- 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 +28 -0
- package/dist/core/taskReportEvidence.js +272 -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/health.js +32 -2
- 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 +74 -22
- package/dist/mcp/taskReportTools.d.ts +188 -0
- package/dist/mcp/taskReportTools.js +147 -0
- package/dist/serve/app.d.ts +36 -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/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 +6 -3
- package/dist/store/jsonStore.d.ts +125 -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/stateCapture.d.ts +15 -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 +79 -0
- package/dist/taskReports.js +69 -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,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("\
|
|
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
|
-
|
|
920
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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"
|
|
4460
|
-
|
|
4461
|
-
|
|
4462
|
-
|
|
4463
|
-
|
|
4464
|
-
|
|
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
|
-
|
|
4620
|
-
|
|
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
|
-
|
|
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(" · ")}`);
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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.
|