@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.
Files changed (48) hide show
  1. package/README.md +5 -1
  2. package/dist/cli/index.js +489 -167
  3. package/dist/constitution/experiment.d.ts +3 -3
  4. package/dist/constitution/g3.d.ts +1 -1
  5. package/dist/core/delivery.d.ts +12 -0
  6. package/dist/core/delivery.js +4 -4
  7. package/dist/core/footprint.d.ts +15 -0
  8. package/dist/core/footprint.js +167 -0
  9. package/dist/core/groundingLag.d.ts +15 -0
  10. package/dist/core/groundingLag.js +27 -0
  11. package/dist/core/hookText.d.ts +4 -0
  12. package/dist/core/hookText.js +8 -0
  13. package/dist/core/hookcache.d.ts +22 -0
  14. package/dist/core/hookcache.js +53 -2
  15. package/dist/core/pipeline.d.ts +28 -0
  16. package/dist/core/pipeline.js +50 -0
  17. package/dist/core/publication.js +17 -3
  18. package/dist/core/shellwrites.d.ts +7 -0
  19. package/dist/core/shellwrites.js +131 -0
  20. package/dist/core/siblingfix.d.ts +124 -0
  21. package/dist/core/siblingfix.js +814 -0
  22. package/dist/core/taskReportHook.d.ts +1 -1
  23. package/dist/core/taskReportHook.js +20 -4
  24. package/dist/core/taskSelection.d.ts +67 -0
  25. package/dist/core/taskSelection.js +196 -0
  26. package/dist/extractors/git.d.ts +31 -0
  27. package/dist/extractors/git.js +180 -1
  28. package/dist/extractors/nativeTreeSitter.d.ts +2 -1
  29. package/dist/extractors/nativeTreeSitter.js +128 -30
  30. package/dist/integrations/claudemd.d.ts +20 -2
  31. package/dist/integrations/claudemd.js +79 -53
  32. package/dist/integrations/providers.d.ts +9 -5
  33. package/dist/integrations/providers.js +36 -23
  34. package/dist/integrations/scaffold.js +2 -1
  35. package/dist/integrations/team.d.ts +24 -4
  36. package/dist/integrations/team.js +154 -16
  37. package/dist/integrations/worktree.d.ts +3 -2
  38. package/dist/integrations/worktree.js +7 -4
  39. package/dist/mcp/server.d.ts +489 -0
  40. package/dist/mcp/server.js +208 -62
  41. package/dist/mcp/taskReportTools.js +12 -9
  42. package/dist/mcp/toolset.d.ts +11 -1
  43. package/dist/mcp/toolset.js +28 -8
  44. package/dist/store/hunchStore.d.ts +25 -1
  45. package/dist/store/hunchStore.js +146 -21
  46. package/dist/store/jsonStore.js +25 -4
  47. package/package.json +1 -1
  48. 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
- if (teamAdvertised && !readTeamConfig(root)) {
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 (teamAdvertised && (store.mode !== "shared"
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 = gitCommonDir(root);
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: [...stateGrounding, ...(asOf ? [] : taskSelectionSupplements(store.selectTasksAuto(target, buildTaskRankingQuery(root, opts.task ?? null, target)), target))],
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
- if ((evt.hook_event_name === "PostToolUse" || evt.hook_event_name === "PostToolUseFailure") && evt.session_id && pipelineEnabled()) {
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
- if (checkpoint.reminder)
4889
- emitContext(provider, evt.hook_event_name, checkpoint.reminder);
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
- let text = isCorrection ? `${HOOK_REMINDER}\n\n${CORRECTION_NUDGE}` : HOOK_REMINDER;
4927
- // Payloads that must NEVER be deduped away. The dedup key hashes CONTENT, and
4928
- // this content is assembled from constants — so two back-to-back corrections
4929
- // produce byte-identical text and the second (usually the escalating one) was
4930
- // silently swallowed, never becoming an enforced rule. Same for the unverified
4931
- // nag, which is documented as the one nag that must repeat but rode the same
4932
- // deduped payload and so fired once per streak.
4933
- let mustDeliver = isCorrection;
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
- text += `\n\n${report}`;
4939
- mustDeliver = true;
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
- text += `\n\n${unverifiedNag(st)}`;
4961
- mustDeliver = true;
5130
+ parts.push(unverifiedNag(st));
4962
5131
  }
4963
5132
  }
4964
- // Once per session is enough for the bare availability reminder — repeating it
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", text);
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 means everything injected so far was just summarized
5085
- // away — the dedup map must forget it delivered anything, or the rest of
5086
- // the session gets delta one-liners against grounding that is gone.
5087
- if (evt.source === "compact")
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 { recent, roadmap, pendingReview } = nowData(decisions, 3);
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
- // Decision-grounding for PROSE (doc≠graph): a markdown target that declares
5254
- // <!-- hunch:topic … --> anchors gets each topic's CURRENT decision — the
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
- const supplements = [
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
- : "✓ grounding docs are fresh.");
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);