@davesheffer/hunch 1.47.0 → 1.47.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli/index.js CHANGED
@@ -23,7 +23,8 @@ import { Command } from "commander";
23
23
  import { hunchPaths, hunchPathsForDir, findRoot, toPosixTarget, isDir, realpathNorm, repoRelativeTarget } from "../core/paths.js";
24
24
  import { writeFileAtomic } from "../core/io.js";
25
25
  import { boundedSpawnSync } from "../core/boundedSpawn.js";
26
- import { looksLikeCorrection, CORRECTION_NUDGE } from "../core/correction.js";
26
+ import { looksLikeCorrection, CORRECTION_NUDGE, stripHostBlocks } from "../core/correction.js";
27
+ import { roleHasHunchTools } from "../core/subagentrole.js";
27
28
  import { HUNCH_VERSION } from "../core/version.js";
28
29
  import { registerIntegrationCommands } from "./integrations.js";
29
30
  import { registerTaskReportCommands } from "./taskReport.js";
@@ -46,7 +47,7 @@ import { isParserLoadError, loadNativeTreeSitter } from "../extractors/nativeTre
46
47
  import { syncCommit, recordFailure, captureTestRun } from "../synthesis/synthesize.js";
47
48
  import { parseTestReport } from "../extractors/testreport.js";
48
49
  import { readSynthesisPreference, resolveSynthesisProvider, selectProvider, SYNTH_PREFERENCES, writeSynthesisPreference, normalizeProviderName, } from "../synthesis/provider.js";
49
- import { isGitRepo, isGitRepoRoot, sameGitPublication, sameRemoteUrl, canonicalRemoteUrl, repositoryUsesRemote, headSha, isolatedHeadSha, logSince, lastChangeDate, checkFreshnessRevision, teamPullIsFresh, firstCommitForFile, stagedFiles, workingFiles, commitFiles, asOfDate, stagedGateDiff, workingGateDiff, commitGateDiff, rangeFiles, rangeGateDiff, rangeCommits, revExists, revParse, commitAndPushHunch, pullHunchStatus, pullHunchStatusDetailed, describePullFailure, syncExistingHunch, gitUntrackCached, gitCommonDir, checkoutCommonDir, separateGitDirCandidate, separateGitDirLink, hooksDir, isLinkedWorktree, mainWorktreeRoot, gitMemoryLog, memoryMoveDiff, revertMemoryMove, pushCurrentBranch, commitChanges, commitRepairStatusAgainst, commitPatchIds, mergeRangeChanges, commitsExist } from "../extractors/git.js";
50
+ import { isGitRepo, isGitRepoRoot, sameGitPublication, sameRemoteUrl, canonicalRemoteUrl, repositoryUsesRemote, headSha, isolatedHeadSha, logSince, lastChangeDate, checkFreshnessRevision, teamPullIsFresh, firstCommitForFile, stagedFiles, workingFiles, commitFiles, asOfDate, stagedGateDiff, workingGateDiff, commitGateDiff, rangeFiles, rangeGateDiff, rangeCommits, revExists, revParse, sameFilesystemEntry, commitAndPushHunch, pullHunchStatus, pullHunchStatusDetailed, describePullFailure, syncExistingHunch, gitUntrackCached, gitCommonDir, checkoutCommonDir, separateGitDirCandidate, separateGitDirLink, hooksDir, isLinkedWorktree, mainWorktreeRoot, gitMemoryLog, memoryMoveDiff, revertMemoryMove, pushCurrentBranch, commitChanges, commitRepairStatusAgainst, commitPatchIds, mergeRangeChanges, commitsExist } from "../extractors/git.js";
50
51
  import { runbookDraft } from "../core/runbookDraft.js";
51
52
  import { parseMemoryLog } from "../core/memorylog.js";
52
53
  import { renamesOf, planRepair, repairDecision, repairConstraint } from "../core/repair.js";
