@davesheffer/hunch 1.41.6 → 1.43.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 +5 -1
- package/dist/cli/index.js +489 -167
- package/dist/constitution/experiment.d.ts +3 -3
- package/dist/constitution/g3.d.ts +1 -1
- package/dist/core/delivery.d.ts +12 -0
- package/dist/core/delivery.js +4 -4
- package/dist/core/footprint.d.ts +15 -0
- package/dist/core/footprint.js +167 -0
- package/dist/core/groundingLag.d.ts +15 -0
- package/dist/core/groundingLag.js +27 -0
- package/dist/core/hookText.d.ts +4 -0
- package/dist/core/hookText.js +8 -0
- package/dist/core/hookcache.d.ts +22 -0
- package/dist/core/hookcache.js +53 -2
- package/dist/core/pipeline.d.ts +28 -0
- package/dist/core/pipeline.js +50 -0
- package/dist/core/publication.js +17 -3
- package/dist/core/shellwrites.d.ts +7 -0
- package/dist/core/shellwrites.js +131 -0
- package/dist/core/siblingfix.d.ts +124 -0
- package/dist/core/siblingfix.js +814 -0
- package/dist/core/taskReportHook.d.ts +1 -1
- package/dist/core/taskReportHook.js +20 -4
- package/dist/core/taskSelection.d.ts +67 -0
- package/dist/core/taskSelection.js +196 -0
- package/dist/extractors/git.d.ts +31 -0
- package/dist/extractors/git.js +180 -1
- package/dist/extractors/nativeTreeSitter.d.ts +2 -1
- package/dist/extractors/nativeTreeSitter.js +128 -30
- package/dist/integrations/claudemd.d.ts +20 -2
- package/dist/integrations/claudemd.js +79 -53
- package/dist/integrations/providers.d.ts +9 -5
- package/dist/integrations/providers.js +36 -23
- package/dist/integrations/scaffold.js +2 -1
- package/dist/integrations/team.d.ts +24 -4
- package/dist/integrations/team.js +154 -16
- package/dist/integrations/worktree.d.ts +3 -2
- package/dist/integrations/worktree.js +7 -4
- package/dist/mcp/server.d.ts +489 -0
- package/dist/mcp/server.js +208 -62
- package/dist/mcp/taskReportTools.js +12 -9
- package/dist/mcp/toolset.d.ts +11 -1
- package/dist/mcp/toolset.js +28 -8
- package/dist/store/hunchStore.d.ts +25 -1
- package/dist/store/hunchStore.js +146 -21
- package/dist/store/jsonStore.js +25 -4
- package/package.json +1 -1
- package/server.json +2 -2
package/dist/cli/index.js
CHANGED
|
@@ -43,13 +43,13 @@ import { isParserLoadError, loadNativeTreeSitter } from "../extractors/nativeTre
|
|
|
43
43
|
import { syncCommit, recordFailure, captureTestRun } from "../synthesis/synthesize.js";
|
|
44
44
|
import { parseTestReport } from "../extractors/testreport.js";
|
|
45
45
|
import { readSynthesisPreference, resolveSynthesisProvider, selectProvider, SYNTH_PREFERENCES, writeSynthesisPreference, normalizeProviderName, } from "../synthesis/provider.js";
|
|
46
|
-
import { isGitRepo, isGitRepoRoot, sameGitPublication, sameRemoteUrl, canonicalRemoteUrl, repositoryUsesRemote, headSha, isolatedHeadSha, logSince, lastChangeDate, firstCommitForFile, stagedFiles, workingFiles, commitFiles, asOfDate, stagedGateDiff, workingGateDiff, commitGateDiff, rangeFiles, rangeGateDiff, rangeSubjects, revExists, revParse, commitAndPushHunch, pullHunchStatus, syncExistingHunch, gitUntrackCached, gitCommonDir, hooksDir, isLinkedWorktree, mainWorktreeRoot, gitMemoryLog, memoryMoveDiff, revertMemoryMove, pushCurrentBranch, commitChanges, commitRepairStatus, mergeRangeChanges, commitsExist } from "../extractors/git.js";
|
|
46
|
+
import { isGitRepo, isGitRepoRoot, sameGitPublication, sameRemoteUrl, canonicalRemoteUrl, repositoryUsesRemote, headSha, isolatedHeadSha, logSince, lastChangeDate, firstCommitForFile, stagedFiles, workingFiles, commitFiles, asOfDate, stagedGateDiff, workingGateDiff, commitGateDiff, rangeFiles, rangeGateDiff, rangeSubjects, revExists, revParse, commitAndPushHunch, pullHunchStatus, syncExistingHunch, gitUntrackCached, gitCommonDir, checkoutCommonDir, separateGitDirCandidate, separateGitDirLink, hooksDir, isLinkedWorktree, mainWorktreeRoot, gitMemoryLog, memoryMoveDiff, revertMemoryMove, pushCurrentBranch, commitChanges, commitRepairStatus, mergeRangeChanges, commitsExist } from "../extractors/git.js";
|
|
47
47
|
import { parseMemoryLog } from "../core/memorylog.js";
|
|
48
48
|
import { renamesOf, planRepair, repairDecision, repairConstraint } from "../core/repair.js";
|
|
49
49
|
import { orphanedCommitDecisions, planCommitRepair, repairDecisionCommit, pickRewrite, commitRepairReviewHash, mergeRewrites, firstFor, deadRewrites, resolvedRewriteIds, withoutDropped, addDropped, withheldForUnresolvableTo } from "../core/commitrepair.js";
|
|
50
50
|
import { readPendingRepairs, writePendingRepairs, readDroppedRepairs, writeDroppedRepairs, readActivePendingRepairs, withheldRewrites } from "../core/repairqueue.js";
|
|
51
51
|
import { planPolicyRepair, repairPolicySpec } from "../constitution/repairPolicies.js";
|
|
52
|
-
import { writeTeamConfig, ensureTeamOverlay, readTeamConfig, safeGitUrl, safeTeamRef, overlayMatchesTeamRemote, advertisedTeamRemoteContract, boundedTeamGitEnv, cloneValidatedTeamOverlay, explicitTeamRemoteContract, teamRemoteContract } from "../integrations/team.js";
|
|
52
|
+
import { writeTeamConfig, ensureTeamOverlay, readTeamConfig, isTeamStoreTrusted, teamWiringConsented, trustTeamStore, teamTrustFile, untrustedTeamStoreMessage, safeGitUrl, safeTeamRef, overlayMatchesTeamRemote, advertisedTeamRemoteContract, boundedTeamGitEnv, cloneValidatedTeamOverlay, explicitTeamRemoteContract, teamRemoteContract } from "../integrations/team.js";
|
|
53
53
|
import { runbookId, decisionId } from "../core/ids.js";
|
|
54
54
|
import { deriveForbids, effectiveForbids } from "../core/constraintmatch.js";
|
|
55
55
|
import { extractInlineIntent } from "../extractors/comments.js";
|
|
@@ -62,6 +62,8 @@ import { installMergeDriver } from "../integrations/mergeDriver.js";
|
|
|
62
62
|
import { ensureGitignore, ignoreHunchMemory, HUNCH_MEMORY_DIRS, upgradeManagedGitignore, describeGitignoreUpgrade } from "../integrations/gitignore.js";
|
|
63
63
|
import { writeCiWorkflow } from "../integrations/ciAction.js";
|
|
64
64
|
import { updateClaudeMd, renderHunchSection } from "../integrations/claudemd.js";
|
|
65
|
+
import { HOOK_REMINDER } from "../core/hookText.js";
|
|
66
|
+
import { measureFootprint } from "../core/footprint.js";
|
|
65
67
|
import { classifyGroundingBlock, describeGroundingFreshness } from "../core/groundingLag.js";
|
|
66
68
|
import { mergeGroundingFile } from "../core/groundingMerge.js";
|
|
67
69
|
import { writeMcpJson, writeSlashCommands, installClaudeHooks } from "../integrations/scaffold.js";
|
|
@@ -89,16 +91,17 @@ import { blockingInScope, vetoInScope, proposedEditLines } from "../core/hookpol
|
|
|
89
91
|
import { isHumanConfirmed } from "../core/strictgate.js";
|
|
90
92
|
import { appendEvent, readEvents } from "../core/events.js";
|
|
91
93
|
import { computeStats, formatStats } from "../core/stats.js";
|
|
92
|
-
import { injectionMode, resetSessionInjections } from "../core/hookcache.js";
|
|
94
|
+
import { clearTaskSelection, injectionMode, loadTaskSelection, resetSessionInjections, saveTaskSelection, taskSelectionEnabled } from "../core/hookcache.js";
|
|
95
|
+
import { isBareFollowUp, isFilterableSelectionId, liveSelectionRecords, selectForTask } from "../core/taskSelection.js";
|
|
93
96
|
import { recordServed, servedSummary } from "../core/served.js";
|
|
94
|
-
import { recordTaskDelivery, reportActivity, reportHash, reportPresentationEnabled, unseenLessons } from "../core/taskReport.js";
|
|
97
|
+
import { readTaskReport, recordTaskDelivery, reportActivity, reportHash, reportPresentationEnabled, unseenLessons } from "../core/taskReport.js";
|
|
95
98
|
import { snapshotDeliveredRecords } from "../core/taskReportEvidence.js";
|
|
96
99
|
import { renderRecalledLine } from "../core/taskReportRender.js";
|
|
97
|
-
import { closeHookTask, hookReportTaskId, nativeHookCwd, settleHookSession, startHookReport, stopHookReport, observeHookDenial } from "../core/taskReportHook.js";
|
|
100
|
+
import { closeHookTask, hookReportTaskId, isNotificationPrompt, nativeHookCwd, settleHookSession, startHookReport, stopHookReport, observeHookDenial } from "../core/taskReportHook.js";
|
|
98
101
|
import { persistTaskRecord } from "../core/taskRecord.js";
|
|
99
102
|
import { recordHookObservation } from "../core/hookObservations.js";
|
|
100
103
|
import { contextHookOutput, denyHookOutput, hookProvider, normalizeHookEvent, stopHookOutput } from "../core/agenthook.js";
|
|
101
|
-
import { PIPELINE_LOOP, armExecutionObligations, beforeEditProbeVerdict, compileExecutableProbes, environmentExecutableProbes, environmentExecutionObligations, executionObligationBrief, isProductPath, loadPipelineState, onCommand, onEdit, onPrompt, onSkill, pipelineEnabled, proofCheckpoint, savePipelineState, stopVerdict, unverifiedNag, } from "../core/pipeline.js";
|
|
104
|
+
import { PIPELINE_LOOP, armExecutionObligations, beforeEditProbeVerdict, compileExecutableProbes, environmentExecutableProbes, environmentExecutionObligations, executionObligationBrief, isProductPath, loadPipelineState, onCommand, onEdit, onPrompt, onSkill, pipelineEnabled, proofCheckpoint, savePipelineState, stopVerdict, onLessonsDelivered, lessonReminder, unverifiedNag, } from "../core/pipeline.js";
|
|
102
105
|
import { draftDuplicateOf, isAcceptedDuplicateAnchor } from "../core/dupdetect.js";
|
|
103
106
|
import { planAutoReview, planMutations } from "../core/autoreview.js";
|
|
104
107
|
import { loadGoldenSet, evaluateRetrieval, evaluateTraversalLift } from "../eval/harness.js";
|
|
@@ -116,6 +119,9 @@ import { buildMadrManifest, writeMadrManifest, refreshMadrCorpus } from "../inte
|
|
|
116
119
|
import { pendingEscalations, policyEscalations, commitRepairEscalations, actionableEscalations, escalationHeadline } from "../core/escalations.js";
|
|
117
120
|
import { premiseEscalations } from "../core/premises.js";
|
|
118
121
|
import { parseDocAnchors, renderDocGrounding } from "../core/docanchors.js";
|
|
122
|
+
import { pathMatchesGlob } from "../core/glob.js";
|
|
123
|
+
import { SIBLING_HEADING, functionBodyHash, siblingGrounding } from "../core/siblingfix.js";
|
|
124
|
+
import { refreshShellBaseline, shellWrittenFiles } from "../core/shellwrites.js";
|
|
119
125
|
import { compareCandidates } from "../core/compare.js";
|
|
120
126
|
import { compareCodeUnits } from "../core/canonicalOrder.js";
|
|
121
127
|
import { MAX_LANDSCAPE_REFRESH_REVISIONS, planLandscapeAdoption, } from "../core/landscapeAdoption.js";
|
|
@@ -246,7 +252,8 @@ function openTeamStore(root, opts = {}) {
|
|
|
246
252
|
const explicitOverlay = !!process.env.HUNCH_PRIVATE_DIR?.trim();
|
|
247
253
|
const teamFile = join(hunchPaths(root).hunch, "team.json");
|
|
248
254
|
const teamAdvertised = !explicitOverlay && existsSync(teamFile);
|
|
249
|
-
|
|
255
|
+
const advertisedTeam = teamAdvertised ? readTeamConfig(root) : null;
|
|
256
|
+
if (teamAdvertised && !advertisedTeam) {
|
|
250
257
|
throw new Error(".hunch/team.json is invalid or unsafe; refusing to fall back to public memory");
|
|
251
258
|
}
|
|
252
259
|
const teamWired = ensureTeamOverlay(root);
|
|
@@ -255,12 +262,16 @@ function openTeamStore(root, opts = {}) {
|
|
|
255
262
|
const overlayWarning = store.overlayResolutionWarning(explicitOverlay && existsSync(teamFile));
|
|
256
263
|
if (overlayWarning)
|
|
257
264
|
console.error(`[hunch] ⚠ ${overlayWarning}`);
|
|
258
|
-
if (
|
|
265
|
+
if (advertisedTeam && (store.mode !== "shared"
|
|
259
266
|
|| !store.privateDir
|
|
260
267
|
|| !existsSync(store.privateDir)
|
|
268
|
+
|| !teamWiringConsented(root, advertisedTeam, store.privateDir)
|
|
261
269
|
|| !overlayMatchesTeamRemote(root, dirname(store.privateDir)))) {
|
|
270
|
+
const consented = !!store.privateDir && teamWiringConsented(root, advertisedTeam, store.privateDir);
|
|
262
271
|
store.close();
|
|
263
272
|
openStore = null;
|
|
273
|
+
if (!consented && !isTeamStoreTrusted(root, advertisedTeam))
|
|
274
|
+
throw new Error(untrustedTeamStoreMessage(advertisedTeam));
|
|
264
275
|
throw new Error("the advertised team memory store is unavailable or tracks a different remote; refusing to read or write another graph");
|
|
265
276
|
}
|
|
266
277
|
// Short-lived CLI processes need the same live edge as the long-lived MCP
|
|
@@ -487,7 +498,7 @@ program
|
|
|
487
498
|
// Worktree-seamless: register any configured overlay at the git common dir so EVERY
|
|
488
499
|
// worktree of this repo auto-discovers it (also backfills pre-0.32 single-worktree setups),
|
|
489
500
|
// and note when we're initializing inside a linked worktree (memory is shared, not separate).
|
|
490
|
-
if (ensureSharedOverlayPointer(root, store.privateDir, store.privateAutoCommit, store.mode === "shared" ? "shared" : "private")) {
|
|
501
|
+
if (ensureSharedOverlayPointer(root, store.privateDir, store.privateAutoCommit, store.mode === "shared" ? "shared" : "private", true)) {
|
|
491
502
|
console.log(` ✓ private overlay registered at the git common dir — shared by every worktree of this repo`);
|
|
492
503
|
}
|
|
493
504
|
for (const line of sharedHooksNote(root, installs))
|
|
@@ -1014,15 +1025,18 @@ function beginFreshOverlaySetup(root, dest, existingLocal, includeHook) {
|
|
|
1014
1025
|
const localFile = join(hunchPaths(root).hunch, "local.json");
|
|
1015
1026
|
const teamFile = join(hunchPaths(root).hunch, "team.json");
|
|
1016
1027
|
const codeGitignore = join(root, ".gitignore");
|
|
1017
|
-
const commonDir =
|
|
1028
|
+
const commonDir = checkoutCommonDir(root) || separateGitDirCandidate(root)?.gitDir || "";
|
|
1018
1029
|
const sharedPointer = commonDir ? join(commonDir, "hunch", "local.json") : "";
|
|
1030
|
+
// Registering the shared pointer can also claim a separate git dir (claimSeparateGitDir),
|
|
1031
|
+
// writing its checkout back-link beside the pointer; a failed setup must not leave it.
|
|
1032
|
+
const backLink = commonDir ? separateGitDirLink(commonDir) : "";
|
|
1019
1033
|
const configuredHooks = includeHook ? hooksDir(root) : "";
|
|
1020
1034
|
const hookDir = configuredHooks ? (isAbsolute(configuredHooks) ? configuredHooks : join(root, configuredHooks)) : "";
|
|
1021
1035
|
// Setup installs post-commit AND post-merge together, so both must be in the
|
|
1022
1036
|
// ledger — restoring one while leaving the other pointing at a just-deleted
|
|
1023
1037
|
// overlay is not a rollback.
|
|
1024
1038
|
const hookFiles = hookDir ? [join(hookDir, "post-commit"), join(hookDir, "post-merge")] : [];
|
|
1025
|
-
const paths = [localFile, codeGitignore, teamFile, ...(sharedPointer ? [sharedPointer] : []), ...hookFiles];
|
|
1039
|
+
const paths = [localFile, codeGitignore, teamFile, ...(sharedPointer ? [sharedPointer, backLink] : []), ...hookFiles];
|
|
1026
1040
|
const snapshots = new Map(paths.map((path) => [path, setupPathSnapshot(path)]));
|
|
1027
1041
|
const parentExisted = new Map([
|
|
1028
1042
|
[dirname(localFile), existsSync(dirname(localFile))],
|
|
@@ -1041,7 +1055,7 @@ function beginFreshOverlaySetup(root, dest, existingLocal, includeHook) {
|
|
|
1041
1055
|
markLocalWrite: () => mark(localFile),
|
|
1042
1056
|
markGitignoreWrite: () => mark(codeGitignore),
|
|
1043
1057
|
markTeamWrite: () => mark(teamFile),
|
|
1044
|
-
markSharedPointerWrite: () => mark(sharedPointer),
|
|
1058
|
+
markSharedPointerWrite: () => { mark(backLink); mark(sharedPointer); },
|
|
1045
1059
|
markHookWrite: () => hookFiles.forEach(mark),
|
|
1046
1060
|
// Migration is a one-way ownership handoff. Once public records have been
|
|
1047
1061
|
// durably copied into this clone, a later setup failure may restore routing
|
|
@@ -1070,6 +1084,74 @@ function beginFreshOverlaySetup(root, dest, existingLocal, includeHook) {
|
|
|
1070
1084
|
},
|
|
1071
1085
|
};
|
|
1072
1086
|
}
|
|
1087
|
+
/** `hunch shared --trust`: the one explicit step that lets this machine auto-wire
|
|
1088
|
+
* the store a committed team.json advertises. Shows the exact URL being trusted. */
|
|
1089
|
+
function trustAdvertisedTeamStore(root, dir, opts) {
|
|
1090
|
+
if (dir || opts.repo || opts.sync || opts.migrate) {
|
|
1091
|
+
return fail("--trust takes no directory and cannot be combined with --repo, --sync, or --migrate");
|
|
1092
|
+
}
|
|
1093
|
+
if (process.env.HUNCH_PRIVATE_DIR?.trim()) {
|
|
1094
|
+
return fail("HUNCH_PRIVATE_DIR is set, so .hunch/team.json is bypassed; unset it before trusting the team store");
|
|
1095
|
+
}
|
|
1096
|
+
const team = readTeamConfig(root);
|
|
1097
|
+
if (!team) {
|
|
1098
|
+
return fail(existsSync(join(hunchPaths(root).hunch, "team.json"))
|
|
1099
|
+
? ".hunch/team.json is invalid or unsafe; refusing to trust it"
|
|
1100
|
+
: "no .hunch/team.json in this repository; nothing to trust");
|
|
1101
|
+
}
|
|
1102
|
+
// Consent persists only after a successful connect: openTeamStore reads the trust
|
|
1103
|
+
// entry, so it is written first and restored to the exact prior state on failure.
|
|
1104
|
+
// The connect can also write routing BEFORE it fails (this checkout's pointer, the
|
|
1105
|
+
// registered common-dir pointer, a separate git dir's back-link), and a registered
|
|
1106
|
+
// pointer is itself consent, so those are snapshotted and restored too — otherwise
|
|
1107
|
+
// "trust was NOT recorded" would be false in substance.
|
|
1108
|
+
const priorTrust = isTeamStoreTrusted(root, team);
|
|
1109
|
+
const commonDir = checkoutCommonDir(root) || separateGitDirCandidate(root)?.gitDir || "";
|
|
1110
|
+
const routing = [
|
|
1111
|
+
join(hunchPaths(root).hunch, "local.json"),
|
|
1112
|
+
...(commonDir ? [join(commonDir, "hunch", "local.json"), separateGitDirLink(commonDir)] : []),
|
|
1113
|
+
];
|
|
1114
|
+
const routingBefore = routing.map((path) => [path, setupPathSnapshot(path)]);
|
|
1115
|
+
const undoTrust = trustTeamStore(root, team);
|
|
1116
|
+
let opened;
|
|
1117
|
+
try {
|
|
1118
|
+
opened = openTeamStore(root);
|
|
1119
|
+
}
|
|
1120
|
+
catch (error) {
|
|
1121
|
+
let reverted = true;
|
|
1122
|
+
for (const [path, before] of routingBefore) {
|
|
1123
|
+
try {
|
|
1124
|
+
restoreSetupPath(path, before);
|
|
1125
|
+
}
|
|
1126
|
+
catch {
|
|
1127
|
+
reverted = false;
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
try {
|
|
1131
|
+
undoTrust();
|
|
1132
|
+
}
|
|
1133
|
+
catch {
|
|
1134
|
+
reverted = false;
|
|
1135
|
+
}
|
|
1136
|
+
// The undo only removes THIS invocation's own entry: a trust entry this checkout
|
|
1137
|
+
// already had stays, and a concurrent process may have recorded (and kept) its own.
|
|
1138
|
+
const stillConsented = reverted && isTeamStoreTrusted(root, team);
|
|
1139
|
+
return fail(`could not connect to the team store: ${error instanceof Error ? error.message : String(error)}\n` +
|
|
1140
|
+
(stillConsented
|
|
1141
|
+
? priorTrust
|
|
1142
|
+
? ` · this checkout's earlier trust entry is kept (${teamTrustFile()})\n`
|
|
1143
|
+
: ` · trust for this checkout was recorded by another process meanwhile (${teamTrustFile()})\n`
|
|
1144
|
+
: reverted
|
|
1145
|
+
? ` · trust was NOT recorded for this checkout (${teamTrustFile()} and its store pointers are unchanged)\n`
|
|
1146
|
+
: ` · trust could not be fully rolled back: remove this checkout's entry from ${teamTrustFile()} and its .hunch/local.json store pointer\n`) +
|
|
1147
|
+
" · retry: `hunch shared --trust` once the store is reachable");
|
|
1148
|
+
}
|
|
1149
|
+
console.log(`✓ trusted the team memory store on this machine → ${team.shared_repo}`);
|
|
1150
|
+
const { store, teamWired } = opened;
|
|
1151
|
+
console.log(teamWired
|
|
1152
|
+
? ` ✓ connected to the team's shared memory store → ${teamWired}`
|
|
1153
|
+
: ` · already connected → ${store.privateDir}`);
|
|
1154
|
+
}
|
|
1073
1155
|
function configureOverlay(dir, opts, mode) {
|
|
1074
1156
|
let freshSetup = null;
|
|
1075
1157
|
let setupComplete = false;
|
|
@@ -1077,6 +1159,8 @@ function configureOverlay(dir, opts, mode) {
|
|
|
1077
1159
|
const root = findRoot();
|
|
1078
1160
|
const paths = hunchPaths(root);
|
|
1079
1161
|
const commandName = mode === "private" ? "private" : "shared";
|
|
1162
|
+
if (opts.trust)
|
|
1163
|
+
return trustAdvertisedTeamStore(root, dir, opts);
|
|
1080
1164
|
// A repository URL reaches Git before the overlay is trusted in BOTH modes.
|
|
1081
1165
|
// Keep private split stores private by omitting team.json, not by weakening the
|
|
1082
1166
|
// clone transport gate: credentials stay in normal Git helpers and every setup
|
|
@@ -1261,6 +1345,10 @@ function configureOverlay(dir, opts, mode) {
|
|
|
1261
1345
|
return fail("could not select one canonical branch for the shared memory repository");
|
|
1262
1346
|
freshSetup?.markTeamWrite();
|
|
1263
1347
|
writeTeamConfig(root, { shared_repo: opts.repo, shared_ref: sharedRef });
|
|
1348
|
+
// The author typed this URL, which is the consent teammates give with --trust.
|
|
1349
|
+
const published = readTeamConfig(root);
|
|
1350
|
+
if (published)
|
|
1351
|
+
trustTeamStore(root, published);
|
|
1264
1352
|
// Bind the graph epoch immediately, in clone-local Git metadata. Waiting
|
|
1265
1353
|
// until the next command would let a coherent team.json+origin repoint
|
|
1266
1354
|
// relabel this clone after setup but before its first normal open.
|
|
@@ -1276,9 +1364,12 @@ function configureOverlay(dir, opts, mode) {
|
|
|
1276
1364
|
// only an absolute path survives the move. Lives under .git/ (never tracked; nothing to ignore).
|
|
1277
1365
|
let worktreeNote = "";
|
|
1278
1366
|
freshSetup?.markSharedPointerWrite();
|
|
1279
|
-
if (ensureSharedOverlayPointer(root, hunchDir, !!opts.autoCommit, mode)) {
|
|
1367
|
+
if (ensureSharedOverlayPointer(root, hunchDir, !!opts.autoCommit, mode, true)) {
|
|
1280
1368
|
worktreeNote = " ✓ registered in the git common dir — shared by every worktree of this repo, on any branch\n";
|
|
1281
1369
|
}
|
|
1370
|
+
else {
|
|
1371
|
+
worktreeNote = " ⚠ could not register the overlay in this checkout's git common dir, so Hunch will not use .hunch/local.json here; set HUNCH_PRIVATE_DIR to this overlay instead\n";
|
|
1372
|
+
}
|
|
1282
1373
|
// 4) route post-commit synthesis to the overlay (local hook, never committed)
|
|
1283
1374
|
let hookNote = "";
|
|
1284
1375
|
if (opts.hook && isGitRepo(root)) {
|
|
@@ -1393,6 +1484,7 @@ program
|
|
|
1393
1484
|
.option("--no-auto-commit", "DON'T auto commit+push the overlay after each capture (default: ON — fully automated two-way sync)")
|
|
1394
1485
|
.option("--sync", "flush the configured overlay store now (git add+commit+push)")
|
|
1395
1486
|
.option("--migrate", "ONE-TIME: move this repo's EXISTING public .hunch memory into the shared overlay, then make the public repo code-only")
|
|
1487
|
+
.option("--trust", "trust the shared store advertised in the committed .hunch/team.json on this machine, then connect to it")
|
|
1396
1488
|
.action((dir, opts) => configureOverlay(dir, opts, "shared"));
|
|
1397
1489
|
// ---- worktree (one-command worktree wired into Hunch) ----------------------
|
|
1398
1490
|
program
|
|
@@ -1427,7 +1519,7 @@ program
|
|
|
1427
1519
|
if (!opts.share) {
|
|
1428
1520
|
shareNote = ` · --no-share — the worktree will NOT see private memory`;
|
|
1429
1521
|
}
|
|
1430
|
-
else if (overlay && ensureSharedOverlayPointer(root, overlay, autoCommit, overlayMode)) {
|
|
1522
|
+
else if (overlay && ensureSharedOverlayPointer(root, overlay, autoCommit, overlayMode, true)) {
|
|
1431
1523
|
shareNote = ` ✓ memory shared via the git common dir — this worktree sees the same decisions / bugs / constraints`;
|
|
1432
1524
|
}
|
|
1433
1525
|
else if (overlay) {
|
|
@@ -4452,6 +4544,7 @@ program
|
|
|
4452
4544
|
.option("--profile <profile>", "delivery role: builder, reviewer, or architect", "builder")
|
|
4453
4545
|
.option("--as-of <ref>", "time-travel: assemble the slice as it stood at a commit/tag/branch")
|
|
4454
4546
|
.option("--task <id>", "retain the exact context delivery for this task's contribution report")
|
|
4547
|
+
.option("--include <kinds>", "opt-in extras (comma list): recent-tasks,project-dna — omitted by default to keep the brief small")
|
|
4455
4548
|
.action(async (target, opts) => {
|
|
4456
4549
|
if (!DELIVERY_PROFILES.includes(opts.profile)) {
|
|
4457
4550
|
return fail(`--profile must be one of: ${DELIVERY_PROFILES.join(", ")}`);
|
|
@@ -4477,6 +4570,27 @@ program
|
|
|
4477
4570
|
// receipts matching the target — the same slice and render as hunch_context.
|
|
4478
4571
|
const slice = asOf ? null : store.stateSlice(target);
|
|
4479
4572
|
const stateGrounding = slice ? stateSupplements(slice, target) : [];
|
|
4573
|
+
// Both extras below cost brief tokens (and, for DNA/recent-tasks selection, extra
|
|
4574
|
+
// latency) — opt-in only via --include, omitted from the default brief.
|
|
4575
|
+
// Accept the MCP spelling (recent_tasks) too, so a name copied from the tool schema works.
|
|
4576
|
+
const include = opts.include ? opts.include.split(",").map((s) => s.trim().replace(/_/g, "-")).filter(Boolean) : [];
|
|
4577
|
+
const unknownInclude = include.filter((k) => k !== "recent-tasks" && k !== "project-dna");
|
|
4578
|
+
if (unknownInclude.length)
|
|
4579
|
+
return fail(`--include accepts recent-tasks, project-dna (got: ${unknownInclude.join(", ")})`);
|
|
4580
|
+
const recentTasks = (asOf || !include.includes("recent-tasks"))
|
|
4581
|
+
? []
|
|
4582
|
+
: taskSelectionSupplements(store.selectTasksAuto(target, buildTaskRankingQuery(root, opts.task ?? null, target)), target);
|
|
4583
|
+
let dnaSupplement = null;
|
|
4584
|
+
if (!asOf && include.includes("project-dna")) {
|
|
4585
|
+
try {
|
|
4586
|
+
dnaSupplement = projectDnaDeliverySupplement(discoverProjectDna(root, "HEAD"));
|
|
4587
|
+
}
|
|
4588
|
+
catch {
|
|
4589
|
+
// Same graceful behavior as hunch_context: a missing/unreadable Git checkout
|
|
4590
|
+
// must not break the CLI brief; the dedicated `hunch project-dna` command
|
|
4591
|
+
// reports the exact derivation error when a caller needs diagnostics.
|
|
4592
|
+
}
|
|
4593
|
+
}
|
|
4480
4594
|
if (empty && !asOf && opts.task) {
|
|
4481
4595
|
const resolved = store.rankedSearch(target, 8).map(hit => ({ hit, record: store.resolve(hit.ref)?.record }));
|
|
4482
4596
|
ctx = { ...ctx,
|
|
@@ -4512,7 +4626,7 @@ program
|
|
|
4512
4626
|
decisionCorpus: store.recs("decisions"),
|
|
4513
4627
|
historical: !!asOf,
|
|
4514
4628
|
profile: opts.profile,
|
|
4515
|
-
supplements: [...
|
|
4629
|
+
supplements: [...(dnaSupplement ? [dnaSupplement] : []), ...stateGrounding, ...recentTasks],
|
|
4516
4630
|
});
|
|
4517
4631
|
process.stdout.write(envelope.text);
|
|
4518
4632
|
if (opts.task) {
|
|
@@ -4856,10 +4970,39 @@ program
|
|
|
4856
4970
|
// Verification pipeline (delivery enforced, not hoped for — see core/pipeline.ts).
|
|
4857
4971
|
// PostToolUse records facts; Stop gates on them. Both are pipeline-only events,
|
|
4858
4972
|
// handled before the grounding dispatch below.
|
|
4859
|
-
|
|
4973
|
+
const postTool = evt.hook_event_name === "PostToolUse" || evt.hook_event_name === "PostToolUseFailure" ? evt.hook_event_name : null;
|
|
4974
|
+
// A shell command can edit files without the edit tools ever firing: ground
|
|
4975
|
+
// what it wrote now. Every other tool call just moves the baseline.
|
|
4976
|
+
let shellGround = "";
|
|
4977
|
+
if (postTool) {
|
|
4978
|
+
// Fail-open on its own: a grounding error must not cost the pipeline
|
|
4979
|
+
// bookkeeping below (a missed check would wrongly hold the Stop gate).
|
|
4980
|
+
try {
|
|
4981
|
+
if (evt.tool_name === "Bash" || evt.tool_name === "PowerShell") {
|
|
4982
|
+
const written = shellWrittenFiles(root, evt.session_id, evt.agent_id);
|
|
4983
|
+
if (written.length) {
|
|
4984
|
+
const opened = openTeamStore(root, { requireFreshTeamMemory: firmness === "strict" });
|
|
4985
|
+
store = opened.store;
|
|
4986
|
+
// Same rule as the pre-edit path: strict never grounds from stale team rules.
|
|
4987
|
+
const stale = firmness === "strict" && opened.teamPullStatus
|
|
4988
|
+
&& opened.teamPullStatus !== "updated" && opened.teamPullStatus !== "current";
|
|
4989
|
+
if (!stale)
|
|
4990
|
+
shellGround = shellWriteGrounding(root, store, provider, evt, written);
|
|
4991
|
+
}
|
|
4992
|
+
}
|
|
4993
|
+
else {
|
|
4994
|
+
refreshShellBaseline(root, evt.session_id, evt.agent_id);
|
|
4995
|
+
}
|
|
4996
|
+
}
|
|
4997
|
+
catch {
|
|
4998
|
+
shellGround = "";
|
|
4999
|
+
}
|
|
5000
|
+
}
|
|
5001
|
+
if (postTool && evt.session_id && pipelineEnabled()) {
|
|
4860
5002
|
let st = loadPipelineState(evt.session_id);
|
|
4861
5003
|
const before = st;
|
|
4862
5004
|
let activity = null;
|
|
5005
|
+
let lessonNote = "";
|
|
4863
5006
|
if (/^(Edit|Write|MultiEdit)$/.test(evt.tool_name ?? "")) {
|
|
4864
5007
|
// A Codex apply_patch touches every file it lists (and each Move-to
|
|
4865
5008
|
// destination); the Stop gate must see all of them, not only the first.
|
|
@@ -4876,6 +5019,13 @@ program
|
|
|
4876
5019
|
const command = String(evt.tool_input?.command ?? "");
|
|
4877
5020
|
st = onCommand(st, command, evt.tool_outcome);
|
|
4878
5021
|
activity = { kind: "command", command };
|
|
5022
|
+
// A command that both wrote the file and ran a check just received the
|
|
5023
|
+
// lesson itself; the follow-up waits for the next check.
|
|
5024
|
+
if (!shellGround) {
|
|
5025
|
+
const followUp = lessonReminder(st, command, (l) => functionBodyHash(root, l.file, l.symbol));
|
|
5026
|
+
st = followUp.state;
|
|
5027
|
+
lessonNote = followUp.reminder;
|
|
5028
|
+
}
|
|
4879
5029
|
}
|
|
4880
5030
|
else if (evt.tool_name === "Skill") {
|
|
4881
5031
|
st = onSkill(st, String(evt.tool_input?.skill ?? ""));
|
|
@@ -4885,11 +5035,19 @@ program
|
|
|
4885
5035
|
const checkpoint = proofCheckpoint(before, st, activity);
|
|
4886
5036
|
st = checkpoint.state;
|
|
4887
5037
|
savePipelineState(evt.session_id, st);
|
|
4888
|
-
|
|
4889
|
-
|
|
5038
|
+
const post = [shellGround, lessonNote, checkpoint.reminder].filter(Boolean).join("\n\n");
|
|
5039
|
+
if (post)
|
|
5040
|
+
emitContext(provider, postTool, post);
|
|
4890
5041
|
return;
|
|
4891
5042
|
}
|
|
4892
5043
|
savePipelineState(evt.session_id, st);
|
|
5044
|
+
if (shellGround)
|
|
5045
|
+
emitContext(provider, postTool, shellGround);
|
|
5046
|
+
return;
|
|
5047
|
+
}
|
|
5048
|
+
if (postTool) {
|
|
5049
|
+
if (shellGround)
|
|
5050
|
+
emitContext(provider, postTool, shellGround);
|
|
4893
5051
|
return;
|
|
4894
5052
|
}
|
|
4895
5053
|
if (evt.hook_event_name === "Stop") {
|
|
@@ -4922,21 +5080,33 @@ program
|
|
|
4922
5080
|
// When the prompt reads like a correction ("no / that's wrong / never X"),
|
|
4923
5081
|
// nudge the agent to PERSIST it as an enforced constraint (Never Twice) —
|
|
4924
5082
|
// not just obey it this once and forget it next session.
|
|
5083
|
+
// Shell writes are measured from the start of each prompt.
|
|
5084
|
+
refreshShellBaseline(root, evt.session_id);
|
|
4925
5085
|
const isCorrection = looksLikeCorrection(evt.prompt);
|
|
4926
|
-
|
|
4927
|
-
//
|
|
4928
|
-
//
|
|
4929
|
-
//
|
|
4930
|
-
//
|
|
4931
|
-
//
|
|
4932
|
-
//
|
|
4933
|
-
|
|
5086
|
+
// Once per session is enough for the bare availability reminder — repeating it
|
|
5087
|
+
// every prompt burns context for zero information (dec_244397d920). It is deduped
|
|
5088
|
+
// on its OWN key: hashed together with the per-prompt task report (a new ID each
|
|
5089
|
+
// prompt) it was re-sent on every prompt. A correction is never deduped: two
|
|
5090
|
+
// back-to-back corrections are byte-identical, and the second (usually the
|
|
5091
|
+
// escalating one) must still become an enforced rule. Neither is the unverified
|
|
5092
|
+
// nag below, the one nag that must repeat.
|
|
5093
|
+
const parts = [];
|
|
5094
|
+
// Always stamp the key, so a correction that delivers the reminder counts as
|
|
5095
|
+
// this session's one delivery.
|
|
5096
|
+
const reminderDue = injectionMode(evt.session_id, "prompt-reminder", HOOK_REMINDER) === "full";
|
|
5097
|
+
if (isCorrection)
|
|
5098
|
+
parts.push(`${HOOK_REMINDER}\n\n${CORRECTION_NUDGE}`);
|
|
5099
|
+
else if (reminderDue)
|
|
5100
|
+
parts.push(HOOK_REMINDER);
|
|
4934
5101
|
// Reporting failure must not suppress the existing correction/policy reminder.
|
|
4935
5102
|
try {
|
|
4936
5103
|
const report = startHookReport(root, provider, evt);
|
|
4937
5104
|
if (report) {
|
|
4938
|
-
|
|
4939
|
-
|
|
5105
|
+
parts.push(report);
|
|
5106
|
+
store ??= new HunchStore(paths);
|
|
5107
|
+
const selected = promptTaskSelection(root, store, provider, evt);
|
|
5108
|
+
if (selected)
|
|
5109
|
+
parts.push(selected);
|
|
4940
5110
|
}
|
|
4941
5111
|
}
|
|
4942
5112
|
catch { /* passive reporting remains fail-open */ }
|
|
@@ -4957,16 +5127,12 @@ program
|
|
|
4957
5127
|
const st = onPrompt(loadPipelineState(evt.session_id));
|
|
4958
5128
|
savePipelineState(evt.session_id, st);
|
|
4959
5129
|
if (!st.verifyAfterEdit || st.obligations.some((item) => item.status !== "satisfied")) {
|
|
4960
|
-
|
|
4961
|
-
mustDeliver = true;
|
|
5130
|
+
parts.push(unverifiedNag(st));
|
|
4962
5131
|
}
|
|
4963
5132
|
}
|
|
4964
|
-
|
|
4965
|
-
// every prompt burns context for zero information (dec_244397d920). Only that
|
|
4966
|
-
// ambient case is deduped.
|
|
4967
|
-
if (!mustDeliver && injectionMode(evt.session_id, "prompt-reminder", text) === "delta")
|
|
5133
|
+
if (!parts.length)
|
|
4968
5134
|
return;
|
|
4969
|
-
emitContext(provider, "UserPromptSubmit",
|
|
5135
|
+
emitContext(provider, "UserPromptSubmit", parts.join("\n\n"));
|
|
4970
5136
|
return;
|
|
4971
5137
|
}
|
|
4972
5138
|
if (evt.hook_event_name === "PreCompact") {
|
|
@@ -4991,6 +5157,12 @@ program
|
|
|
4991
5157
|
// would cross worktrees; stay silent instead of guessing which side is right.
|
|
4992
5158
|
if ((provider === "claude" || provider === "codex") && evt.cwd !== undefined && !routedCwd)
|
|
4993
5159
|
return;
|
|
5160
|
+
// The prompt's baseline is the session's, not this agent's: without its
|
|
5161
|
+
// own, a first tool call that is a shell write would go ungrounded.
|
|
5162
|
+
// Without an agent id the key would be the SESSION's baseline, and
|
|
5163
|
+
// refreshing it here could swallow a parent write not yet grounded.
|
|
5164
|
+
if (evt.agent_id)
|
|
5165
|
+
refreshShellBaseline(root, evt.session_id, evt.agent_id);
|
|
4994
5166
|
const s = new HunchStore(paths);
|
|
4995
5167
|
try {
|
|
4996
5168
|
const clip1 = (text, max) => {
|
|
@@ -5081,10 +5253,11 @@ program
|
|
|
5081
5253
|
return;
|
|
5082
5254
|
}
|
|
5083
5255
|
if (evt.hook_event_name === "SessionStart") {
|
|
5084
|
-
// A compact-resume
|
|
5085
|
-
//
|
|
5086
|
-
// the session gets delta one-liners
|
|
5087
|
-
|
|
5256
|
+
// A compact-resume (or a cleared context that keeps its session id) means
|
|
5257
|
+
// everything injected so far is gone — the dedup map must forget it
|
|
5258
|
+
// delivered anything, or the rest of the session gets delta one-liners
|
|
5259
|
+
// against grounding and task rules the agent no longer has.
|
|
5260
|
+
if (evt.source === "compact" || evt.source === "clear")
|
|
5088
5261
|
resetSessionInjections(evt.session_id);
|
|
5089
5262
|
// Orientation at the moment it matters: what just happened + what's next,
|
|
5090
5263
|
// straight from the graph — the agent sits down already knowing where it
|
|
@@ -5108,7 +5281,7 @@ program
|
|
|
5108
5281
|
// in the overlay. Private mode stays public-only: session transcripts travel
|
|
5109
5282
|
// further than a terminal.
|
|
5110
5283
|
const decisions = s.advisoryRecs("decisions");
|
|
5111
|
-
const {
|
|
5284
|
+
const { pendingReview } = nowData(decisions, 3);
|
|
5112
5285
|
const escalations = pendingEscalations(decisions);
|
|
5113
5286
|
escalations.push(...premiseEscalations(decisions, { now: new Date().toISOString(), exists: (p) => existsSync(join(paths.root, p)) }));
|
|
5114
5287
|
// liveness checked against the full store even in private mode — a
|
|
@@ -5145,17 +5318,6 @@ program
|
|
|
5145
5318
|
}
|
|
5146
5319
|
const L = [];
|
|
5147
5320
|
L.push(`🧠 Hunch orientation — ${decisions.length} decision(s) in the graph.`);
|
|
5148
|
-
if (recent.length) {
|
|
5149
|
-
L.push("Recent:");
|
|
5150
|
-
for (const r of recent)
|
|
5151
|
-
L.push(` ${r.date} [${r.status}] ${r.title} (${r.id})`);
|
|
5152
|
-
}
|
|
5153
|
-
if (roadmap.length) {
|
|
5154
|
-
L.push(`Roadmap (${roadmap.length} live proposed): ${roadmap.slice(0, 3).map((r) => (r.unconfirmed ? `${r.title} [unconfirmed, ${r.id}]` : r.title)).join(" · ")}${roadmap.length > 3 ? " · …" : ""}`);
|
|
5155
|
-
const unconfirmed = roadmap.filter((r) => r.unconfirmed).length;
|
|
5156
|
-
if (unconfirmed)
|
|
5157
|
-
L.push(`${unconfirmed} roadmap item(s) are unconfirmed agent testimony — the human confirms each with \`hunch review --confirm <id>${s.unified ? " --private" : ""}\`.`);
|
|
5158
|
-
}
|
|
5159
5321
|
if (pendingReview > 0)
|
|
5160
5322
|
L.push(`${pendingReview} legacy un-vouched draft(s) — adopt as advisory memory with \`hunch adopt-drafts\` (new captures auto-trust).`);
|
|
5161
5323
|
if (actionableEsc.length) {
|
|
@@ -5183,6 +5345,13 @@ program
|
|
|
5183
5345
|
}
|
|
5184
5346
|
if (evt.hook_event_name !== "PreToolUse")
|
|
5185
5347
|
return;
|
|
5348
|
+
// A shell command's writes are measured from its own start: a file written
|
|
5349
|
+
// before it (a parallel tool call, another process, a host that skipped the
|
|
5350
|
+
// last PostToolUse) is not this command's to be blamed for.
|
|
5351
|
+
if (evt.tool_name === "Bash" || evt.tool_name === "PowerShell") {
|
|
5352
|
+
refreshShellBaseline(root, evt.session_id, evt.agent_id);
|
|
5353
|
+
return;
|
|
5354
|
+
}
|
|
5186
5355
|
const targets = editTargets(root, evt.tool_input);
|
|
5187
5356
|
// Nothing inside the repo → nothing for Hunch to say.
|
|
5188
5357
|
if (!targets.length)
|
|
@@ -5250,115 +5419,10 @@ program
|
|
|
5250
5419
|
}
|
|
5251
5420
|
}
|
|
5252
5421
|
// advisory / firm / strict(non-blocking): inject the relevant Hunch slice.
|
|
5253
|
-
|
|
5254
|
-
|
|
5255
|
-
// graph outranks the prose being edited, and a stale pin is called out inline.
|
|
5256
|
-
let docGround = "";
|
|
5257
|
-
if (/\.(md|mdx)$/i.test(target)) {
|
|
5258
|
-
try {
|
|
5259
|
-
docGround = renderDocGrounding(parseDocAnchors(readFileSync(abs, "utf8")), store.recs("decisions"));
|
|
5260
|
-
}
|
|
5261
|
-
catch { /* unreadable / not yet created — no doc grounding */ }
|
|
5262
|
-
}
|
|
5263
|
-
const ctx = store.assembleContext(target);
|
|
5264
|
-
// Regression Guard (edit-time grounding): what an in-force decision retired
|
|
5265
|
-
// from this file. No diff exists yet, so this is context — "don't re-add X" —
|
|
5266
|
-
// not a block; the commit-time `hunch check` does the actual gating.
|
|
5267
|
-
const retired = store.retiredForFile(target).filter((r) => r.symbols.length || r.deps.length);
|
|
5268
|
-
const recentTasks = taskSelectionSupplements(store.selectTasksAuto(target, buildTaskRankingQuery(root, hookReportTaskId(root, provider, evt), target, { excludeTargetDeliveries: true })), target);
|
|
5269
|
-
const hasContent = ctx.constraints.length ||
|
|
5270
|
-
ctx.decisions.length ||
|
|
5271
|
-
ctx.bugs.length ||
|
|
5272
|
-
ctx.blast_radius.length ||
|
|
5273
|
-
ctx.findings.length ||
|
|
5274
|
-
ctx.landscape?.resources.length ||
|
|
5275
|
-
ctx.landscape?.relationships.length ||
|
|
5276
|
-
retired.length ||
|
|
5277
|
-
recentTasks.length ||
|
|
5278
|
-
docGround;
|
|
5279
|
-
if (!hasContent)
|
|
5422
|
+
const grounded = fileGrounding(root, store, provider, evt, target, abs);
|
|
5423
|
+
if (!grounded)
|
|
5280
5424
|
return; // no noise on files Hunch hasn't learned yet
|
|
5281
|
-
|
|
5282
|
-
...(retired.length ? [{
|
|
5283
|
-
id: "retired-code",
|
|
5284
|
-
kind: "retired-code",
|
|
5285
|
-
priority: 200,
|
|
5286
|
-
text: `⚠ Deliberately RETIRED from this file — do not re-introduce without cause: ${retired.map((r) => `${[...r.symbols, ...r.deps].join(", ")} (${r.decision})`).join("; ")}.`,
|
|
5287
|
-
}] : []),
|
|
5288
|
-
...(docGround ? [{ id: "doc-grounding", kind: "doc-grounding", priority: 100, text: docGround }] : []),
|
|
5289
|
-
...recentTasks,
|
|
5290
|
-
];
|
|
5291
|
-
const envelope = buildDeliveryEnvelope(ctx, {
|
|
5292
|
-
profile: "builder",
|
|
5293
|
-
root,
|
|
5294
|
-
symbols: store.recs("symbols"),
|
|
5295
|
-
components: store.recs("components"),
|
|
5296
|
-
decisionCorpus: store.recs("decisions"),
|
|
5297
|
-
supplements,
|
|
5298
|
-
});
|
|
5299
|
-
const text = envelope.text.trim();
|
|
5300
|
-
// Identical grounding already shown this session → one-line delta instead of
|
|
5301
|
-
// the full 10-16KB block. Any record change re-sends the full text; the
|
|
5302
|
-
// strict-gate deny path above never routes through this (dec_244397d920).
|
|
5303
|
-
// Delivery receipts (dec_925f4bcaad): the ledger of what actually reached
|
|
5304
|
-
// an agent. A full injection is a serve; a delta one-liner attests the
|
|
5305
|
-
// earlier serve is still standing. Never throws, never blocks.
|
|
5306
|
-
const receipts = (event) => recordServed(root, [
|
|
5307
|
-
...envelope.delivered.map((item) => ({
|
|
5308
|
-
event,
|
|
5309
|
-
kind: item.kind,
|
|
5310
|
-
record_id: item.record_id,
|
|
5311
|
-
target,
|
|
5312
|
-
session_id: evt.session_id,
|
|
5313
|
-
rank: item.rank,
|
|
5314
|
-
delivery_reason: item.delivery_reason,
|
|
5315
|
-
provenance_status: item.provenance_status,
|
|
5316
|
-
token_cost: item.token_cost,
|
|
5317
|
-
delivery_profile: envelope.profile,
|
|
5318
|
-
ranking_policy: envelope.ranking_policy,
|
|
5319
|
-
})),
|
|
5320
|
-
// Delivered task lines are receipts too: they feed access-based recency.
|
|
5321
|
-
...envelope.supplements.filter((s) => s.kind === "recent-task" && s.delivered).map((s) => ({
|
|
5322
|
-
event, kind: "tasks", record_id: s.id, target, session_id: evt.session_id,
|
|
5323
|
-
rank: s.rank, delivery_reason: "supplemental", token_cost: s.token_cost,
|
|
5324
|
-
delivery_profile: envelope.profile, ranking_policy: envelope.ranking_policy,
|
|
5325
|
-
})),
|
|
5326
|
-
]);
|
|
5327
|
-
const reportTaskId = hookReportTaskId(root, provider, evt);
|
|
5328
|
-
// A new authoritative prompt gets its own full delivery. An earlier
|
|
5329
|
-
// prompt's session-level delta cannot establish this task's receipt.
|
|
5330
|
-
// A subagent reports to the prompt's task but starts with FRESH context:
|
|
5331
|
-
// it never saw that grounding, so its dedup is scoped by its own agent
|
|
5332
|
-
// identity (hashed — the raw agent_id is never retained in the key).
|
|
5333
|
-
const agentKey = evt.agent_id ? `:${reportHash(evt.agent_id).slice(7, 19)}` : "";
|
|
5334
|
-
// Dedup on the envelope's stable IDENTITY projection, never on the
|
|
5335
|
-
// rendered block: serving the full text writes delivery receipts, and the
|
|
5336
|
-
// next call's task ranking reads them back and moves the wording ("today"
|
|
5337
|
-
// → "delivered today"), so hashing the presentation made this grounding
|
|
5338
|
-
// self-invalidating and re-sent the full block for unchanged records.
|
|
5339
|
-
if (injectionMode(evt.session_id, `pre:${target}${reportTaskId ? `:${reportTaskId}` : ""}${agentKey}`, text, deliveryDedupeInput(envelope, supplements)) === "delta") {
|
|
5340
|
-
receipts("refreshed");
|
|
5341
|
-
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).`);
|
|
5342
|
-
return;
|
|
5343
|
-
}
|
|
5344
|
-
receipts("served");
|
|
5345
|
-
let reportNotice = "";
|
|
5346
|
-
let recalled = null;
|
|
5347
|
-
if (reportTaskId) {
|
|
5348
|
-
try {
|
|
5349
|
-
const snapshots = snapshotDeliveredRecords(store, envelope);
|
|
5350
|
-
// The first time a lesson reaches this prompt's task, tell the USER in one
|
|
5351
|
-
// line (systemMessage); repeats of the same revision stay silent.
|
|
5352
|
-
recalled = reportPresentationEnabled(root) ? renderRecalledLine(unseenLessons(root, reportTaskId, snapshots)) : null;
|
|
5353
|
-
const occurrence = recordTaskDelivery(root, reportTaskId, envelope, snapshots, undefined, target);
|
|
5354
|
-
reportNotice = `\n\nHunch task ${reportTaskId} · delivery ${occurrence}. Inspect exact application references with hunch_report(task_id).`;
|
|
5355
|
-
}
|
|
5356
|
-
catch {
|
|
5357
|
-
reportNotice = "\n\nTask report observation unavailable; this delivery's task contribution remains unverified.";
|
|
5358
|
-
recalled = null;
|
|
5359
|
-
}
|
|
5360
|
-
}
|
|
5361
|
-
emitContext(provider, "PreToolUse", text + reportNotice, recalled ?? undefined);
|
|
5425
|
+
emitContext(provider, "PreToolUse", grounded.text, grounded.mode === "full" ? grounded.recalled ?? undefined : undefined);
|
|
5362
5426
|
}
|
|
5363
5427
|
catch (e) {
|
|
5364
5428
|
// Never block an edit on a hook failure — and never go silent either: an
|
|
@@ -6551,6 +6615,24 @@ program
|
|
|
6551
6615
|
return fail("push failed — no upstream, offline, or nothing to push.");
|
|
6552
6616
|
console.log("✓ pushed the current branch to its remote.");
|
|
6553
6617
|
});
|
|
6618
|
+
// ---- footprint (how much text Hunch injects into an agent's context) -------
|
|
6619
|
+
program
|
|
6620
|
+
.command("footprint")
|
|
6621
|
+
.description("Measure how much text Hunch injects into an agent's context — MCP tool list, hunch_context brief, grounding block, hook text — from the same code paths the product serves. Tokens are estimated as characters / 4.")
|
|
6622
|
+
.option("--json", "emit the report (hunch.footprint/1) as JSON")
|
|
6623
|
+
.option("--target <file>", "file to measure the hunch_context result for (default: the file most decisions cite)")
|
|
6624
|
+
.action(async (opts) => {
|
|
6625
|
+
const report = await measureFootprint(findRoot(), { target: opts.target });
|
|
6626
|
+
if (opts.json)
|
|
6627
|
+
return console.log(JSON.stringify(report, null, 2));
|
|
6628
|
+
const width = Math.max(...report.surfaces.map((s) => s.id.length));
|
|
6629
|
+
for (const s of report.surfaces)
|
|
6630
|
+
console.log(`${s.id.padEnd(width)} ${String(s.chars).padStart(7)} ~${s.est_tokens}`);
|
|
6631
|
+
console.log("\nNot measured in-process:");
|
|
6632
|
+
for (const u of report.unmeasured)
|
|
6633
|
+
console.log(`· ${u}`);
|
|
6634
|
+
console.log(dim("\nTokens estimated as characters / 4, not a tokenizer."));
|
|
6635
|
+
});
|
|
6554
6636
|
// ---- drift (doc≠graph detector; advisory + CI-gateable) -------------------
|
|
6555
6637
|
program
|
|
6556
6638
|
.command("drift")
|
|
@@ -6607,10 +6689,13 @@ program
|
|
|
6607
6689
|
.command("grounding")
|
|
6608
6690
|
.description("Check the committed grounding docs (CLAUDE.md, AGENTS.md, copilot-instructions, hunch.mdc, hunch.md) against what the PUBLIC graph generates — direction-aware: counts that merely LAG a merge are reported, counts AHEAD of the store (a record never committed) or divergent prose fail. --refresh regenerates every existing doc from the public store (never the overlay union, never a doc the project lacks). Exits 1 on ahead/diverged unless refreshed.")
|
|
6609
6691
|
.option("--refresh", "regenerate the existing grounding docs from the public store (what the post-merge hook and the release remedy run)")
|
|
6692
|
+
.option("--force", "with --refresh: re-render even a block written by a newer Hunch template")
|
|
6610
6693
|
.option("--json", "machine-readable verdicts")
|
|
6611
6694
|
.option("--quiet", "print nothing on success")
|
|
6612
6695
|
.action((opts) => {
|
|
6613
6696
|
const root = findRoot();
|
|
6697
|
+
if (opts.force && !opts.refresh)
|
|
6698
|
+
console.error("hunch grounding: --force only applies with --refresh; checking without re-rendering.");
|
|
6614
6699
|
// PUBLIC-ONLY by construction, exactly as the release gate and the freshness test
|
|
6615
6700
|
// read it: HUNCH_PRIVATE_DIR at an empty overlay beats .hunch/local.json and the
|
|
6616
6701
|
// shared pointer, so a dev machine with an overlay attached can never write union
|
|
@@ -6631,7 +6716,8 @@ program
|
|
|
6631
6716
|
return { doc: rel, verdict: { kind: "diverged", reason: "no managed HUNCH block" } };
|
|
6632
6717
|
return { doc: rel, verdict: classifyGroundingBlock(committed, generated) };
|
|
6633
6718
|
});
|
|
6634
|
-
const refreshed = opts.refresh ? refreshExistingGrounding(root, store) : [];
|
|
6719
|
+
const refreshed = opts.refresh ? refreshExistingGrounding(root, store, { force: opts.force }) : [];
|
|
6720
|
+
// `newer` (a block from a newer Hunch template) is reported, never failing.
|
|
6635
6721
|
const failing = verdicts.filter((v) => v.verdict.kind === "ahead" || v.verdict.kind === "diverged");
|
|
6636
6722
|
const lagging = verdicts.filter((v) => v.verdict.kind === "lagging");
|
|
6637
6723
|
if (opts.json) {
|
|
@@ -6647,12 +6733,15 @@ program
|
|
|
6647
6733
|
continue;
|
|
6648
6734
|
if (v.verdict.kind === "fresh" && opts.quiet)
|
|
6649
6735
|
continue;
|
|
6650
|
-
console.log(`${v.verdict.kind === "fresh" ? "✓" : v.verdict.kind === "lagging" ? "·" : "✗"} ${describeGroundingFreshness(v.doc, v.verdict)}`);
|
|
6736
|
+
console.log(`${v.verdict.kind === "fresh" ? "✓" : v.verdict.kind === "lagging" || v.verdict.kind === "newer" ? "·" : "✗"} ${describeGroundingFreshness(v.doc, v.verdict)}`);
|
|
6651
6737
|
}
|
|
6652
6738
|
if (!opts.quiet && !failing.length) {
|
|
6739
|
+
const newer = verdicts.filter((v) => v.verdict.kind === "newer");
|
|
6653
6740
|
console.log(lagging.length
|
|
6654
6741
|
? `\n${lagging.length} doc(s) lag a merge — transient; the next capture commit or \`hunch grounding --refresh\` heals it.`
|
|
6655
|
-
:
|
|
6742
|
+
: newer.length
|
|
6743
|
+
? `\n${newer.length} doc(s) written by a newer Hunch — kept as written; upgrade Hunch to check them fully.`
|
|
6744
|
+
: "✓ grounding docs are fresh.");
|
|
6656
6745
|
}
|
|
6657
6746
|
}
|
|
6658
6747
|
if (!opts.refresh && failing.length)
|
|
@@ -7315,7 +7404,7 @@ program
|
|
|
7315
7404
|
else {
|
|
7316
7405
|
const team = readTeamConfig(root);
|
|
7317
7406
|
console.log(team
|
|
7318
|
-
? `overlay: off, but .hunch/team.json advertises the team store (${team.shared_repo}) — run \`hunch init\` to auto-connect`
|
|
7407
|
+
? `overlay: off, but .hunch/team.json advertises the team store (${team.shared_repo}) — ${isTeamStoreTrusted(root, team) ? "run \`hunch init\` to auto-connect" : "if it is your team's store, run \`hunch shared --trust\` to connect"}`
|
|
7319
7408
|
: dim(`private: off — run \`hunch shared\` (or \`hunch private\`) to use one overlay repo across teammates/worktrees (or set HUNCH_PRIVATE_DIR)`));
|
|
7320
7409
|
}
|
|
7321
7410
|
// Worktree posture: linked worktrees share ONE memory via the git common dir. Only
|
|
@@ -7389,10 +7478,6 @@ function fail(msg) {
|
|
|
7389
7478
|
process.exitCode = 1;
|
|
7390
7479
|
}
|
|
7391
7480
|
// --- agent-hook helpers (used by `hunch hook`) -----------------------------
|
|
7392
|
-
const HOOK_REMINDER = "Hunch (engineering memory) is available for this repo. Before editing, call " +
|
|
7393
|
-
"hunch_check_constraints(scope) for do-not-break invariants and hunch_why(target) " +
|
|
7394
|
-
"for the rationale; use hunch_get_dependents for blast radius and hunch_bug_lineage " +
|
|
7395
|
-
"for prior root causes. After a non-trivial choice, record it with hunch_record_decision.";
|
|
7396
7481
|
/** Read all of stdin (the hook event JSON). A TTY (no piped input) resolves to ""
|
|
7397
7482
|
* so an accidental interactive `hunch hook` exits cleanly instead of hanging. */
|
|
7398
7483
|
function readStdin() {
|
|
@@ -7419,6 +7504,243 @@ function toRepoRel(root, abs) {
|
|
|
7419
7504
|
const rel = repoRelativeTarget(abs, root);
|
|
7420
7505
|
return isAbsolute(rel) || /^[a-zA-Z]:/.test(rel) ? "" : rel;
|
|
7421
7506
|
}
|
|
7507
|
+
/** Files a shell command wrote get the grounding the edit tools would have
|
|
7508
|
+
* delivered before the edit — late, but while the agent can still revise.
|
|
7509
|
+
* Already-served grounding (a delta) is not repeated. */
|
|
7510
|
+
const MAX_SHELL_GROUNDED = 3;
|
|
7511
|
+
function shellWriteGrounding(root, store, provider, evt, written) {
|
|
7512
|
+
const parts = [];
|
|
7513
|
+
const grounded = [];
|
|
7514
|
+
for (const target of written.slice(0, MAX_SHELL_GROUNDED)) {
|
|
7515
|
+
const g = fileGrounding(root, store, provider, evt, target, join(root, target));
|
|
7516
|
+
if (g?.mode === "full") {
|
|
7517
|
+
parts.push(g.text);
|
|
7518
|
+
grounded.push(target);
|
|
7519
|
+
}
|
|
7520
|
+
}
|
|
7521
|
+
if (!parts.length)
|
|
7522
|
+
return "";
|
|
7523
|
+
const more = written.length > MAX_SHELL_GROUNDED ? ` (${written.length - MAX_SHELL_GROUNDED} more written file(s) not checked)` : "";
|
|
7524
|
+
const sibling = parts.some((p) => p.startsWith(SIBLING_HEADING)) ? " It starts with a fix a same-shaped function elsewhere received and this file's copy never did: resolve it before you finish." : "";
|
|
7525
|
+
return `Hunch: this shell command wrote ${grounded.join(", ")}${more}. Edits made outside the Edit/Write tools skip the pre-edit grounding, so it arrives now: re-check the change against it before relying on it.${sibling}\n\n${parts.join("\n\n")}`;
|
|
7526
|
+
}
|
|
7527
|
+
/** Score the store against this prompt once per task (taskSelection.ts) and keep
|
|
7528
|
+
* the selected ids — never the prompt — for the task's file grounding. Returns
|
|
7529
|
+
* the short prompt-time list, or "" when nothing clears the threshold (silence).
|
|
7530
|
+
* A follow-up prompt widens the qualifying set rather than replacing it, so a
|
|
7531
|
+
* "go" cannot unselect the task's memory. */
|
|
7532
|
+
const TASK_SELECTION_TITLE_CHARS = 80;
|
|
7533
|
+
function promptTaskSelection(root, store, provider, evt) {
|
|
7534
|
+
// A host notification is not a task prompt: the earlier selection stands untouched.
|
|
7535
|
+
if (!taskSelectionEnabled() || isNotificationPrompt(evt.prompt))
|
|
7536
|
+
return "";
|
|
7537
|
+
const taskId = hookReportTaskId(root, provider, evt);
|
|
7538
|
+
if (!taskId)
|
|
7539
|
+
return "";
|
|
7540
|
+
const selection = selectForTask(liveSelectionRecords({
|
|
7541
|
+
decisions: store.recs("decisions"),
|
|
7542
|
+
bugs: store.recs("bugs"),
|
|
7543
|
+
constraints: store.recs("constraints"),
|
|
7544
|
+
findings: store.recs("findings"),
|
|
7545
|
+
}), evt.prompt ?? "", { root, pathExists: (p) => existsSync(join(root, p)) });
|
|
7546
|
+
// Same task id again (a notification alias) keeps its own earlier selection; a
|
|
7547
|
+
// new task that CONTINUES the session's previous one inherits that task's
|
|
7548
|
+
// selection only on a bare follow-up ("continue", "go on"), so it is not left
|
|
7549
|
+
// ungrounded. A substantive prompt names its own task: a same-session prompt
|
|
7550
|
+
// inside the continuation window must not hide memory behind an old selection.
|
|
7551
|
+
let previous = loadTaskSelection(taskId);
|
|
7552
|
+
if (!previous && isBareFollowUp(evt.prompt ?? "")) {
|
|
7553
|
+
try {
|
|
7554
|
+
const continues = readTaskReport(root, taskId).task.continues;
|
|
7555
|
+
previous = continues ? loadTaskSelection(continues) : null;
|
|
7556
|
+
}
|
|
7557
|
+
catch { /* no continuity; the prompt's own selection stands */ }
|
|
7558
|
+
}
|
|
7559
|
+
const qualifying = [...new Set([...(previous?.qualifying ?? []), ...selection.qualifying])];
|
|
7560
|
+
// An empty selection is no selection: the task keeps the unfiltered grounding.
|
|
7561
|
+
// Emptiness counts only the kinds grounding filters (decisions, bugs,
|
|
7562
|
+
// findings): constraints always pass, so a constraint-only set would hide every
|
|
7563
|
+
// other record anchored to the file. The prompt-time list may still print.
|
|
7564
|
+
if (!qualifying.some(isFilterableSelectionId)) {
|
|
7565
|
+
clearTaskSelection(taskId);
|
|
7566
|
+
}
|
|
7567
|
+
else {
|
|
7568
|
+
saveTaskSelection({
|
|
7569
|
+
task_id: taskId,
|
|
7570
|
+
qualifying,
|
|
7571
|
+
top: selection.qualifying.length ? selection.top.map((item) => item.id) : previous?.top ?? [],
|
|
7572
|
+
});
|
|
7573
|
+
}
|
|
7574
|
+
// An inherited selection was listed when its own prompt ran: not again.
|
|
7575
|
+
if (!selection.top.length)
|
|
7576
|
+
return "";
|
|
7577
|
+
const clip = (text) => {
|
|
7578
|
+
const flat = text.replace(/\s+/g, " ").trim();
|
|
7579
|
+
return flat.length > TASK_SELECTION_TITLE_CHARS ? `${flat.slice(0, TASK_SELECTION_TITLE_CHARS - 1).trimEnd()}…` : flat;
|
|
7580
|
+
};
|
|
7581
|
+
return `Hunch memory for this task: ${selection.top.map((item) => `${item.id} — ${clip(item.title)}`).join(" · ")} (hunch_why(id) for detail)`;
|
|
7582
|
+
}
|
|
7583
|
+
/** Memory budget beside a sibling lesson (the default is 1500 tokens). */
|
|
7584
|
+
const SIBLING_MEMORY_BUDGET_TOKENS = 800;
|
|
7585
|
+
/** The advisory grounding for one repo-relative file: the ranked memory slice,
|
|
7586
|
+
* retired code, doc anchors, recent tasks and sibling-fix lessons. `delta` when
|
|
7587
|
+
* identical grounding was already served this session, null when Hunch knows
|
|
7588
|
+
* nothing about the file. Serves the pre-edit hook and files a shell command
|
|
7589
|
+
* wrote (which never pass through the edit tools). */
|
|
7590
|
+
function fileGrounding(root, store, provider, evt, target, abs) {
|
|
7591
|
+
// Decision-grounding for PROSE (doc≠graph): a markdown target that declares
|
|
7592
|
+
// <!-- hunch:topic … --> anchors gets each topic's CURRENT decision — the
|
|
7593
|
+
// graph outranks the prose being edited, and a stale pin is called out inline.
|
|
7594
|
+
let docGround = "";
|
|
7595
|
+
if (/\.(md|mdx)$/i.test(target)) {
|
|
7596
|
+
try {
|
|
7597
|
+
docGround = renderDocGrounding(parseDocAnchors(readFileSync(abs, "utf8")), store.recs("decisions"));
|
|
7598
|
+
}
|
|
7599
|
+
catch { /* unreadable / not yet created — no doc grounding */ }
|
|
7600
|
+
}
|
|
7601
|
+
// A task whose prompt was scored (promptTaskSelection) gets the file's
|
|
7602
|
+
// decisions, bugs and findings conditioned on it. Constraints always pass (any
|
|
7603
|
+
// severity): they are scoped rules, including agent-recorded corrections capped
|
|
7604
|
+
// at warning, not relevance guesses. No selection (a host without a prompt
|
|
7605
|
+
// hook, a legacy session, a prompt that selected nothing) keeps the unfiltered
|
|
7606
|
+
// grounding.
|
|
7607
|
+
const selection = loadTaskSelection(hookReportTaskId(root, provider, evt));
|
|
7608
|
+
const assembled = store.assembleContext(target);
|
|
7609
|
+
const selected = selection ? new Set(selection.qualifying) : null;
|
|
7610
|
+
const ctx = selected ? {
|
|
7611
|
+
...assembled,
|
|
7612
|
+
decisions: assembled.decisions.filter((d) => selected.has(d.id)),
|
|
7613
|
+
bugs: assembled.bugs.filter((b) => selected.has(b.id)),
|
|
7614
|
+
findings: assembled.findings.filter((f) => selected.has(f.id)),
|
|
7615
|
+
} : assembled;
|
|
7616
|
+
// Sibling fixes: a same-shaped function elsewhere was fixed and this copy
|
|
7617
|
+
// never was — the concrete lesson a scoped constraint cannot carry.
|
|
7618
|
+
const siblings = siblingGrounding(root, target, store.recs("symbols"), ctx.constraints.filter((c) => c.severity === "blocking" && c.scope.some((g) => pathMatchesGlob(target, g))).map((c) => ({ id: c.id, statement: c.statement })));
|
|
7619
|
+
// Remember what was delivered, so the first check the agent runs can follow
|
|
7620
|
+
// up if the function is still untouched (pipeline.ts lessonReminder).
|
|
7621
|
+
if (siblings.lessons.length && evt.session_id && pipelineEnabled()) {
|
|
7622
|
+
try {
|
|
7623
|
+
savePipelineState(evt.session_id, onLessonsDelivered(loadPipelineState(evt.session_id), siblings.lessons.map((l) => ({
|
|
7624
|
+
id: `${l.file}:${l.symbol}~${l.siblingFile}:${l.sibling}`,
|
|
7625
|
+
file: l.file,
|
|
7626
|
+
symbol: l.symbol,
|
|
7627
|
+
sibling: l.sibling,
|
|
7628
|
+
siblingFile: l.siblingFile,
|
|
7629
|
+
change: l.commits.map((c) => `${c.sha.slice(0, 8)} ${c.subject}`).join("; "),
|
|
7630
|
+
callers: [...(siblings.callers.get(l.symbol) ?? [])],
|
|
7631
|
+
hash: functionBodyHash(root, l.file, l.symbol),
|
|
7632
|
+
}))));
|
|
7633
|
+
}
|
|
7634
|
+
catch { /* the reminder is a convenience; the lesson itself was delivered */ }
|
|
7635
|
+
}
|
|
7636
|
+
// Regression Guard (edit-time grounding): what an in-force decision retired
|
|
7637
|
+
// from this file. No diff exists yet, so this is context — "don't re-add X" —
|
|
7638
|
+
// not a block; the commit-time `hunch check` does the actual gating.
|
|
7639
|
+
const retired = store.retiredForFile(target).filter((r) => r.symbols.length || r.deps.length);
|
|
7640
|
+
// Recent-task history is advisory; under a task selection it is dropped
|
|
7641
|
+
// (`hunch task list` still has it).
|
|
7642
|
+
const recentTasks = selected ? [] : taskSelectionSupplements(store.selectTasksAuto(target, buildTaskRankingQuery(root, hookReportTaskId(root, provider, evt), target, { excludeTargetDeliveries: true })), target);
|
|
7643
|
+
const hasContent = ctx.constraints.length ||
|
|
7644
|
+
ctx.decisions.length ||
|
|
7645
|
+
ctx.bugs.length ||
|
|
7646
|
+
ctx.blast_radius.length ||
|
|
7647
|
+
ctx.findings.length ||
|
|
7648
|
+
ctx.landscape?.resources.length ||
|
|
7649
|
+
ctx.landscape?.relationships.length ||
|
|
7650
|
+
retired.length ||
|
|
7651
|
+
recentTasks.length ||
|
|
7652
|
+
docGround ||
|
|
7653
|
+
siblings.text;
|
|
7654
|
+
if (!hasContent)
|
|
7655
|
+
return null;
|
|
7656
|
+
const supplements = [
|
|
7657
|
+
...(retired.length ? [{
|
|
7658
|
+
id: "retired-code",
|
|
7659
|
+
kind: "retired-code",
|
|
7660
|
+
priority: 200,
|
|
7661
|
+
text: `⚠ Deliberately RETIRED from this file — do not re-introduce without cause: ${retired.map((r) => `${[...r.symbols, ...r.deps].join(", ")} (${r.decision})`).join("; ")}.`,
|
|
7662
|
+
}] : []),
|
|
7663
|
+
...(docGround ? [{ id: "doc-grounding", kind: "doc-grounding", priority: 100, text: docGround }] : []),
|
|
7664
|
+
...recentTasks,
|
|
7665
|
+
];
|
|
7666
|
+
// A sibling lesson is the most specific thing Hunch knows about this edit:
|
|
7667
|
+
// it leads, and the ranked memory gets a smaller budget so it cannot bury it.
|
|
7668
|
+
const envelope = buildDeliveryEnvelope(siblings.text ? { ...ctx, budget_tokens: Math.min(ctx.budget_tokens, SIBLING_MEMORY_BUDGET_TOKENS) } : ctx, {
|
|
7669
|
+
profile: "builder",
|
|
7670
|
+
root,
|
|
7671
|
+
symbols: store.recs("symbols"),
|
|
7672
|
+
components: store.recs("components"),
|
|
7673
|
+
decisionCorpus: store.recs("decisions"),
|
|
7674
|
+
supplements,
|
|
7675
|
+
});
|
|
7676
|
+
// Outside the envelope's budget on purpose: the lesson is a code change, not a
|
|
7677
|
+
// one-line supplement, and memory records must not crowd it out.
|
|
7678
|
+
const text = [siblings.text, envelope.text.trim()].filter(Boolean).join("\n\n");
|
|
7679
|
+
// Identical grounding already shown this session → one-line delta instead of
|
|
7680
|
+
// the full 10-16KB block. Any record change re-sends the full text; the
|
|
7681
|
+
// strict-gate deny path above never routes through this (dec_244397d920).
|
|
7682
|
+
// Delivery receipts (dec_925f4bcaad): the ledger of what actually reached
|
|
7683
|
+
// an agent. A full injection is a serve; a delta one-liner attests the
|
|
7684
|
+
// earlier serve is still standing. Never throws, never blocks.
|
|
7685
|
+
const receipts = (event) => recordServed(root, [
|
|
7686
|
+
...envelope.delivered.map((item) => ({
|
|
7687
|
+
event,
|
|
7688
|
+
kind: item.kind,
|
|
7689
|
+
record_id: item.record_id,
|
|
7690
|
+
target,
|
|
7691
|
+
session_id: evt.session_id,
|
|
7692
|
+
rank: item.rank,
|
|
7693
|
+
delivery_reason: item.delivery_reason,
|
|
7694
|
+
provenance_status: item.provenance_status,
|
|
7695
|
+
token_cost: item.token_cost,
|
|
7696
|
+
delivery_profile: envelope.profile,
|
|
7697
|
+
ranking_policy: envelope.ranking_policy,
|
|
7698
|
+
})),
|
|
7699
|
+
// Delivered task lines are receipts too: they feed access-based recency.
|
|
7700
|
+
...envelope.supplements.filter((s) => s.kind === "recent-task" && s.delivered).map((s) => ({
|
|
7701
|
+
event, kind: "tasks", record_id: s.id, target, session_id: evt.session_id,
|
|
7702
|
+
rank: s.rank, delivery_reason: "supplemental", token_cost: s.token_cost,
|
|
7703
|
+
delivery_profile: envelope.profile, ranking_policy: envelope.ranking_policy,
|
|
7704
|
+
})),
|
|
7705
|
+
]);
|
|
7706
|
+
const reportTaskId = hookReportTaskId(root, provider, evt);
|
|
7707
|
+
// A new authoritative prompt gets its own full delivery. An earlier
|
|
7708
|
+
// prompt's session-level delta cannot establish this task's receipt.
|
|
7709
|
+
// A subagent reports to the prompt's task but starts with FRESH context:
|
|
7710
|
+
// it never saw that grounding, so its dedup is scoped by its own agent
|
|
7711
|
+
// identity (hashed — the raw agent_id is never retained in the key).
|
|
7712
|
+
const agentKey = evt.agent_id ? `:${reportHash(evt.agent_id).slice(7, 19)}` : "";
|
|
7713
|
+
// Dedup on the envelope's stable IDENTITY projection, never on the
|
|
7714
|
+
// rendered block: serving the full text writes delivery receipts, and the
|
|
7715
|
+
// next call's task ranking reads them back and moves the wording ("today"
|
|
7716
|
+
// → "delivered today"), so hashing the presentation made this grounding
|
|
7717
|
+
// self-invalidating and re-sent the full block for unchanged records.
|
|
7718
|
+
if (injectionMode(evt.session_id, `pre:${target}${reportTaskId ? `:${reportTaskId}` : ""}${agentKey}`, text, deliveryDedupeInput(envelope, supplements) + (siblings.identity ? `\u0000sibling-fix\u0000${siblings.identity}` : "")) === "delta") {
|
|
7719
|
+
receipts("refreshed");
|
|
7720
|
+
return {
|
|
7721
|
+
mode: "delta",
|
|
7722
|
+
text: `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)${siblings.identity ? ", sibling-fix lesson" : ""} shown earlier — still current; hunch_why("${target}") to re-expand).`,
|
|
7723
|
+
};
|
|
7724
|
+
}
|
|
7725
|
+
receipts("served");
|
|
7726
|
+
let reportNotice = "";
|
|
7727
|
+
let recalled = null;
|
|
7728
|
+
if (reportTaskId) {
|
|
7729
|
+
try {
|
|
7730
|
+
const snapshots = snapshotDeliveredRecords(store, envelope);
|
|
7731
|
+
// The first time a lesson reaches this prompt's task, tell the USER in one
|
|
7732
|
+
// line (systemMessage); repeats of the same revision stay silent.
|
|
7733
|
+
recalled = reportPresentationEnabled(root) ? renderRecalledLine(unseenLessons(root, reportTaskId, snapshots)) : null;
|
|
7734
|
+
const occurrence = recordTaskDelivery(root, reportTaskId, envelope, snapshots, undefined, target);
|
|
7735
|
+
reportNotice = `\n\nHunch task ${reportTaskId} · delivery ${occurrence}. Inspect exact application references with hunch_report(task_id).`;
|
|
7736
|
+
}
|
|
7737
|
+
catch {
|
|
7738
|
+
reportNotice = "\n\nTask report observation unavailable; this delivery's task contribution remains unverified.";
|
|
7739
|
+
recalled = null;
|
|
7740
|
+
}
|
|
7741
|
+
}
|
|
7742
|
+
return { mode: "full", text: text + reportNotice, recalled };
|
|
7743
|
+
}
|
|
7422
7744
|
/** The in-repo files a pre-edit event would change, each with the lines the edit
|
|
7423
7745
|
* ADDS to that file. A Codex apply_patch yields one entry per touched path (a
|
|
7424
7746
|
* Move-to destination is its own entry, carrying the section's added lines);
|