@davesheffer/hunch 1.41.5 → 1.42.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 (47) hide show
  1. package/README.md +1 -1
  2. package/dist/cli/index.js +402 -154
  3. package/dist/constitution/experiment.d.ts +3 -3
  4. package/dist/constitution/g3.d.ts +1 -1
  5. package/dist/core/docanchors.js +2 -1
  6. package/dist/core/eol.d.ts +10 -0
  7. package/dist/core/eol.js +23 -0
  8. package/dist/core/footprint.d.ts +15 -0
  9. package/dist/core/footprint.js +167 -0
  10. package/dist/core/groundingLag.d.ts +15 -0
  11. package/dist/core/groundingLag.js +27 -0
  12. package/dist/core/groundingMerge.js +8 -6
  13. package/dist/core/hookText.d.ts +4 -0
  14. package/dist/core/hookText.js +8 -0
  15. package/dist/core/pipeline.d.ts +28 -0
  16. package/dist/core/pipeline.js +50 -0
  17. package/dist/core/shellwrites.d.ts +7 -0
  18. package/dist/core/shellwrites.js +131 -0
  19. package/dist/core/siblingfix.d.ts +124 -0
  20. package/dist/core/siblingfix.js +814 -0
  21. package/dist/core/taskReportHook.d.ts +1 -1
  22. package/dist/core/taskReportHook.js +19 -3
  23. package/dist/extractors/git.d.ts +31 -0
  24. package/dist/extractors/git.js +180 -1
  25. package/dist/extractors/k8sManifest.js +15 -42
  26. package/dist/extractors/nativeTreeSitter.d.ts +2 -1
  27. package/dist/extractors/nativeTreeSitter.js +128 -30
  28. package/dist/integrations/claudemd.d.ts +20 -2
  29. package/dist/integrations/claudemd.js +79 -53
  30. package/dist/integrations/gitignore.js +2 -1
  31. package/dist/integrations/hooks.js +5 -4
  32. package/dist/integrations/providers.d.ts +9 -5
  33. package/dist/integrations/providers.js +34 -22
  34. package/dist/integrations/team.d.ts +24 -4
  35. package/dist/integrations/team.js +154 -16
  36. package/dist/integrations/worktree.d.ts +3 -2
  37. package/dist/integrations/worktree.js +7 -4
  38. package/dist/mcp/server.d.ts +489 -0
  39. package/dist/mcp/server.js +208 -62
  40. package/dist/mcp/taskReportTools.js +12 -9
  41. package/dist/mcp/toolset.d.ts +11 -1
  42. package/dist/mcp/toolset.js +28 -8
  43. package/dist/store/hunchStore.d.ts +25 -1
  44. package/dist/store/hunchStore.js +146 -21
  45. package/dist/store/jsonStore.js +25 -4
  46. package/package.json +1 -1
  47. 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";