@@ -61,7 +62,7 @@ import { renderText, renderMarkdown, renderSarif, renderImpact, reportFailsStric
61
62
  import { partitionReview, isReviewDraft, READY_MIN_GROUNDED } from "../core/reviewqueue.js";
62
63
  import { installPostCommitHook, installPreCommitHook, installPostMergeHook, installPostCheckoutHook, hookStatus, hookReport, hookInvocationLines, formatHookInstall, sharedHooksNote } from "../integrations/hooks.js";
63
64
  import { ensureSharedOverlayPointer } from "../integrations/worktree.js";
64
- import { flushCapture, flushMemoryHome, flushMemoryHomes, pinSharedRemote, sharedRemoteFor } from "../integrations/sync.js";
65
+ import { flushCapture, flushMemoryHome, flushMemoryHomes, pinSharedRemote, sharedRemoteFor, holdsOnDefaultBranch, HELD_ON_DEFAULT_BRANCH } from "../integrations/sync.js";
65
66
  import { installMergeDriver } from "../integrations/mergeDriver.js";
66
67
  import { ensureGitignore, ignoreHunchMemory, HUNCH_MEMORY_DIRS, upgradeManagedGitignore, describeGitignoreUpgrade, memoryIgnoreRule, describeMemoryIgnoreRule } from "../integrations/gitignore.js";
67
68
  import { writeCiWorkflow } from "../integrations/ciAction.js";
@@ -210,7 +211,7 @@ registerReviewMemoryCommands(program, (records, repository, privateOnly) => {
210
211
  for (const record of records)
211
212
  store.putCapture("constraints", record, privateOnly);
212
213
  store.reindex();
213
- if (home === "public" && !store.autoCommit)
214
+ if (home === "public" && !publicFlushCommitsGrounding(store, root))
214
215
  refreshExistingGrounding(root, store);
215
216
  pumpMemoryHome(store, root, home, `hunch: capture ${records.length} sourced review rule(s)`);
216
217
  }, (repository, privateOnly) => {
@@ -447,8 +448,25 @@ function storeFor(opts = {}) {
447
448
  const { store, teamPullStatus, teamPullDetail } = openTeamStore(root, opts);
448
449
  return { store, root, teamPullStatus, teamPullDetail };
449
450
  }
451
+ /** Whether a public memory flush commits the tracked grounding docs itself. When it
452
+ * won't (auto-commit off, or held on the default branch), a by-hand command refreshes
453
+ * them on disk instead, so CLAUDE.md never keeps enforcing what the command just changed. */
454
+ function publicFlushCommitsGrounding(store, root) {
455
+ return store.autoCommit && !holdsOnDefaultBranch(hunchPaths(root).hunch);
456
+ }
457
+ // A held public flush says so once per command, on stderr: stdout may be a --json payload.
458
+ let heldNoticePrinted = false;
459
+ function noteHeld() {
460
+ if (heldNoticePrinted)
461
+ return;
462
+ heldNoticePrinted = true;
463
+ console.error(` ↳ ${HELD_ON_DEFAULT_BRANCH}`);
464
+ }
450
465
  function pumpMemoryHome(store, root, home, message) {
451
- flushMemoryHome(store, hunchPaths(root).hunch, home, message);
466
+ const flush = flushMemoryHome(store, hunchPaths(root).hunch, home, message);
467
+ if (flush === "held")
468
+ noteHeld();
469
+ return flush;
452
470
  }
453
471
  function pumpPolicyHome(store, root, service, policyId, message) {
454
472
  const home = service.repository.homeOfPolicy(policyId);
@@ -457,7 +475,9 @@ function pumpPolicyHome(store, root, service, policyId, message) {
457
475
  pumpMemoryHome(store, root, home, message);
458
476
  }
459
477
  function pumpMemoryHomes(store, root, homes, message) {
460
- flushMemoryHomes(store, hunchPaths(root).hunch, homes, message);
478
+ const flushes = flushMemoryHomes(store, hunchPaths(root).hunch, homes, message);
479
+ if (flushes.public === "held")
480
+ noteHeld();
461
481
  }
462
482
  // ---- init -----------------------------------------------------------------
463
483
  program
@@ -767,7 +787,7 @@ program
767
787
  // while the docs are still Git-clean so it can stage them atomically with
768
788
  // the graph. Pre-refreshing would make the safety filter treat generated
769
789
  // docs as user-dirty and leave them stranded outside the memory commit.
770
- const healed = shouldAutoCommit ? [] : refreshExistingGrounding(root, store);
790
+ const healed = shouldAutoCommit && publicFlushCommitsGrounding(store, root) ? [] : refreshExistingGrounding(root, store);
771
791
  console.log(`Indexed ${res.files} files:`);
772
792
  console.log(` ${counts.symbols} symbols, ${counts.edges} edges, ${counts.components} components`);
773
793
  for (const item of res.coverage) {
@@ -995,7 +1015,13 @@ program
995
1015
  // each home's artifacts into at most one memory commit.
996
1016
  const publicCapture = r.status === "written" && !toOverlay;
997
1017
  const privateCapture = r.status === "written" && toOverlay;
998
- const groundingToStage = publicCapture || graphRefreshed || publicRepairTouched ? refreshCommittableGrounding(root, store) : [];
1018
+ const publicNeedsFlush = publicCapture || graphRefreshed || publicRepairTouched;
1019
+ // The default-branch hold covers the post-commit hook too. Its baked-in --commit is
1020
+ // the installed auto-commit default, so only a --commit typed by hand overrides it.
1021
+ // Decided before the grounding refresh: a held capture leaves tracked docs at HEAD,
1022
+ // so `git commit -a` cannot commit counts whose record it leaves untracked.
1023
+ const publicHeld = doCommit && publicNeedsFlush && (opts.fromHook || opts.commit === undefined) && holdsOnDefaultBranch(hunchPaths(root).hunch);
1024
+ const groundingToStage = publicNeedsFlush && !publicHeld ? refreshCommittableGrounding(root, store) : [];
999
1025
  if (!opts.fromHook && (r.status === "written" || graphRefreshed || publicRepairTouched)) {
1000
1026
  const healed = refreshExistingGrounding(root, store);
1001
1027
  const refreshed = [...new Set([...groundingToStage.map((file) => relative(root, file)), ...healed])];
@@ -1003,9 +1029,10 @@ program
1003
1029
  console.log(` ↳ grounding refreshed: ${refreshed.join(", ")}`);
1004
1030
  }
1005
1031
  if (doCommit) {
1006
- const publicNeedsFlush = publicCapture || graphRefreshed || publicRepairTouched;
1007
1032
  const privateNeedsFlush = privateCapture || (privateCorrectionQueued && graphRefreshed) || g2Recorded > 0 || privateRepairTouched;
1008
- const publicResult = publicNeedsFlush
1033
+ if (publicHeld && !opts.quiet)
1034
+ console.log(` ↳ ${HELD_ON_DEFAULT_BRANCH}`);
1035
+ const publicResult = publicNeedsFlush && !publicHeld
1009
1036
  ? commitAndPushHunch(hunchPaths(root).hunch, publicCapture
1010
1037
  ? `hunch: capture ${r.decision?.id ?? "decision"}`
1011
1038
  : graphRefreshed
@@ -2283,12 +2310,13 @@ program
2283
2310
  }
2284
2311
  }
2285
2312
  store.reindex();
2286
- if (dec || con)
2287
- flushCapture(store, hunchPaths(root).hunch, !!opts.private, `hunch: capture ${dec + con} inline intent(s)`);
2313
+ const flush = dec || con ? flushCapture(store, hunchPaths(root).hunch, !!opts.private, `hunch: capture ${dec + con} inline intent(s)`) : null;
2288
2314
  if (!intents.length)
2289
2315
  console.log("No `hunch-why:` / `hunch-rule:` comments found.");
2290
2316
  else
2291
2317
  console.log(`✓ captured ${dec} decision(s) + ${con} constraint(s) from inline comments${opts.private ? " [private overlay]" : ""}`);
2318
+ if (flush === "held")
2319
+ console.log(` ↳ ${HELD_ON_DEFAULT_BRANCH}`);
2292
2320
  store.close();
2293
2321
  });
2294
2322
  // ---- conform (Architectural Conformance: does the code still satisfy recorded intent) ----
@@ -2330,7 +2358,7 @@ program
2330
2358
  valid_from: now,
2331
2359
  });
2332
2360
  store.reindex();
2333
- if (home === "public" && !store.autoCommit)
2361
+ if (home === "public" && !publicFlushCommitsGrounding(store, root))
2334
2362
  refreshExistingGrounding(root, store);
2335
2363
  pumpMemoryHome(store, root, home, `hunch: record architectural invariant ${d.id}`);
2336
2364
  console.log(`✓ recorded architectural invariant ${d.id}: "${opts.add}"`);
@@ -4047,7 +4075,7 @@ program
4047
4075
  }
4048
4076
  store.reindex();
4049
4077
  const home = store.captureHome(!!opts.private);
4050
- if (home === "public" && !store.autoCommit)
4078
+ if (home === "public" && !publicFlushCommitsGrounding(store, root))
4051
4079
  refreshExistingGrounding(root, store);
4052
4080
  const flush = flushCapture(store, hunchPaths(root).hunch, !!opts.private, `hunch: import ${decisions.length} ADR(s) from ${dir}`);
4053
4081
  const live = decisions.filter((d) => d.status === "accepted").length;
@@ -4066,6 +4094,8 @@ program
4066
4094
  }
4067
4095
  if (flush === "pushed")
4068
4096
  console.log(" ↳ private memory committed + pushed");
4097
+ if (flush === "held")
4098
+ console.log(` ↳ ${HELD_ON_DEFAULT_BRANCH}`);
4069
4099
  }
4070
4100
  finally {
4071
4101
  store.close();
@@ -4203,7 +4233,7 @@ program
4203
4233
  store.reindex();
4204
4234
  // Public grounding is a publishable artifact. Private rules stay local and
4205
4235
  // are surfaced by local checks/MCP, never copied into committed agent docs.
4206
- if (home === "public" && !store.autoCommit)
4236
+ if (home === "public" && !publicFlushCommitsGrounding(store, root))
4207
4237
  refreshExistingGrounding(root, store);
4208
4238
  const flush = flushCapture(store, hunchPaths(root).hunch, !!opts.private, `hunch: capture ${c.id}`);
4209
4239
  console.log(`✓ recorded ${c.severity} constraint ${c.id}: "${c.statement}" (scope: ${scope.join(", ") || "repo"})${opts.private ? " [private overlay]" : ""}`);
@@ -4217,6 +4247,8 @@ program
4217
4247
  }
4218
4248
  if (flush === "pushed")
4219
4249
  console.log(" ↳ private memory committed + pushed");
4250
+ if (flush === "held")
4251
+ console.log(` ↳ ${HELD_ON_DEFAULT_BRANCH}`);
4220
4252
  store.close();
4221
4253
  });
4222
4254
  // ---- test (failure-learning loop) -----------------------------------------
@@ -4960,7 +4992,7 @@ program
4960
4992
  // is on, but no commit happens to carry that refresh when it's off, so do it here
4961
4993
  // too — otherwise a retired constraint keeps showing as enforced on disk (the same
4962
4994
  // gap record-constraint and `conform --add` close for their own capture path).
4963
- if (home === "public" && !store.autoCommit)
4995
+ if (home === "public" && !publicFlushCommitsGrounding(store, root))
4964
4996
  refreshExistingGrounding(root, store);
4965
4997
  // `--reason` goes in the commit BODY, never the subject: `hunch log`'s move
4966
4998
  // classifier (src/core/memorylog.ts) regexes the commit SUBJECT for keywords
@@ -4968,11 +5000,14 @@ program
4968
5000
  // in the subject could accidentally match one and misclassify the move.
4969
5001
  const willCommit = home === "private" ? store.privateAutoCommit : store.autoCommit;
4970
5002
  const message = `hunch: retire constraint ${id}${opts.reason ? `\n\n${opts.reason}` : ""}`;
4971
- pumpMemoryHome(store, root, home, message);
5003
+ const flush = pumpMemoryHome(store, root, home, message);
4972
5004
  console.log(`✓ ${retired.id} retired — window closed at ${retired.valid_to?.slice(0, 10)}.`);
4973
5005
  if (opts.reason && !willCommit) {
4974
5006
  console.log(" ⚠ --reason is not recorded anywhere: auto-commit is off, so no commit message captured it.");
4975
5007
  }
5008
+ else if (opts.reason && flush === "held") {
5009
+ console.log(" ⚠ --reason is not recorded anywhere: the commit was held on the default branch, so no commit message captured it.");
5010
+ }
4976
5011
  store.close();
4977
5012
  });
4978
5013
  // ---- firmness (agent-hook enforcement level) ------------------------------
@@ -5198,10 +5233,14 @@ program
5198
5233
  if (!evt)
5199
5234
  return;
5200
5235
  eventName = evt.hook_event_name;
5201
- const root = findRoot();
5236
+ // Grounding reads the store of the checkout the event's file is in; the prompt's
5237
+ // task and the hook evidence stay with the process root that opened them.
5238
+ const processRoot = findRoot();
5239
+ const routed = hookEventRoot(processRoot, evt);
5240
+ const root = routed.root;
5202
5241
  // The host delivered this event: runtime evidence for `hunch integrations check`,