@@ -98,7 +100,7 @@ import { closeHookTask, hookReportTaskId, nativeHookCwd, settleHookSession, star
98
100
  import { persistTaskRecord } from "../core/taskRecord.js";
99
101
  import { recordHookObservation } from "../core/hookObservations.js";
100
102
  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";
103
+ 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
104
  import { draftDuplicateOf, isAcceptedDuplicateAnchor } from "../core/dupdetect.js";
103
105
  import { planAutoReview, planMutations } from "../core/autoreview.js";
104
106
  import { loadGoldenSet, evaluateRetrieval, evaluateTraversalLift } from "../eval/harness.js";
@@ -116,6 +118,9 @@ import { buildMadrManifest, writeMadrManifest, refreshMadrCorpus } from "../inte
116
118
  import { pendingEscalations, policyEscalations, commitRepairEscalations, actionableEscalations, escalationHeadline } from "../core/escalations.js";
117
119
  import { premiseEscalations } from "../core/premises.js";
118
120
  import { parseDocAnchors, renderDocGrounding } from "../core/docanchors.js";
121
+ import { pathMatchesGlob } from "../core/glob.js";
122
+ import { SIBLING_HEADING, functionBodyHash, siblingGrounding } from "../core/siblingfix.js";
123
+ import { refreshShellBaseline, shellWrittenFiles } from "../core/shellwrites.js";
119
124
  import { compareCandidates } from "../core/compare.js";
120
125
  import { compareCodeUnits } from "../core/canonicalOrder.js";
121
126
  import { MAX_LANDSCAPE_REFRESH_REVISIONS, planLandscapeAdoption, } from "../core/landscapeAdoption.js";
@@ -246,7 +251,8 @@ function openTeamStore(root, opts = {}) {
246
251
  const explicitOverlay = !!process.env.HUNCH_PRIVATE_DIR?.trim();
247
252
  const teamFile = join(hunchPaths(root).hunch, "team.json");
248
253
  const teamAdvertised = !explicitOverlay && existsSync(teamFile);
249
- if (teamAdvertised && !readTeamConfig(root)) {
254
+ const advertisedTeam = teamAdvertised ? readTeamConfig(root) : null;
255
+ if (teamAdvertised && !advertisedTeam) {
250
256
  throw new Error(".hunch/team.json is invalid or unsafe; refusing to fall back to public memory");
251
257
  }
252
258
  const teamWired = ensureTeamOverlay(root);
@@ -255,12 +261,16 @@ function openTeamStore(root, opts = {}) {
255
261
  const overlayWarning = store.overlayResolutionWarning(explicitOverlay && existsSync(teamFile));
256
262
  if (overlayWarning)
257
263
  console.error(`[hunch] ⚠ ${overlayWarning}`);
258
- if (teamAdvertised && (store.mode !== "shared"
264
+ if (advertisedTeam && (store.mode !== "shared"
259
265
  || !store.privateDir
260
266
  || !existsSync(store.privateDir)
267
+ || !teamWiringConsented(root, advertisedTeam, store.privateDir)
261
268
  || !overlayMatchesTeamRemote(root, dirname(store.privateDir)))) {
269
+ const consented = !!store.privateDir && teamWiringConsented(root, advertisedTeam, store.privateDir);
262
270
  store.close();
263
271
  openStore = null;
272
+ if (!consented && !isTeamStoreTrusted(root, advertisedTeam))
273
+ throw new Error(untrustedTeamStoreMessage(advertisedTeam));
264
274
  throw new Error("the advertised team memory store is unavailable or tracks a different remote; refusing to read or write another graph");
265
275
  }
266
276
  // Short-lived CLI processes need the same live edge as the long-lived MCP
@@ -487,7 +497,7 @@ program
487
497
  // Worktree-seamless: register any configured overlay at the git common dir so EVERY
488
498
  // worktree of this repo auto-discovers it (also backfills pre-0.32 single-worktree setups),
489
499
  // 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")) {
500
+ if (ensureSharedOverlayPointer(root, store.privateDir, store.privateAutoCommit, store.mode === "shared" ? "shared" : "private", true)) {
491
501
  console.log(` ✓ private overlay registered at the git common dir — shared by every worktree of this repo`);
492
502
  }
493
503
  for (const line of sharedHooksNote(root, installs))
@@ -1014,15 +1024,18 @@ function beginFreshOverlaySetup(root, dest, existingLocal, includeHook) {
1014
1024
  const localFile = join(hunchPaths(root).hunch, "local.json");
1015
1025
  const teamFile = join(hunchPaths(root).hunch, "team.json");
1016
1026
  const codeGitignore = join(root, ".gitignore");
1017
- const commonDir = gitCommonDir(root);
1027
+ const commonDir = checkoutCommonDir(root) || separateGitDirCandidate(root)?.gitDir || "";
1018
1028
  const sharedPointer = commonDir ? join(commonDir, "hunch", "local.json") : "";
1029
+ // Registering the shared pointer can also claim a separate git dir (claimSeparateGitDir),
1030
+ // writing its checkout back-link beside the pointer; a failed setup must not leave it.
1031
+ const backLink = commonDir ? separateGitDirLink(commonDir) : "";
1019
1032
  const configuredHooks = includeHook ? hooksDir(root) : "";
1020
1033
  const hookDir = configuredHooks ? (isAbsolute(configuredHooks) ? configuredHooks : join(root, configuredHooks)) : "";
1021
1034
  // Setup installs post-commit AND post-merge together, so both must be in the
1022
1035
  // ledger — restoring one while leaving the other pointing at a just-deleted
1023
1036
  // overlay is not a rollback.
1024
1037
  const hookFiles = hookDir ? [join(hookDir, "post-commit"), join(hookDir, "post-merge")] : [];
1025
- const paths = [localFile, codeGitignore, teamFile, ...(sharedPointer ? [sharedPointer] : []), ...hookFiles];
1038
+ const paths = [localFile, codeGitignore, teamFile, ...(sharedPointer ? [sharedPointer, backLink] : []), ...hookFiles];
1026
1039
  const snapshots = new Map(paths.map((path) => [path, setupPathSnapshot(path)]));
1027
1040
  const parentExisted = new Map([
1028
1041
  [dirname(localFile), existsSync(dirname(localFile))],
@@ -1041,7 +1054,7 @@ function beginFreshOverlaySetup(root, dest, existingLocal, includeHook) {
1041
1054
  markLocalWrite: () => mark(localFile),
1042
1055
  markGitignoreWrite: () => mark(codeGitignore),
1043
1056
  markTeamWrite: () => mark(teamFile),
1044
- markSharedPointerWrite: () => mark(sharedPointer),
1057
+ markSharedPointerWrite: () => { mark(backLink); mark(sharedPointer); },
1045
1058
  markHookWrite: () => hookFiles.forEach(mark),
1046
1059
  // Migration is a one-way ownership handoff. Once public records have been
1047
1060
  // durably copied into this clone, a later setup failure may restore routing
@@ -1070,6 +1083,74 @@ function beginFreshOverlaySetup(root, dest, existingLocal, includeHook) {
1070
1083
  },
1071
1084
  };
1072
1085
  }
1086
+ /** `hunch shared --trust`: the one explicit step that lets this machine auto-wire
1087
+ * the store a committed team.json advertises. Shows the exact URL being trusted. */
1088
+ function trustAdvertisedTeamStore(root, dir, opts) {
1089
+ if (dir || opts.repo || opts.sync || opts.migrate) {
1090
+ return fail("--trust takes no directory and cannot be combined with --repo, --sync, or --migrate");
1091
+ }
1092
+ if (process.env.HUNCH_PRIVATE_DIR?.trim()) {
1093
+ return fail("HUNCH_PRIVATE_DIR is set, so .hunch/team.json is bypassed; unset it before trusting the team store");
1094
+ }
1095
+ const team = readTeamConfig(root);
1096
+ if (!team) {
1097
+ return fail(existsSync(join(hunchPaths(root).hunch, "team.json"))
1098
+ ? ".hunch/team.json is invalid or unsafe; refusing to trust it"
1099
+ : "no .hunch/team.json in this repository; nothing to trust");
1100
+ }
1101
+ // Consent persists only after a successful connect: openTeamStore reads the trust
1102
+ // entry, so it is written first and restored to the exact prior state on failure.
1103
+ // The connect can also write routing BEFORE it fails (this checkout's pointer, the
1104
+ // registered common-dir pointer, a separate git dir's back-link), and a registered
1105
+ // pointer is itself consent, so those are snapshotted and restored too — otherwise
1106
+ // "trust was NOT recorded" would be false in substance.
1107
+ const priorTrust = isTeamStoreTrusted(root, team);
1108
+ const commonDir = checkoutCommonDir(root) || separateGitDirCandidate(root)?.gitDir || "";
1109
+ const routing = [
1110
+ join(hunchPaths(root).hunch, "local.json"),
1111
+ ...(commonDir ? [join(commonDir, "hunch", "local.json"), separateGitDirLink(commonDir)] : []),
1112
+ ];
1113
+ const routingBefore = routing.map((path) => [path, setupPathSnapshot(path)]);
1114
+ const undoTrust = trustTeamStore(root, team);
1115
+ let opened;
1116
+ try {
1117
+ opened = openTeamStore(root);
1118
+ }
1119
+ catch (error) {
1120
+ let reverted = true;
1121
+ for (const [path, before] of routingBefore) {
1122
+ try {
1123
+ restoreSetupPath(path, before);
1124
+ }
1125
+ catch {
1126
+ reverted = false;
1127
+ }
1128
+ }
1129
+ try {
1130
+ undoTrust();
1131
+ }
1132
+ catch {
1133
+ reverted = false;
1134
+ }
1135
+ // The undo only removes THIS invocation's own entry: a trust entry this checkout
1136
+ // already had stays, and a concurrent process may have recorded (and kept) its own.
1137
+ const stillConsented = reverted && isTeamStoreTrusted(root, team);
1138
+ return fail(`could not connect to the team store: ${error instanceof Error ? error.message : String(error)}\n` +
1139
+ (stillConsented
1140
+ ? priorTrust
1141
+ ? ` · this checkout's earlier trust entry is kept (${teamTrustFile()})\n`
1142
+ : ` · trust for this checkout was recorded by another process meanwhile (${teamTrustFile()})\n`
1143
+ : reverted
1144
+ ? ` · trust was NOT recorded for this checkout (${teamTrustFile()} and its store pointers are unchanged)\n`
1145
+ : ` · trust could not be fully rolled back: remove this checkout's entry from ${teamTrustFile()} and its .hunch/local.json store pointer\n`) +
1146
+ " · retry: `hunch shared --trust` once the store is reachable");
1147
+ }
1148
+ console.log(`✓ trusted the team memory store on this machine → ${team.shared_repo}`);
1149
+ const { store, teamWired } = opened;
1150
+ console.log(teamWired
1151
+ ? ` ✓ connected to the team's shared memory store → ${teamWired}`
1152
+ : ` · already connected → ${store.privateDir}`);
1153
+ }
1073
1154
  function configureOverlay(dir, opts, mode) {
1074
1155
  let freshSetup = null;
1075
1156
  let setupComplete = false;
@@ -1077,6 +1158,8 @@ function configureOverlay(dir, opts, mode) {
1077
1158
  const root = findRoot();
1078
1159
  const paths = hunchPaths(root);
1079
1160
  const commandName = mode === "private" ? "private" : "shared";
1161
+ if (opts.trust)
1162
+ return trustAdvertisedTeamStore(root, dir, opts);
1080
1163
  // A repository URL reaches Git before the overlay is trusted in BOTH modes.
1081
1164
  // Keep private split stores private by omitting team.json, not by weakening the
1082
1165
  // clone transport gate: credentials stay in normal Git helpers and every setup
@@ -1261,6 +1344,10 @@ function configureOverlay(dir, opts, mode) {
1261
1344
  return fail("could not select one canonical branch for the shared memory repository");
1262
1345
  freshSetup?.markTeamWrite();
1263
1346
  writeTeamConfig(root, { shared_repo: opts.repo, shared_ref: sharedRef });
1347
+ // The author typed this URL, which is the consent teammates give with --trust.
1348
+ const published = readTeamConfig(root);
1349
+ if (published)
1350
+ trustTeamStore(root, published);
1264
1351
  // Bind the graph epoch immediately, in clone-local Git metadata. Waiting
1265
1352
  // until the next command would let a coherent team.json+origin repoint
1266
1353
  // relabel this clone after setup but before its first normal open.
@@ -1276,9 +1363,12 @@ function configureOverlay(dir, opts, mode) {
1276
1363
  // only an absolute path survives the move. Lives under .git/ (never tracked; nothing to ignore).
1277
1364
  let worktreeNote = "";
1278
1365
  freshSetup?.markSharedPointerWrite();
1279
- if (ensureSharedOverlayPointer(root, hunchDir, !!opts.autoCommit, mode)) {
1366
+ if (ensureSharedOverlayPointer(root, hunchDir, !!opts.autoCommit, mode, true)) {
1280
1367
  worktreeNote = " ✓ registered in the git common dir — shared by every worktree of this repo, on any branch\n";
1281
1368
  }
1369
+ else {
1370
+ 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";
1371
+ }
1282
1372
  // 4) route post-commit synthesis to the overlay (local hook, never committed)
1283
1373
  let hookNote = "";
1284
1374
  if (opts.hook && isGitRepo(root)) {
@@ -1393,6 +1483,7 @@ program
1393
1483
  .option("--no-auto-commit", "DON'T auto commit+push the overlay after each capture (default: ON — fully automated two-way sync)")
1394
1484
  .option("--sync", "flush the configured overlay store now (git add+commit+push)")
1395
1485
  .option("--migrate", "ONE-TIME: move this repo's EXISTING public .hunch memory into the shared overlay, then make the public repo code-only")
1486
+ .option("--trust", "trust the shared store advertised in the committed .hunch/team.json on this machine, then connect to it")
1396
1487
  .action((dir, opts) => configureOverlay(dir, opts, "shared"));
1397
1488
  // ---- worktree (one-command worktree wired into Hunch) ----------------------
1398
1489
  program
@@ -1427,7 +1518,7 @@ program
1427
1518
  if (!opts.share) {
1428
1519
  shareNote = ` · --no-share — the worktree will NOT see private memory`;
1429
1520
  }
1430
- else if (overlay && ensureSharedOverlayPointer(root, overlay, autoCommit, overlayMode)) {
1521
+ else if (overlay && ensureSharedOverlayPointer(root, overlay, autoCommit, overlayMode, true)) {
1431
1522
  shareNote = ` ✓ memory shared via the git common dir — this worktree sees the same decisions / bugs / constraints`;
1432
1523
  }
1433
1524
  else if (overlay) {
@@ -4452,6 +4543,7 @@ program
4452
4543
  .option("--profile <profile>", "delivery role: builder, reviewer, or architect", "builder")
4453
4544
  .option("--as-of <ref>", "time-travel: assemble the slice as it stood at a commit/tag/branch")
4454
4545
  .option("--task <id>", "retain the exact context delivery for this task's contribution report")
4546
+ .option("--include <kinds>", "opt-in extras (comma list): recent-tasks,project-dna — omitted by default to keep the brief small")
4455
4547
  .action(async (target, opts) => {
4456
4548
  if (!DELIVERY_PROFILES.includes(opts.profile)) {
4457
4549
  return fail(`--profile must be one of: ${DELIVERY_PROFILES.join(", ")}`);
@@ -4477,6 +4569,27 @@ program
4477
4569
  // receipts matching the target — the same slice and render as hunch_context.
4478
4570
  const slice = asOf ? null : store.stateSlice(target);
4479
4571
  const stateGrounding = slice ? stateSupplements(slice, target) : [];
4572
+ // Both extras below cost brief tokens (and, for DNA/recent-tasks selection, extra
4573
+ // latency) — opt-in only via --include, omitted from the default brief.
4574
+ // Accept the MCP spelling (recent_tasks) too, so a name copied from the tool schema works.
4575
+ const include = opts.include ? opts.include.split(",").map((s) => s.trim().replace(/_/g, "-")).filter(Boolean) : [];
4576
+ const unknownInclude = include.filter((k) => k !== "recent-tasks" && k !== "project-dna");
4577
+ if (unknownInclude.length)
4578
+ return fail(`--include accepts recent-tasks, project-dna (got: ${unknownInclude.join(", ")})`);
4579
+ const recentTasks = (asOf || !include.includes("recent-tasks"))
4580
+ ? []
4581
+ : taskSelectionSupplements(store.selectTasksAuto(target, buildTaskRankingQuery(root, opts.task ?? null, target)), target);
4582
+ let dnaSupplement = null;
4583
+ if (!asOf && include.includes("project-dna")) {
4584
+ try {
4585
+ dnaSupplement = projectDnaDeliverySupplement(discoverProjectDna(root, "HEAD"));
4586
+ }
4587
+ catch {
4588
+ // Same graceful behavior as hunch_context: a missing/unreadable Git checkout
4589
+ // must not break the CLI brief; the dedicated `hunch project-dna` command
4590
+ // reports the exact derivation error when a caller needs diagnostics.
4591
+ }
4592
+ }
4480
4593
  if (empty && !asOf && opts.task) {
4481
4594
  const resolved = store.rankedSearch(target, 8).map(hit => ({ hit, record: store.resolve(hit.ref)?.record }));
4482
4595
  ctx = { ...ctx,
@@ -4512,7 +4625,7 @@ program
4512
4625
  decisionCorpus: store.recs("decisions"),
4513
4626
  historical: !!asOf,
4514
4627
  profile: opts.profile,
4515
- supplements: [...stateGrounding, ...(asOf ? [] : taskSelectionSupplements(store.selectTasksAuto(target, buildTaskRankingQuery(root, opts.task ?? null, target)), target))],
4628
+ supplements: [...(dnaSupplement ? [dnaSupplement] : []), ...stateGrounding, ...recentTasks],
4516
4629
  });
4517
4630
  process.stdout.write(envelope.text);
4518
4631
  if (opts.task) {
@@ -4856,10 +4969,39 @@ program
4856
4969
  // Verification pipeline (delivery enforced, not hoped for — see core/pipeline.ts).
4857
4970
  // PostToolUse records facts; Stop gates on them. Both are pipeline-only events,
4858
4971
  // handled before the grounding dispatch below.
4859
- if ((evt.hook_event_name === "PostToolUse" || evt.hook_event_name === "PostToolUseFailure") && evt.session_id && pipelineEnabled()) {
4972
+ const postTool = evt.hook_event_name === "PostToolUse" || evt.hook_event_name === "PostToolUseFailure" ? evt.hook_event_name : null;
4973
+ // A shell command can edit files without the edit tools ever firing: ground
4974
+ // what it wrote now. Every other tool call just moves the baseline.
4975
+ let shellGround = "";
4976
+ if (postTool) {
4977
+ // Fail-open on its own: a grounding error must not cost the pipeline
4978
+ // bookkeeping below (a missed check would wrongly hold the Stop gate).
4979
+ try {
4980
+ if (evt.tool_name === "Bash" || evt.tool_name === "PowerShell") {
4981
+ const written = shellWrittenFiles(root, evt.session_id, evt.agent_id);
4982
+ if (written.length) {
4983
+ const opened = openTeamStore(root, { requireFreshTeamMemory: firmness === "strict" });
4984
+ store = opened.store;
4985
+ // Same rule as the pre-edit path: strict never grounds from stale team rules.
4986
+ const stale = firmness === "strict" && opened.teamPullStatus
4987
+ && opened.teamPullStatus !== "updated" && opened.teamPullStatus !== "current";
4988
+ if (!stale)
4989
+ shellGround = shellWriteGrounding(root, store, provider, evt, written);
4990
+ }
4991
+ }
4992
+ else {
4993
+ refreshShellBaseline(root, evt.session_id, evt.agent_id);
4994
+ }
4995
+ }
4996
+ catch {
4997
+ shellGround = "";
4998
+ }
4999
+ }
5000
+ if (postTool && evt.session_id && pipelineEnabled()) {
4860
5001
  let st = loadPipelineState(evt.session_id);
4861
5002
  const before = st;
4862
5003
  let activity = null;
5004
+ let lessonNote = "";
4863
5005
  if (/^(Edit|Write|MultiEdit)$/.test(evt.tool_name ?? "")) {
4864
5006
  // A Codex apply_patch touches every file it lists (and each Move-to
4865
5007
  // destination); the Stop gate must see all of them, not only the first.
@@ -4876,6 +5018,13 @@ program
4876
5018
  const command = String(evt.tool_input?.command ?? "");
4877
5019
  st = onCommand(st, command, evt.tool_outcome);
4878
5020
  activity = { kind: "command", command };
5021
+ // A command that both wrote the file and ran a check just received the
5022
+ // lesson itself; the follow-up waits for the next check.
5023
+ if (!shellGround) {
5024
+ const followUp = lessonReminder(st, command, (l) => functionBodyHash(root, l.file, l.symbol));
5025
+ st = followUp.state;
5026
+ lessonNote = followUp.reminder;
5027
+ }
4879
5028
  }
4880
5029
  else if (evt.tool_name === "Skill") {
4881
5030
  st = onSkill(st, String(evt.tool_input?.skill ?? ""));
@@ -4885,11 +5034,19 @@ program
4885
5034
  const checkpoint = proofCheckpoint(before, st, activity);
4886
5035
  st = checkpoint.state;
4887
5036
  savePipelineState(evt.session_id, st);
4888
- if (checkpoint.reminder)
4889
- emitContext(provider, evt.hook_event_name, checkpoint.reminder);
5037
+ const post = [shellGround, lessonNote, checkpoint.reminder].filter(Boolean).join("\n\n");
5038
+ if (post)
5039
+ emitContext(provider, postTool, post);
4890
5040
  return;
4891
5041
  }
4892
5042
  savePipelineState(evt.session_id, st);
5043
+ if (shellGround)
5044
+ emitContext(provider, postTool, shellGround);
5045
+ return;
5046
+ }
5047
+ if (postTool) {
5048
+ if (shellGround)
5049
+ emitContext(provider, postTool, shellGround);
4893
5050
  return;
4894
5051
  }
4895
5052
  if (evt.hook_event_name === "Stop") {
@@ -4922,22 +5079,29 @@ program
4922
5079
  // When the prompt reads like a correction ("no / that's wrong / never X"),
4923
5080
  // nudge the agent to PERSIST it as an enforced constraint (Never Twice) —
4924
5081
  // not just obey it this once and forget it next session.
5082
+ // Shell writes are measured from the start of each prompt.
5083
+ refreshShellBaseline(root, evt.session_id);
4925
5084
  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;
5085
+ // Once per session is enough for the bare availability reminder — repeating it
5086
+ // every prompt burns context for zero information (dec_244397d920). It is deduped
5087
+ // on its OWN key: hashed together with the per-prompt task report (a new ID each
5088
+ // prompt) it was re-sent on every prompt. A correction is never deduped: two
5089
+ // back-to-back corrections are byte-identical, and the second (usually the
5090
+ // escalating one) must still become an enforced rule. Neither is the unverified
5091
+ // nag below, the one nag that must repeat.
5092
+ const parts = [];
5093
+ // Always stamp the key, so a correction that delivers the reminder counts as
5094
+ // this session's one delivery.
5095
+ const reminderDue = injectionMode(evt.session_id, "prompt-reminder", HOOK_REMINDER) === "full";
5096
+ if (isCorrection)
5097
+ parts.push(`${HOOK_REMINDER}\n\n${CORRECTION_NUDGE}`);
5098
+ else if (reminderDue)
5099
+ parts.push(HOOK_REMINDER);
4934
5100
  // Reporting failure must not suppress the existing correction/policy reminder.
4935
5101
  try {
4936
5102
  const report = startHookReport(root, provider, evt);
4937
- if (report) {
4938
- text += `\n\n${report}`;
4939
- mustDeliver = true;
4940
- }
5103
+ if (report)
5104
+ parts.push(report);
4941
5105
  }
4942
5106
  catch { /* passive reporting remains fail-open */ }
4943
5107
  // A task an earlier prompt of this session left open (interrupted before
@@ -4957,16 +5121,12 @@ program
4957
5121
  const st = onPrompt(loadPipelineState(evt.session_id));
4958
5122
  savePipelineState(evt.session_id, st);
4959
5123
  if (!st.verifyAfterEdit || st.obligations.some((item) => item.status !== "satisfied")) {
4960
- text += `\n\n${unverifiedNag(st)}`;
4961
- mustDeliver = true;
5124
+ parts.push(unverifiedNag(st));
4962
5125
  }
4963
5126
  }
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")
5127
+ if (!parts.length)
4968
5128
  return;
4969
- emitContext(provider, "UserPromptSubmit", text);
5129
+ emitContext(provider, "UserPromptSubmit", parts.join("\n\n"));
4970
5130
  return;
4971
5131
  }
4972
5132
  if (evt.hook_event_name === "PreCompact") {
@@ -4991,6 +5151,12 @@ program
4991
5151
  // would cross worktrees; stay silent instead of guessing which side is right.
4992
5152
  if ((provider === "claude" || provider === "codex") && evt.cwd !== undefined && !routedCwd)
4993
5153
  return;
5154
+ // The prompt's baseline is the session's, not this agent's: without its
5155
+ // own, a first tool call that is a shell write would go ungrounded.
5156
+ // Without an agent id the key would be the SESSION's baseline, and
5157
+ // refreshing it here could swallow a parent write not yet grounded.
5158
+ if (evt.agent_id)
5159
+ refreshShellBaseline(root, evt.session_id, evt.agent_id);
4994
5160
  const s = new HunchStore(paths);
4995
5161
  try {
4996
5162
  const clip1 = (text, max) => {
@@ -5081,10 +5247,11 @@ program
5081
5247
  return;
5082
5248
  }
5083
5249
  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")
5250
+ // A compact-resume (or a cleared context that keeps its session id) means
5251
+ // everything injected so far is gone — the dedup map must forget it
5252
+ // delivered anything, or the rest of the session gets delta one-liners
5253
+ // against grounding and task rules the agent no longer has.
5254
+ if (evt.source === "compact" || evt.source === "clear")
5088
5255
  resetSessionInjections(evt.session_id);
5089
5256
  // Orientation at the moment it matters: what just happened + what's next,
5090
5257
  // straight from the graph — the agent sits down already knowing where it
@@ -5250,115 +5417,10 @@ program
5250
5417
  }
5251
5418
  }
5252
5419
  // 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)
5420
+ const grounded = fileGrounding(root, store, provider, evt, target, abs);
5421
+ if (!grounded)
5280
5422
  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);
5423
+ emitContext(provider, "PreToolUse", grounded.text, grounded.mode === "full" ? grounded.recalled ?? undefined : undefined);
5362
5424
  }
5363
5425
  catch (e) {
5364
5426
  // Never block an edit on a hook failure — and never go silent either: an
@@ -6551,6 +6613,24 @@ program
6551
6613
  return fail("push failed — no upstream, offline, or nothing to push.");
6552
6614
  console.log("✓ pushed the current branch to its remote.");
6553
6615
  });
6616
+ // ---- footprint (how much text Hunch injects into an agent's context) -------
6617
+ program
6618
+ .command("footprint")
6619
+ .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.")
6620
+ .option("--json", "emit the report (hunch.footprint/1) as JSON")
6621
+ .option("--target <file>", "file to measure the hunch_context result for (default: the file most decisions cite)")
6622
+ .action(async (opts) => {
6623
+ const report = await measureFootprint(findRoot(), { target: opts.target });
6624
+ if (opts.json)
6625
+ return console.log(JSON.stringify(report, null, 2));
6626
+ const width = Math.max(...report.surfaces.map((s) => s.id.length));
6627
+ for (const s of report.surfaces)
6628
+ console.log(`${s.id.padEnd(width)} ${String(s.chars).padStart(7)} ~${s.est_tokens}`);
6629
+ console.log("\nNot measured in-process:");
6630
+ for (const u of report.unmeasured)
6631
+ console.log(`· ${u}`);
6632
+ console.log(dim("\nTokens estimated as characters / 4, not a tokenizer."));
6633
+ });
6554
6634
  // ---- drift (doc≠graph detector; advisory + CI-gateable) -------------------
6555
6635
  program
6556
6636
  .command("drift")
@@ -6607,10 +6687,13 @@ program
6607
6687
  .command("grounding")
6608
6688
  .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
6689
  .option("--refresh", "regenerate the existing grounding docs from the public store (what the post-merge hook and the release remedy run)")
6690
+ .option("--force", "with --refresh: re-render even a block written by a newer Hunch template")
6610
6691
  .option("--json", "machine-readable verdicts")
6611
6692
  .option("--quiet", "print nothing on success")
6612
6693
  .action((opts) => {
6613
6694
  const root = findRoot();
6695
+ if (opts.force && !opts.refresh)
6696
+ console.error("hunch grounding: --force only applies with --refresh; checking without re-rendering.");
6614
6697
  // PUBLIC-ONLY by construction, exactly as the release gate and the freshness test
6615
6698
  // read it: HUNCH_PRIVATE_DIR at an empty overlay beats .hunch/local.json and the
6616
6699
  // shared pointer, so a dev machine with an overlay attached can never write union
@@ -6631,7 +6714,8 @@ program
6631
6714
  return { doc: rel, verdict: { kind: "diverged", reason: "no managed HUNCH block" } };
6632
6715
  return { doc: rel, verdict: classifyGroundingBlock(committed, generated) };
6633
6716
  });
6634
- const refreshed = opts.refresh ? refreshExistingGrounding(root, store) : [];
6717
+ const refreshed = opts.refresh ? refreshExistingGrounding(root, store, { force: opts.force }) : [];
6718
+ // `newer` (a block from a newer Hunch template) is reported, never failing.
6635
6719
  const failing = verdicts.filter((v) => v.verdict.kind === "ahead" || v.verdict.kind === "diverged");
6636
6720
  const lagging = verdicts.filter((v) => v.verdict.kind === "lagging");
6637
6721
  if (opts.json) {
@@ -6647,12 +6731,15 @@ program
6647
6731
  continue;
6648
6732
  if (v.verdict.kind === "fresh" && opts.quiet)
6649
6733
  continue;
6650
- console.log(`${v.verdict.kind === "fresh" ? "✓" : v.verdict.kind === "lagging" ? "·" : "✗"} ${describeGroundingFreshness(v.doc, v.verdict)}`);
6734
+ console.log(`${v.verdict.kind === "fresh" ? "✓" : v.verdict.kind === "lagging" || v.verdict.kind === "newer" ? "·" : "✗"} ${describeGroundingFreshness(v.doc, v.verdict)}`);
6651
6735
  }
6652
6736
  if (!opts.quiet && !failing.length) {
6737
+ const newer = verdicts.filter((v) => v.verdict.kind === "newer");
6653
6738
  console.log(lagging.length
6654
6739
  ? `\n${lagging.length} doc(s) lag a merge — transient; the next capture commit or \`hunch grounding --refresh\` heals it.`
6655
- : "✓ grounding docs are fresh.");
6740
+ : newer.length
6741
+ ? `\n${newer.length} doc(s) written by a newer Hunch — kept as written; upgrade Hunch to check them fully.`
6742
+ : "✓ grounding docs are fresh.");
6656
6743
  }
6657
6744
  }
6658
6745
  if (!opts.refresh && failing.length)
@@ -7315,7 +7402,7 @@ program
7315
7402
  else {
7316
7403
  const team = readTeamConfig(root);
7317
7404
  console.log(team
7318
- ? `overlay: off, but .hunch/team.json advertises the team store (${team.shared_repo}) — run \`hunch init\` to auto-connect`
7405
+ ? `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
7406
  : dim(`private: off — run \`hunch shared\` (or \`hunch private\`) to use one overlay repo across teammates/worktrees (or set HUNCH_PRIVATE_DIR)`));
7320
7407
  }
7321
7408
  // Worktree posture: linked worktrees share ONE memory via the git common dir. Only
@@ -7389,10 +7476,6 @@ function fail(msg) {
7389
7476
  process.exitCode = 1;
7390
7477
  }
7391
7478
  // --- 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
7479
  /** Read all of stdin (the hook event JSON). A TTY (no piped input) resolves to ""
7397
7480
  * so an accidental interactive `hunch hook` exits cleanly instead of hanging. */
7398
7481
  function readStdin() {
@@ -7419,6 +7502,171 @@ function toRepoRel(root, abs) {
7419
7502
  const rel = repoRelativeTarget(abs, root);
7420
7503
  return isAbsolute(rel) || /^[a-zA-Z]:/.test(rel) ? "" : rel;
7421
7504
  }
7505
+ /** Files a shell command wrote get the grounding the edit tools would have
7506
+ * delivered before the edit — late, but while the agent can still revise.
7507
+ * Already-served grounding (a delta) is not repeated. */
7508
+ const MAX_SHELL_GROUNDED = 3;
7509
+ function shellWriteGrounding(root, store, provider, evt, written) {
7510
+ const parts = [];
7511
+ const grounded = [];
7512
+ for (const target of written.slice(0, MAX_SHELL_GROUNDED)) {
7513
+ const g = fileGrounding(root, store, provider, evt, target, join(root, target));
7514
+ if (g?.mode === "full") {
7515
+ parts.push(g.text);
7516
+ grounded.push(target);
7517
+ }
7518
+ }
7519
+ if (!parts.length)
7520
+ return "";
7521
+ const more = written.length > MAX_SHELL_GROUNDED ? ` (${written.length - MAX_SHELL_GROUNDED} more written file(s) not checked)` : "";
7522
+ 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." : "";
7523
+ 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")}`;
7524
+ }
7525
+ /** Memory budget beside a sibling lesson (the default is 1500 tokens). */
7526
+ const SIBLING_MEMORY_BUDGET_TOKENS = 800;
7527
+ /** The advisory grounding for one repo-relative file: the ranked memory slice,
7528
+ * retired code, doc anchors, recent tasks and sibling-fix lessons. `delta` when
7529
+ * identical grounding was already served this session, null when Hunch knows
7530
+ * nothing about the file. Serves the pre-edit hook and files a shell command
7531
+ * wrote (which never pass through the edit tools). */
7532
+ function fileGrounding(root, store, provider, evt, target, abs) {
7533
+ // Decision-grounding for PROSE (doc≠graph): a markdown target that declares
7534
+ // <!-- hunch:topic … --> anchors gets each topic's CURRENT decision — the
7535
+ // graph outranks the prose being edited, and a stale pin is called out inline.
7536
+ let docGround = "";
7537
+ if (/\.(md|mdx)$/i.test(target)) {
7538
+ try {
7539
+ docGround = renderDocGrounding(parseDocAnchors(readFileSync(abs, "utf8")), store.recs("decisions"));
7540
+ }
7541
+ catch { /* unreadable / not yet created — no doc grounding */ }
7542
+ }
7543
+ const ctx = store.assembleContext(target);
7544
+ // Sibling fixes: a same-shaped function elsewhere was fixed and this copy
7545
+ // never was — the concrete lesson a scoped constraint cannot carry.
7546
+ 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 })));
7547
+ // Remember what was delivered, so the first check the agent runs can follow
7548
+ // up if the function is still untouched (pipeline.ts lessonReminder).
7549
+ if (siblings.lessons.length && evt.session_id && pipelineEnabled()) {
7550
+ try {
7551
+ savePipelineState(evt.session_id, onLessonsDelivered(loadPipelineState(evt.session_id), siblings.lessons.map((l) => ({
7552
+ id: `${l.file}:${l.symbol}~${l.siblingFile}:${l.sibling}`,
7553
+ file: l.file,
7554
+ symbol: l.symbol,
7555
+ sibling: l.sibling,
7556
+ siblingFile: l.siblingFile,
7557
+ change: l.commits.map((c) => `${c.sha.slice(0, 8)} ${c.subject}`).join("; "),
7558
+ callers: [...(siblings.callers.get(l.symbol) ?? [])],
7559
+ hash: functionBodyHash(root, l.file, l.symbol),
7560
+ }))));
7561
+ }
7562
+ catch { /* the reminder is a convenience; the lesson itself was delivered */ }
7563
+ }
7564
+ // Regression Guard (edit-time grounding): what an in-force decision retired
7565
+ // from this file. No diff exists yet, so this is context — "don't re-add X" —
7566
+ // not a block; the commit-time `hunch check` does the actual gating.
7567
+ const retired = store.retiredForFile(target).filter((r) => r.symbols.length || r.deps.length);
7568
+ const recentTasks = taskSelectionSupplements(store.selectTasksAuto(target, buildTaskRankingQuery(root, hookReportTaskId(root, provider, evt), target, { excludeTargetDeliveries: true })), target);
7569
+ const hasContent = ctx.constraints.length ||
7570
+ ctx.decisions.length ||
7571
+ ctx.bugs.length ||
7572
+ ctx.blast_radius.length ||
7573
+ ctx.findings.length ||
7574
+ ctx.landscape?.resources.length ||
7575
+ ctx.landscape?.relationships.length ||
7576
+ retired.length ||
7577
+ recentTasks.length ||
7578
+ docGround ||
7579
+ siblings.text;
7580
+ if (!hasContent)
7581
+ return null;
7582
+ const supplements = [
7583
+ ...(retired.length ? [{
7584
+ id: "retired-code",
7585
+ kind: "retired-code",
7586
+ priority: 200,
7587
+ text: `⚠ Deliberately RETIRED from this file — do not re-introduce without cause: ${retired.map((r) => `${[...r.symbols, ...r.deps].join(", ")} (${r.decision})`).join("; ")}.`,
7588
+ }] : []),
7589
+ ...(docGround ? [{ id: "doc-grounding", kind: "doc-grounding", priority: 100, text: docGround }] : []),
7590
+ ...recentTasks,
7591
+ ];
7592
+ // A sibling lesson is the most specific thing Hunch knows about this edit:
7593
+ // it leads, and the ranked memory gets a smaller budget so it cannot bury it.
7594
+ const envelope = buildDeliveryEnvelope(siblings.text ? { ...ctx, budget_tokens: Math.min(ctx.budget_tokens, SIBLING_MEMORY_BUDGET_TOKENS) } : ctx, {
7595
+ profile: "builder",
7596
+ root,
7597
+ symbols: store.recs("symbols"),
7598
+ components: store.recs("components"),
7599
+ decisionCorpus: store.recs("decisions"),
7600
+ supplements,
7601
+ });
7602
+ // Outside the envelope's budget on purpose: the lesson is a code change, not a
7603
+ // one-line supplement, and memory records must not crowd it out.
7604
+ const text = [siblings.text, envelope.text.trim()].filter(Boolean).join("\n\n");
7605
+ // Identical grounding already shown this session → one-line delta instead of
7606
+ // the full 10-16KB block. Any record change re-sends the full text; the
7607
+ // strict-gate deny path above never routes through this (dec_244397d920).
7608
+ // Delivery receipts (dec_925f4bcaad): the ledger of what actually reached
7609
+ // an agent. A full injection is a serve; a delta one-liner attests the
7610
+ // earlier serve is still standing. Never throws, never blocks.
7611
+ const receipts = (event) => recordServed(root, [
7612
+ ...envelope.delivered.map((item) => ({
7613
+ event,
7614
+ kind: item.kind,
7615
+ record_id: item.record_id,
7616
+ target,
7617
+ session_id: evt.session_id,
7618
+ rank: item.rank,
7619
+ delivery_reason: item.delivery_reason,
7620
+ provenance_status: item.provenance_status,
7621
+ token_cost: item.token_cost,
7622
+ delivery_profile: envelope.profile,
7623
+ ranking_policy: envelope.ranking_policy,
7624
+ })),
7625
+ // Delivered task lines are receipts too: they feed access-based recency.
7626
+ ...envelope.supplements.filter((s) => s.kind === "recent-task" && s.delivered).map((s) => ({
7627
+ event, kind: "tasks", record_id: s.id, target, session_id: evt.session_id,
7628
+ rank: s.rank, delivery_reason: "supplemental", token_cost: s.token_cost,
7629
+ delivery_profile: envelope.profile, ranking_policy: envelope.ranking_policy,
7630
+ })),
7631
+ ]);
7632
+ const reportTaskId = hookReportTaskId(root, provider, evt);
7633
+ // A new authoritative prompt gets its own full delivery. An earlier
7634
+ // prompt's session-level delta cannot establish this task's receipt.
7635
+ // A subagent reports to the prompt's task but starts with FRESH context:
7636
+ // it never saw that grounding, so its dedup is scoped by its own agent
7637
+ // identity (hashed — the raw agent_id is never retained in the key).
7638
+ const agentKey = evt.agent_id ? `:${reportHash(evt.agent_id).slice(7, 19)}` : "";
7639
+ // Dedup on the envelope's stable IDENTITY projection, never on the
7640
+ // rendered block: serving the full text writes delivery receipts, and the
7641
+ // next call's task ranking reads them back and moves the wording ("today"
7642
+ // → "delivered today"), so hashing the presentation made this grounding
7643
+ // self-invalidating and re-sent the full block for unchanged records.
7644
+ if (injectionMode(evt.session_id, `pre:${target}${reportTaskId ? `:${reportTaskId}` : ""}${agentKey}`, text, deliveryDedupeInput(envelope, supplements) + (siblings.identity ? `\u0000sibling-fix\u0000${siblings.identity}` : "")) === "delta") {
7645
+ receipts("refreshed");
7646
+ return {
7647
+ mode: "delta",
7648
+ 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).`,
7649
+ };
7650
+ }
7651
+ receipts("served");
7652
+ let reportNotice = "";
7653
+ let recalled = null;
7654
+ if (reportTaskId) {
7655
+ try {
7656
+ const snapshots = snapshotDeliveredRecords(store, envelope);
7657
+ // The first time a lesson reaches this prompt's task, tell the USER in one
7658
+ // line (systemMessage); repeats of the same revision stay silent.
7659
+ recalled = reportPresentationEnabled(root) ? renderRecalledLine(unseenLessons(root, reportTaskId, snapshots)) : null;
7660
+ const occurrence = recordTaskDelivery(root, reportTaskId, envelope, snapshots, undefined, target);
7661
+ reportNotice = `\n\nHunch task ${reportTaskId} · delivery ${occurrence}. Inspect exact application references with hunch_report(task_id).`;
7662
+ }
7663
+ catch {
7664
+ reportNotice = "\n\nTask report observation unavailable; this delivery's task contribution remains unverified.";
7665
+ recalled = null;
7666
+ }
7667
+ }
7668
+ return { mode: "full", text: text + reportNotice, recalled };
7669
+ }
7422
7670
  /** The in-repo files a pre-edit event would change, each with the lines the edit
7423
7671
  * ADDS to that file. A Codex apply_patch yields one entry per touched path (a
7424
7672
  * Move-to destination is its own entry, carrying the section's added lines);