5203
5242
  // recorded before any policy decision so firmness never hides delivery itself.
5204
- recordHookObservation(root, provider, evt.hook_event_name, evt.tool_outcome?.status);
5243
+ recordHookObservation(processRoot, provider, evt.hook_event_name, evt.tool_outcome?.status);
5205
5244
  const paths = hunchPaths(root);
5206
5245
  const firmness = readConfig(paths).firmness;
5207
5246
  if (firmness === "off")
@@ -5225,7 +5264,7 @@ program
5225
5264
  // Same rule as the pre-edit path: strict never grounds from stale team rules.
5226
5265
  const stale = firmness === "strict" && opened.teamPullStatus && !teamPullIsFresh(opened.teamPullStatus);
5227
5266
  if (!stale)
5228
- shellGround = shellWriteGrounding(root, store, provider, evt, written);
5267
+ shellGround = shellWriteGrounding(root, store, provider, evt, written, processRoot);
5229
5268
  }
5230
5269
  }
5231
5270
  else {
@@ -5418,9 +5457,11 @@ program
5418
5457
  return flat.length > max ? `${flat.slice(0, max - 1).trimEnd()}…` : flat;
5419
5458
  };
5420
5459
  const type = (evt.agent_type ?? "").toLowerCase();
5460
+ // A role whose tool list leaves out Hunch's MCP tools is not told to call them.
5461
+ const tools = roleHasHunchTools(root, evt.agent_type ?? "");
5421
5462
  const L = [];
5422
5463
  const served = [];
5423
- const route = routedCwd
5464
+ const route = routedCwd && tools
5424
5465
  ? `Worktree routing: call hunch_context first with cwd: ${JSON.stringify(routedCwd)}, and pass the same cwd to hunch_task, hunch_report, and every Hunch capture/write call in this delegated task.`
5425
5466
  : null;
5426
5467
  const activeProvider = provider;
@@ -5440,8 +5481,9 @@ program
5440
5481
  served.push({ kind: "components", record_id: c.id, token_cost: Math.ceil([...line].length / 4) });
5441
5482
  }
5442
5483
  if (components.length > 12)
5443
- L.push(`…and ${components.length - 12} more — hunch_structure() for the full map.`);
5444
- L.push("Orient: hunch_structure(target) · hunch_why(target) · hunch_context(task).");
5484
+ L.push(`…and ${components.length - 12} more${tools ? " — hunch_structure() for the full map" : ""}.`);
5485
+ if (tools)
5486
+ L.push("Orient: hunch_structure(target) · hunch_why(target) · hunch_context(task).");
5445
5487
  }
5446
5488
  else if (/plan|architect|design/.test(type)) {
5447
5489
  // A plan drafted blind re-proposes what the graph already rejected.
@@ -5458,7 +5500,8 @@ program
5458
5500
  L.push(line);
5459
5501
  served.push({ kind: "decisions", record_id: d.id, token_cost: Math.ceil([...line].length / 4) });
5460
5502
  }
5461
- L.push("Before finalizing a plan: hunch_why(target) · hunch_current_decision(topic) · hunch_check_constraints(scope).");
5503
+ if (tools)
5504
+ L.push("Before finalizing a plan: hunch_why(target) · hunch_current_decision(topic) · hunch_check_constraints(scope).");
5462
5505
  }
5463
5506
  else {
5464
5507
  const sevRank = { blocking: 0, warning: 1, advisory: 2 };
@@ -5476,8 +5519,9 @@ program
5476
5519
  served.push({ kind: "constraints", record_id: c.id, token_cost: Math.ceil([...line].length / 4) });
5477
5520
  }
5478
5521
  if (constraints.length > 8)
5479
- L.push(`…and ${constraints.length - 8} more — hunch_check_constraints(scope) for your files.`);
5480
- L.push("Before editing: hunch_check_constraints(scope) · hunch_why(target). Orient: hunch_context(task).");
5522
+ L.push(`…and ${constraints.length - 8} more${tools ? " — hunch_check_constraints(scope) for your files" : "; the edit hook shows the ones your files touch"}.`);
5523
+ if (tools)
5524
+ L.push("Before editing: hunch_check_constraints(scope) · hunch_why(target). Orient: hunch_context(task).");
5481
5525
  }
5482
5526
  if (route)
5483
5527
  L.push(route);
@@ -5582,9 +5626,13 @@ program
5582
5626
  return;
5583
5627
  }
5584
5628
  const targets = editTargets(root, evt.tool_input);
5585
- // Nothing inside the repo → nothing for Hunch to say.
5586
- if (!targets.length)
5629
+ // Nothing inside the repo → nothing for Hunch to say, except for a file in a
5630
+ // linked worktree of this repository Hunch could not ground (never silently).
5631
+ if (!targets.length) {
5632
+ if (routed.ungrounded)
5633
+ emitContext(provider, "PreToolUse", routed.ungrounded);
5587
5634
  return;
5635
+ }
5588
5636
  // Grounding below stays about the first in-repo file; the strict gate checks every target.
5589
5637
  const { abs, target } = targets[0];
5590
5638
  // A compiled red→green probe is only meaningful if its red receipt exists
@@ -5646,12 +5694,12 @@ program
5646
5694
  : `Hunch: this patch is denied for ${denials.length} files (${denials.map((d) => d.target).join(", ")}).\n${denials.map((d) => d.hit.reason).join("\n")}`;
5647
5695
  emitDeny(provider, reason);
5648
5696
  for (const d of denials)
5649
- observeHookDenial(root, provider, evt, d.target, d.hit);
5697
+ observeHookDenial(processRoot, provider, evt, d.target, d.hit);
5650
5698
  return;
5651
5699
  }
5652
5700
  }
5653
5701
  // advisory / firm / strict(non-blocking): inject the relevant Hunch slice.
5654
- const grounded = fileGrounding(root, store, provider, evt, target, abs);
5702
+ const grounded = fileGrounding(root, store, provider, evt, target, abs, processRoot);
5655
5703
  if (!grounded)
5656
5704
  return; // no noise on files Hunch hasn't learned yet
5657
5705
  emitContext(provider, "PreToolUse", grounded.text, grounded.mode === "full" ? grounded.recalled ?? undefined : undefined);
@@ -5830,7 +5878,7 @@ program
5830
5878
  store.reindex();
5831
5879
  if (home === "public") {
5832
5880
  publicGroundingChanged = true;
5833
- if (!store.autoCommit)
5881
+ if (!publicFlushCommitsGrounding(store, root))
5834
5882
  refreshExistingGrounding(root, store);
5835
5883
  }
5836
5884
  console.log(opts.approveImport
@@ -5890,7 +5938,7 @@ program
5890
5938
  store.reindex();
5891
5939
  if (home === "public") {
5892
5940
  publicGroundingChanged = true;
5893
- if (!store.autoCommit)
5941
+ if (!publicFlushCommitsGrounding(store, root))
5894
5942
  refreshExistingGrounding(root, store);
5895
5943
  }
5896
5944
  }
@@ -5909,7 +5957,7 @@ program
5909
5957
  store.reindex();
5910
5958
  if (home === "public") {
5911
5959
  publicGroundingChanged = true;
5912
- if (!store.autoCommit)
5960
+ if (!publicFlushCommitsGrounding(store, root))
5913
5961
  refreshExistingGrounding(root, store);
5914
5962
  }
5915
5963
  console.log(`✓ accepted ${opts.accept} (now ${source}, confidence 0.95${armed ? `, ${armed} tripwire(s) now blocking` : ""})`);
@@ -5968,7 +6016,7 @@ program
5968
6016
  armedTotal += accepted.armed;
5969
6017
  }
5970
6018
  store.reindex();
5971
- if (publicGroundingChanged && !store.autoCommit)
6019
+ if (publicGroundingChanged && !publicFlushCommitsGrounding(store, root))
5972
6020
  refreshExistingGrounding(root, store);
5973
6021
  console.log(`✓ accepted ${ready.length} verified draft(s); ${armedTotal} tripwire(s) now blocking.`);
5974
6022
  for (const it of ready)
@@ -6128,7 +6176,7 @@ program
6128
6176
  }
6129
6177
  if (accepted || rejected) {
6130
6178
  store.reindex();
6131
- if (publicAccepted && !store.autoCommit)
6179
+ if (publicAccepted && !publicFlushCommitsGrounding(store, root))
6132
6180
  refreshExistingGrounding(root, store);
6133
6181
  pumpMemoryHomes(store, root, touchedHomes, "hunch: auto-review decision lifecycle");
6134
6182
  }
@@ -7599,15 +7647,64 @@ function toRepoRel(root, abs) {
7599
7647
  return "";
7600
7648
  return rel;
7601
7649
  }
7650
+ /** The repository root a hook event belongs to. Normally the process root, but an
7651
+ * edit to a file in another LINKED worktree of the same repository (or a subagent
7652
+ * started in one) belongs to that worktree's memory: grounded against the process
7653
+ * root it was dropped as "outside the repo", with no output at all. Re-roots only
7654
+ * to a worktree sharing this repository's git common dir that carries its own
7655
+ * .hunch/; one without it is reported as ungrounded rather than skipped silently.
7656
+ * Checkouts are told apart by their `.git` entry, never by a .hunch/ hit, so a
7657
+ * nested package .hunch/ inside this checkout (a monorepo) never displaces the
7658
+ * process root and its blocking constraints. Git is spawned only for a file in a
7659
+ * different checkout than the process root's. */
7660
+ function hookEventRoot(processRoot, evt) {
7661
+ const toolEvent = evt.hook_event_name === "PreToolUse" || evt.hook_event_name === "PostToolUse" || evt.hook_event_name === "PostToolUseFailure";
7662
+ const subject = evt.hook_event_name === "SubagentStart" ? evt.cwd
7663
+ : toolEvent ? (evt.tool_input?.patch_files?.[0]?.path ?? evt.tool_input?.file_path) : undefined;
7664
+ if (!subject || !isAbsolute(subject))
7665
+ return { root: processRoot };
7666
+ try {
7667
+ const ownerTop = checkoutTop(evt.hook_event_name === "SubagentStart" ? subject : dirname(subject));
7668
+ const processTop = checkoutTop(processRoot);
7669
+ if (!ownerTop || !processTop || sameFilesystemEntry(ownerTop, processTop))
7670
+ return { root: processRoot };
7671
+ const common = checkoutCommonDir(processRoot);
7672
+ const ownerCommon = common ? checkoutCommonDir(ownerTop) : "";
7673
+ if (!common || !ownerCommon || !sameFilesystemEntry(common, ownerCommon))
7674
+ return { root: processRoot };
7675
+ // The same Hunch root inside the other worktree: its top, or the package dir a
7676
+ // subdirectory-rooted process uses.
7677
+ const owner = join(ownerTop, relative(processTop, processRoot));
7678
+ if (isDir(join(owner, ".hunch")))
7679
+ return { root: owner };
7680
+ return { root: processRoot, ungrounded: `Hunch: ${toPosixTarget(subject)} is in linked worktree ${toPosixTarget(ownerTop)}, which has no .hunch/ — this edit proceeds ungrounded.` };
7681
+ }
7682
+ catch {
7683
+ return { root: processRoot };
7684
+ }
7685
+ }
7686
+ /** The nearest directory at or above `start` holding a `.git` entry (a checkout's top;
7687
+ * a linked worktree's `.git` is a file), or "" outside every checkout. Pure fs. */
7688
+ function checkoutTop(start) {
7689
+ let cur = resolve(start);
7690
+ for (;;) {
7691
+ if (existsSync(join(cur, ".git")))
7692
+ return cur;
7693
+ const parent = dirname(cur);
7694
+ if (parent === cur)
7695
+ return "";
7696
+ cur = parent;
7697
+ }
7698
+ }
7602
7699
  /** Files a shell command wrote get the grounding the edit tools would have
7603
7700
  * delivered before the edit — late, but while the agent can still revise.
7604
7701
  * Already-served grounding (a delta) is not repeated. */
7605
7702
  const MAX_SHELL_GROUNDED = 3;
7606
- function shellWriteGrounding(root, store, provider, evt, written) {
7703
+ function shellWriteGrounding(root, store, provider, evt, written, ledgerRoot = root) {
7607
7704
  const parts = [];
7608
7705
  const grounded = [];
7609
7706
  for (const target of written.slice(0, MAX_SHELL_GROUNDED)) {
7610
- const g = fileGrounding(root, store, provider, evt, target, join(root, target));
7707
+ const g = fileGrounding(root, store, provider, evt, target, join(root, target), ledgerRoot);
7611
7708
  if (g?.mode === "full") {
7612
7709
  parts.push(g.text);
7613
7710
  grounded.push(target);
@@ -7677,19 +7774,21 @@ function promptTaskSelection(root, store, provider, evt) {
7677
7774
  const taskId = hookReportTaskId(root, provider, evt);
7678
7775
  if (!taskId)
7679
7776
  return "";
7777
+ // Host-injected blocks (reminders, browser instructions) are not the task the human named.
7778
+ const humanText = stripHostBlocks(evt.prompt ?? "");
7680
7779
  const selection = selectForTask(liveSelectionRecords({
7681
7780
  decisions: store.recs("decisions"),
7682
7781
  bugs: store.recs("bugs"),
7683
7782
  constraints: store.recs("constraints"),
7684
7783
  findings: store.recs("findings"),
7685
- }), evt.prompt ?? "", { root, pathExists: (p) => existsSync(join(root, p)) });
7784
+ }), humanText, { root, pathExists: (p) => existsSync(join(root, p)) });
7686
7785
  // Same task id again (a notification alias) keeps its own earlier selection; a
7687
7786
  // new task that CONTINUES the session's previous one inherits that task's
7688
7787
  // selection only on a bare follow-up ("continue", "go on"), so it is not left
7689
7788
  // ungrounded. A substantive prompt names its own task: a same-session prompt
7690
7789
  // inside the continuation window must not hide memory behind an old selection.
7691
7790
  let previous = loadTaskSelection(taskId);
7692
- if (!previous && isBareFollowUp(evt.prompt ?? "")) {
7791
+ if (!previous && isBareFollowUp(humanText)) {
7693
7792
  try {
7694
7793
  const continues = readTaskReport(root, taskId).task.continues;
7695
7794
  previous = continues ? loadTaskSelection(continues) : null;
@@ -7726,8 +7825,9 @@ const SIBLING_MEMORY_BUDGET_TOKENS = 800;
7726
7825
  * retired code, doc anchors, recent tasks and sibling-fix lessons. `delta` when
7727
7826
  * identical grounding was already served this session, null when Hunch knows
7728
7827
  * nothing about the file. Serves the pre-edit hook and files a shell command
7729
- * wrote (which never pass through the edit tools). */
7730
- function fileGrounding(root, store, provider, evt, target, abs) {
7828
+ * wrote (which never pass through the edit tools). `ledgerRoot` is where the prompt's
7829
+ * task lives — the process root when the event was re-rooted to a linked worktree. */
7830
+ function fileGrounding(root, store, provider, evt, target, abs, ledgerRoot = root) {
7731
7831
  // Decision-grounding for PROSE (doc≠graph): a markdown target that declares
7732
7832
  // <!-- hunch:topic … --> anchors gets each topic's CURRENT decision — the
7733
7833
  // graph outranks the prose being edited, and a stale pin is called out inline.
@@ -7744,7 +7844,7 @@ function fileGrounding(root, store, provider, evt, target, abs) {
7744
7844
  // at warning, not relevance guesses. No selection (a host without a prompt
7745
7845
  // hook, a legacy session, a prompt that selected nothing) keeps the unfiltered
7746
7846
  // grounding.
7747
- const selection = loadTaskSelection(hookReportTaskId(root, provider, evt));
7847
+ const selection = loadTaskSelection(hookReportTaskId(ledgerRoot, provider, evt));
7748
7848
  const assembled = store.assembleContext(target);
7749
7849
  const selected = selection ? new Set(selection.qualifying) : null;
7750
7850
  const ctx = selected ? {
@@ -7779,7 +7879,7 @@ function fileGrounding(root, store, provider, evt, target, abs) {
7779
7879
  const retired = store.retiredForFile(target).filter((r) => r.symbols.length || r.deps.length);
7780
7880
  // Recent-task history is advisory; under a task selection it is dropped
7781
7881
  // (`hunch task list` still has it).
7782
- const recentTasks = selected ? [] : taskSelectionSupplements(store.selectTasksAuto(target, buildTaskRankingQuery(root, hookReportTaskId(root, provider, evt), target, { excludeTargetDeliveries: true })), target);
7882
+ const recentTasks = selected ? [] : taskSelectionSupplements(store.selectTasksAuto(target, buildTaskRankingQuery(ledgerRoot, hookReportTaskId(ledgerRoot, provider, evt), target, { excludeTargetDeliveries: true })), target);
7783
7883
  const hasContent = ctx.constraints.length ||
7784
7884
  ctx.decisions.length ||
7785
7885
  ctx.bugs.length ||
@@ -7843,7 +7943,7 @@ function fileGrounding(root, store, provider, evt, target, abs) {
7843
7943
  delivery_profile: envelope.profile, ranking_policy: envelope.ranking_policy,
7844
7944
  })),
7845
7945
  ]);
7846
- const reportTaskId = hookReportTaskId(root, provider, evt);
7946
+ const reportTaskId = hookReportTaskId(ledgerRoot, provider, evt);
7847
7947
  // A new authoritative prompt gets its own full delivery. An earlier
7848
7948
  // prompt's session-level delta cannot establish this task's receipt.
7849
7949
  // A subagent reports to the prompt's task but starts with FRESH context:
@@ -7870,8 +7970,8 @@ function fileGrounding(root, store, provider, evt, target, abs) {
7870
7970
  const snapshots = snapshotDeliveredRecords(store, envelope);
7871
7971
  // The first time a lesson reaches this prompt's task, tell the USER in one
7872
7972
  // line (systemMessage); repeats of the same revision stay silent.
7873
- recalled = reportPresentationEnabled(root) ? renderRecalledLine(unseenLessons(root, reportTaskId, snapshots)) : null;
7874
- const occurrence = recordTaskDelivery(root, reportTaskId, envelope, snapshots, undefined, target);
7973
+ recalled = reportPresentationEnabled(ledgerRoot) ? renderRecalledLine(unseenLessons(ledgerRoot, reportTaskId, snapshots)) : null;
7974
+ const occurrence = recordTaskDelivery(ledgerRoot, reportTaskId, envelope, snapshots, undefined, target);
7875
7975
  reportNotice = `\n\nHunch task ${reportTaskId} · delivery ${occurrence}. Inspect exact application references with hunch_report(task_id).`;
7876
7976
  }
7877
7977
  catch {
@@ -4,3 +4,9 @@ export declare function registerTaskReportCommands(program: Command, openStore:
4
4
  store: HunchStore;
5
5
  root: string;
6
6
  }): void;
7
+ /** The root whose task ledger holds `id`: `root` itself, else a sibling worktree of
8
+ * the same repository that has a .hunch/. A hook rooted at the main checkout opens
9
+ * the task while the agent works in a linked worktree (or the reverse); the check
10
+ * still runs, and is snapshotted, where the caller stands. `root` when no sibling
11
+ * has the task, so the usual "task not found" stays the error. */
12
+ export declare function taskLedgerRoot(root: string, id: string): string;
@@ -1,9 +1,11 @@
1
1
  import { existsSync, mkdirSync, readFileSync } from "node:fs";
2
- import { findRoot } from "../core/paths.js";
2
+ import { join } from "node:path";
3
+ import { findRoot, isDir } from "../core/paths.js";
4
+ import { sameFilesystemEntry, worktreePaths } from "../extractors/git.js";
3
5
  import { writeFileAtomic } from "../core/io.js";
4
- import { finishReportTask, forgetReportTask, listReportTasks, listTaskSummaries, pruneReportHistory, readTaskReport, readLessonHistory, renderTaskStatusLine, startReportTask, summarizeTaskReport, taskReportStats } from "../core/taskReport.js";
6
+ import { finishReportTask, forgetReportTask, reportTaskExists, listReportTasks, listTaskSummaries, pruneReportHistory, readTaskReport, readLessonHistory, renderTaskStatusLine, startReportTask, summarizeTaskReport, taskReportStats } from "../core/taskReport.js";
5
7
  import { promptTaskId } from "../core/taskReportHook.js";
6
- import { DEFAULT_CHECK_TIMEOUT_MS, MAX_CHECK_TIMEOUT_MS, reportSourceSnapshot, runReportCheck, runReportConformance } from "../core/taskReportEvidence.js";
8
+ import { DEFAULT_CHECK_TIMEOUT_MS, MAX_CHECK_TIMEOUT_MS, currentSource, runReportCheck, runReportConformance } from "../core/taskReportEvidence.js";
7
9
  import { renderTaskReport, writeTaskReportHtml } from "../core/taskReportRender.js";
8
10
  import { assertReportPath } from "../core/taskReportPaths.js";
9
11
  import { publicTaskReport } from "../core/taskReportPublic.js";
@@ -61,7 +63,7 @@ export function registerTaskReportCommands(program, openStore) {
61
63
  catch (error) {
62
64
  graph = `\nGraph not saved: ${error.message}`;
63
65
  }
64
- console.log(renderTaskReport(readTaskReport(root, id, reportSourceSnapshot(root).hash)) + graph);
66
+ console.log(renderTaskReport(readTaskReport(root, id, currentSource(root))) + graph);
65
67
  });
66
68
  task.command("list").description("Recent tasks observed in this repository with what Hunch delivered, saved, guarded, and checked")
67
69
  .option("--limit <n>", "how many recent tasks (max 30)", "30")
@@ -69,7 +71,7 @@ export function registerTaskReportCommands(program, openStore) {
69
71
  .action((opts) => {
70
72
  const root = findRoot();
71
73
  const limit = Number(opts.limit) || 30;
72
- let summaries = listTaskSummaries(root, limit, reportSourceSnapshot(root).hash);
74
+ let summaries = listTaskSummaries(root, limit, currentSource(root));
73
75
  // Graph records (this machine's or a teammate's) join the local ledger view.
74
76
  try {
75
77
  const opened = openStore();
@@ -161,7 +163,7 @@ export function registerTaskReportCommands(program, openStore) {
161
163
  catch { /* a malformed host payload falls back to the most recent task */ }
162
164
  let summary = null;
163
165
  try {
164
- const snapshot = reportSourceSnapshot(root).hash;
166
+ const snapshot = currentSource(root);
165
167
  summary = taskId ? summarizeTaskReport(root, taskId, snapshot) : listTaskSummaries(root, 1, snapshot)[0] ?? null;
166
168
  }
167
169
  catch {
@@ -207,7 +209,9 @@ export function registerTaskReportCommands(program, openStore) {
207
209
  process.on("SIGINT", interrupt);
208
210
  process.on("SIGTERM", terminate);
209
211
  try {
210
- const result = await runReportCheck(findRoot(), id, command, opts.label, seconds * 1000, {
212
+ const here = findRoot();
213
+ const result = await runReportCheck(taskLedgerRoot(here, id), id, command, opts.label, seconds * 1000, {
214
+ workRoot: here,
211
215
  signal: controller.signal,
212
216
  onStdout: opts.json ? undefined : chunk => { process.stdout.write(chunk); },
213
217
  onStderr: opts.json ? undefined : chunk => { process.stderr.write(chunk); },
@@ -258,7 +262,7 @@ export function registerTaskReportCommands(program, openStore) {
258
262
  }
259
263
  let report;
260
264
  try {
261
- report = readTaskReport(root, id, reportSourceSnapshot(root).hash);
265
+ report = readTaskReport(root, id, currentSource(root));
262
266
  }
263
267
  catch (error) {
264
268
  // Not in this machine's ledger: the graph record (if any) is what remains.
@@ -277,6 +281,25 @@ export function registerTaskReportCommands(program, openStore) {
277
281
  console.log(opts.json ? JSON.stringify(report, null, 2) : renderTaskReport(report));
278
282
  });
279
283
  }
284
+ /** The root whose task ledger holds `id`: `root` itself, else a sibling worktree of
285
+ * the same repository that has a .hunch/. A hook rooted at the main checkout opens
286
+ * the task while the agent works in a linked worktree (or the reverse); the check
287
+ * still runs, and is snapshotted, where the caller stands. `root` when no sibling
288
+ * has the task, so the usual "task not found" stays the error. */
289
+ export function taskLedgerRoot(root, id) {
290
+ if (reportTaskExists(root, id))
291
+ return root;
292
+ for (const other of worktreePaths(root)) {
293
+ if (sameFilesystemEntry(other, root) || !isDir(join(other, ".hunch")) || !existsSync(join(other, ".hunch-cache", "served.db")))
294
+ continue;
295
+ try {
296
+ if (reportTaskExists(other, id))
297
+ return other;
298
+ }
299
+ catch { /* unreadable ledger: keep looking */ }
300
+ }
301
+ return root;
302
+ }
280
303
  function readStdinText() {
281
304
  return new Promise(resolve => {
282
305
  let data = "";
@@ -1,4 +1,6 @@
1
1
  import type { Constraint } from "./types.js";
2
+ /** The prompt with host-generated blocks removed: what the human actually typed. */
3
+ export declare function stripHostBlocks(prompt: string): string;
2
4
  export declare function looksLikeCorrection(prompt: string | undefined | null): boolean;
3
5
  /** One-line nudge appended to the UserPromptSubmit hook context when a prompt
4
6
  * reads like a correction — surfaces the write tool so the rule gets ENFORCED,
@@ -29,10 +29,24 @@ const CORRECTION_PATTERNS = [
29
29
  /\b(undo|revert) (that|this|it|your)\b/i,
30
30
  /\b(not like that|don'?t do (that|this)( again)?|stop doing (that|this))\b/i,
31
31
  ];
32
+ /** Host-generated blocks a prompt can carry: a background task's completion
33
+ * (`<task-notification>`, which also wraps a subagent's hand-back report),
34
+ * injected context (`<system-reminder>`, hook output) and the browser
35
+ * extension's standing instructions (`<browser_instruction>`). Their words are not the
36
+ * human's, so "never use X" quoted from a constraint is not a correction. A tag
37
+ * left unclosed drops only the rest of its own line, never the human's text below. */
38
+ const HOST_TAGS = "task-notification|system-reminder|local-command-stdout|local-command-stderr|command-message|command-name|command-args|browser_instruction";
39
+ const HOST_BLOCK = new RegExp(`<(${HOST_TAGS})>[\\s\\S]*?<\\/\\1>`, "gi");
40
+ const HOST_OPEN_LINE = new RegExp(`<(?:${HOST_TAGS})>[^\\n]*`, "gi");
41
+ /** The prompt with host-generated blocks removed: what the human actually typed. */
42
+ export function stripHostBlocks(prompt) {
43
+ return prompt.replace(HOST_BLOCK, " ").replace(HOST_OPEN_LINE, " ");
44
+ }
32
45
  export function looksLikeCorrection(prompt) {
33
46
  if (!prompt || typeof prompt !== "string")
34
47
  return false;
35
- return CORRECTION_PATTERNS.some((re) => re.test(prompt));
48
+ const human = stripHostBlocks(prompt);
49
+ return CORRECTION_PATTERNS.some((re) => re.test(human));
36
50
  }
37
51
  /** One-line nudge appended to the UserPromptSubmit hook context when a prompt
38
52
  * reads like a correction — surfaces the write tool so the rule gets ENFORCED,
@@ -0,0 +1,6 @@
1
+ /** Whether a delegated agent can call Hunch's MCP tools. A named role defined in
2
+ * `.claude/agents/<type>.md` (project first, then user) with a `tools:` list that
3
+ * names no `mcp__hunch` tool and no wildcard cannot: telling it to call
4
+ * hunch_why only costs it a failed lookup. Built-in and unknown types inherit
5
+ * the parent's tools. */
6
+ export declare function roleHasHunchTools(root: string, agentType: string, home?: string): boolean;