@davesheffer/hunch 1.47.0 → 1.47.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli/index.js CHANGED
@@ -46,7 +46,7 @@ import { isParserLoadError, loadNativeTreeSitter } from "../extractors/nativeTre
46
46
  import { syncCommit, recordFailure, captureTestRun } from "../synthesis/synthesize.js";
47
47
  import { parseTestReport } from "../extractors/testreport.js";
48
48
  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";
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, 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
50
  import { runbookDraft } from "../core/runbookDraft.js";
51
51
  import { parseMemoryLog } from "../core/memorylog.js";
52
52
  import { renamesOf, planRepair, repairDecision, repairConstraint } from "../core/repair.js";
@@ -61,7 +61,7 @@ import { renderText, renderMarkdown, renderSarif, renderImpact, reportFailsStric
61
61
  import { partitionReview, isReviewDraft, READY_MIN_GROUNDED } from "../core/reviewqueue.js";
62
62
  import { installPostCommitHook, installPreCommitHook, installPostMergeHook, installPostCheckoutHook, hookStatus, hookReport, hookInvocationLines, formatHookInstall, sharedHooksNote } from "../integrations/hooks.js";
63
63
  import { ensureSharedOverlayPointer } from "../integrations/worktree.js";
64
- import { flushCapture, flushMemoryHome, flushMemoryHomes, pinSharedRemote, sharedRemoteFor } from "../integrations/sync.js";
64
+ import { flushCapture, flushMemoryHome, flushMemoryHomes, pinSharedRemote, sharedRemoteFor, holdsOnDefaultBranch, HELD_ON_DEFAULT_BRANCH } from "../integrations/sync.js";
65
65
  import { installMergeDriver } from "../integrations/mergeDriver.js";
66
66
  import { ensureGitignore, ignoreHunchMemory, HUNCH_MEMORY_DIRS, upgradeManagedGitignore, describeGitignoreUpgrade, memoryIgnoreRule, describeMemoryIgnoreRule } from "../integrations/gitignore.js";
67
67
  import { writeCiWorkflow } from "../integrations/ciAction.js";
@@ -210,7 +210,7 @@ registerReviewMemoryCommands(program, (records, repository, privateOnly) => {
210
210
  for (const record of records)
211
211
  store.putCapture("constraints", record, privateOnly);
212
212
  store.reindex();
213
- if (home === "public" && !store.autoCommit)
213
+ if (home === "public" && !publicFlushCommitsGrounding(store, root))
214
214
  refreshExistingGrounding(root, store);
215
215
  pumpMemoryHome(store, root, home, `hunch: capture ${records.length} sourced review rule(s)`);
216
216
  }, (repository, privateOnly) => {
@@ -447,8 +447,25 @@ function storeFor(opts = {}) {
447
447
  const { store, teamPullStatus, teamPullDetail } = openTeamStore(root, opts);
448
448
  return { store, root, teamPullStatus, teamPullDetail };
449
449
  }
450
+ /** Whether a public memory flush commits the tracked grounding docs itself. When it
451
+ * won't (auto-commit off, or held on the default branch), a by-hand command refreshes
452
+ * them on disk instead, so CLAUDE.md never keeps enforcing what the command just changed. */
453
+ function publicFlushCommitsGrounding(store, root) {
454
+ return store.autoCommit && !holdsOnDefaultBranch(hunchPaths(root).hunch);
455
+ }
456
+ // A held public flush says so once per command, on stderr: stdout may be a --json payload.
457
+ let heldNoticePrinted = false;
458
+ function noteHeld() {
459
+ if (heldNoticePrinted)
460
+ return;
461
+ heldNoticePrinted = true;
462
+ console.error(` ↳ ${HELD_ON_DEFAULT_BRANCH}`);
463
+ }
450
464
  function pumpMemoryHome(store, root, home, message) {
451
- flushMemoryHome(store, hunchPaths(root).hunch, home, message);
465
+ const flush = flushMemoryHome(store, hunchPaths(root).hunch, home, message);
466
+ if (flush === "held")
467
+ noteHeld();
468
+ return flush;
452
469
  }
453
470
  function pumpPolicyHome(store, root, service, policyId, message) {
454
471
  const home = service.repository.homeOfPolicy(policyId);
@@ -457,7 +474,9 @@ function pumpPolicyHome(store, root, service, policyId, message) {
457
474
  pumpMemoryHome(store, root, home, message);
458
475
  }
459
476
  function pumpMemoryHomes(store, root, homes, message) {
460
- flushMemoryHomes(store, hunchPaths(root).hunch, homes, message);
477
+ const flushes = flushMemoryHomes(store, hunchPaths(root).hunch, homes, message);
478
+ if (flushes.public === "held")
479
+ noteHeld();
461
480
  }
462
481
  // ---- init -----------------------------------------------------------------
463
482
  program
@@ -767,7 +786,7 @@ program
767
786
  // while the docs are still Git-clean so it can stage them atomically with
768
787
  // the graph. Pre-refreshing would make the safety filter treat generated
769
788
  // docs as user-dirty and leave them stranded outside the memory commit.
770
- const healed = shouldAutoCommit ? [] : refreshExistingGrounding(root, store);
789
+ const healed = shouldAutoCommit && publicFlushCommitsGrounding(store, root) ? [] : refreshExistingGrounding(root, store);
771
790
  console.log(`Indexed ${res.files} files:`);
772
791
  console.log(` ${counts.symbols} symbols, ${counts.edges} edges, ${counts.components} components`);
773
792
  for (const item of res.coverage) {
@@ -995,7 +1014,13 @@ program
995
1014
  // each home's artifacts into at most one memory commit.
996
1015
  const publicCapture = r.status === "written" && !toOverlay;
997
1016
  const privateCapture = r.status === "written" && toOverlay;
998
- const groundingToStage = publicCapture || graphRefreshed || publicRepairTouched ? refreshCommittableGrounding(root, store) : [];
1017
+ const publicNeedsFlush = publicCapture || graphRefreshed || publicRepairTouched;
1018
+ // The default-branch hold covers the post-commit hook too. Its baked-in --commit is
1019
+ // the installed auto-commit default, so only a --commit typed by hand overrides it.
1020
+ // Decided before the grounding refresh: a held capture leaves tracked docs at HEAD,
1021
+ // so `git commit -a` cannot commit counts whose record it leaves untracked.
1022
+ const publicHeld = doCommit && publicNeedsFlush && (opts.fromHook || opts.commit === undefined) && holdsOnDefaultBranch(hunchPaths(root).hunch);
1023
+ const groundingToStage = publicNeedsFlush && !publicHeld ? refreshCommittableGrounding(root, store) : [];
999
1024
  if (!opts.fromHook && (r.status === "written" || graphRefreshed || publicRepairTouched)) {
1000
1025
  const healed = refreshExistingGrounding(root, store);
1001
1026
  const refreshed = [...new Set([...groundingToStage.map((file) => relative(root, file)), ...healed])];
@@ -1003,9 +1028,10 @@ program
1003
1028
  console.log(` ↳ grounding refreshed: ${refreshed.join(", ")}`);
1004
1029
  }
1005
1030
  if (doCommit) {
1006
- const publicNeedsFlush = publicCapture || graphRefreshed || publicRepairTouched;
1007
1031
  const privateNeedsFlush = privateCapture || (privateCorrectionQueued && graphRefreshed) || g2Recorded > 0 || privateRepairTouched;
1008
- const publicResult = publicNeedsFlush
1032
+ if (publicHeld && !opts.quiet)
1033
+ console.log(` ↳ ${HELD_ON_DEFAULT_BRANCH}`);
1034
+ const publicResult = publicNeedsFlush && !publicHeld
1009
1035
  ? commitAndPushHunch(hunchPaths(root).hunch, publicCapture
1010
1036
  ? `hunch: capture ${r.decision?.id ?? "decision"}`
1011
1037
  : graphRefreshed
@@ -2283,12 +2309,13 @@ program
2283
2309
  }
2284
2310
  }
2285
2311
  store.reindex();
2286
- if (dec || con)
2287
- flushCapture(store, hunchPaths(root).hunch, !!opts.private, `hunch: capture ${dec + con} inline intent(s)`);
2312
+ const flush = dec || con ? flushCapture(store, hunchPaths(root).hunch, !!opts.private, `hunch: capture ${dec + con} inline intent(s)`) : null;
2288
2313
  if (!intents.length)
2289
2314
  console.log("No `hunch-why:` / `hunch-rule:` comments found.");
2290
2315
  else
2291
2316
  console.log(`✓ captured ${dec} decision(s) + ${con} constraint(s) from inline comments${opts.private ? " [private overlay]" : ""}`);
2317
+ if (flush === "held")
2318
+ console.log(` ↳ ${HELD_ON_DEFAULT_BRANCH}`);
2292
2319
  store.close();
2293
2320
  });
2294
2321
  // ---- conform (Architectural Conformance: does the code still satisfy recorded intent) ----
@@ -2330,7 +2357,7 @@ program
2330
2357
  valid_from: now,
2331
2358
  });
2332
2359
  store.reindex();
2333
- if (home === "public" && !store.autoCommit)
2360
+ if (home === "public" && !publicFlushCommitsGrounding(store, root))
2334
2361
  refreshExistingGrounding(root, store);
2335
2362
  pumpMemoryHome(store, root, home, `hunch: record architectural invariant ${d.id}`);
2336
2363
  console.log(`✓ recorded architectural invariant ${d.id}: "${opts.add}"`);
@@ -4047,7 +4074,7 @@ program
4047
4074
  }
4048
4075
  store.reindex();
4049
4076
  const home = store.captureHome(!!opts.private);
4050
- if (home === "public" && !store.autoCommit)
4077
+ if (home === "public" && !publicFlushCommitsGrounding(store, root))
4051
4078
  refreshExistingGrounding(root, store);
4052
4079
  const flush = flushCapture(store, hunchPaths(root).hunch, !!opts.private, `hunch: import ${decisions.length} ADR(s) from ${dir}`);
4053
4080
  const live = decisions.filter((d) => d.status === "accepted").length;
@@ -4066,6 +4093,8 @@ program
4066
4093
  }
4067
4094
  if (flush === "pushed")
4068
4095
  console.log(" ↳ private memory committed + pushed");
4096
+ if (flush === "held")
4097
+ console.log(` ↳ ${HELD_ON_DEFAULT_BRANCH}`);
4069
4098
  }
4070
4099
  finally {
4071
4100
  store.close();
@@ -4203,7 +4232,7 @@ program
4203
4232
  store.reindex();
4204
4233
  // Public grounding is a publishable artifact. Private rules stay local and
4205
4234
  // are surfaced by local checks/MCP, never copied into committed agent docs.
4206
- if (home === "public" && !store.autoCommit)
4235
+ if (home === "public" && !publicFlushCommitsGrounding(store, root))
4207
4236
  refreshExistingGrounding(root, store);
4208
4237
  const flush = flushCapture(store, hunchPaths(root).hunch, !!opts.private, `hunch: capture ${c.id}`);
4209
4238
  console.log(`✓ recorded ${c.severity} constraint ${c.id}: "${c.statement}" (scope: ${scope.join(", ") || "repo"})${opts.private ? " [private overlay]" : ""}`);
@@ -4217,6 +4246,8 @@ program
4217
4246
  }
4218
4247
  if (flush === "pushed")
4219
4248
  console.log(" ↳ private memory committed + pushed");
4249
+ if (flush === "held")
4250
+ console.log(` ↳ ${HELD_ON_DEFAULT_BRANCH}`);
4220
4251
  store.close();
4221
4252
  });
4222
4253
  // ---- test (failure-learning loop) -----------------------------------------
@@ -4960,7 +4991,7 @@ program
4960
4991
  // is on, but no commit happens to carry that refresh when it's off, so do it here
4961
4992
  // too — otherwise a retired constraint keeps showing as enforced on disk (the same
4962
4993
  // gap record-constraint and `conform --add` close for their own capture path).
4963
- if (home === "public" && !store.autoCommit)
4994
+ if (home === "public" && !publicFlushCommitsGrounding(store, root))
4964
4995
  refreshExistingGrounding(root, store);
4965
4996
  // `--reason` goes in the commit BODY, never the subject: `hunch log`'s move
4966
4997
  // classifier (src/core/memorylog.ts) regexes the commit SUBJECT for keywords
@@ -4968,11 +4999,14 @@ program
4968
4999
  // in the subject could accidentally match one and misclassify the move.
4969
5000
  const willCommit = home === "private" ? store.privateAutoCommit : store.autoCommit;
4970
5001
  const message = `hunch: retire constraint ${id}${opts.reason ? `\n\n${opts.reason}` : ""}`;
4971
- pumpMemoryHome(store, root, home, message);
5002
+ const flush = pumpMemoryHome(store, root, home, message);
4972
5003
  console.log(`✓ ${retired.id} retired — window closed at ${retired.valid_to?.slice(0, 10)}.`);
4973
5004
  if (opts.reason && !willCommit) {
4974
5005
  console.log(" ⚠ --reason is not recorded anywhere: auto-commit is off, so no commit message captured it.");
4975
5006
  }
5007
+ else if (opts.reason && flush === "held") {
5008
+ console.log(" ⚠ --reason is not recorded anywhere: the commit was held on the default branch, so no commit message captured it.");
5009
+ }
4976
5010
  store.close();
4977
5011
  });
4978
5012
  // ---- firmness (agent-hook enforcement level) ------------------------------
@@ -5198,10 +5232,14 @@ program
5198
5232
  if (!evt)
5199
5233
  return;
5200
5234
  eventName = evt.hook_event_name;
5201
- const root = findRoot();
5235
+ // Grounding reads the store of the checkout the event's file is in; the prompt's
5236
+ // task and the hook evidence stay with the process root that opened them.
5237
+ const processRoot = findRoot();
5238
+ const routed = hookEventRoot(processRoot, evt);
5239
+ const root = routed.root;
5202
5240
  // The host delivered this event: runtime evidence for `hunch integrations check`,
5203
5241
  // recorded before any policy decision so firmness never hides delivery itself.
5204
- recordHookObservation(root, provider, evt.hook_event_name, evt.tool_outcome?.status);
5242
+ recordHookObservation(processRoot, provider, evt.hook_event_name, evt.tool_outcome?.status);
5205
5243
  const paths = hunchPaths(root);
5206
5244
  const firmness = readConfig(paths).firmness;
5207
5245
  if (firmness === "off")
@@ -5225,7 +5263,7 @@ program
5225
5263
  // Same rule as the pre-edit path: strict never grounds from stale team rules.
5226
5264
  const stale = firmness === "strict" && opened.teamPullStatus && !teamPullIsFresh(opened.teamPullStatus);
5227
5265
  if (!stale)
5228
- shellGround = shellWriteGrounding(root, store, provider, evt, written);
5266
+ shellGround = shellWriteGrounding(root, store, provider, evt, written, processRoot);
5229
5267
  }
5230
5268
  }
5231
5269
  else {
@@ -5582,9 +5620,13 @@ program
5582
5620
  return;
5583
5621
  }
5584
5622
  const targets = editTargets(root, evt.tool_input);
5585
- // Nothing inside the repo → nothing for Hunch to say.
5586
- if (!targets.length)
5623
+ // Nothing inside the repo → nothing for Hunch to say, except for a file in a
5624
+ // linked worktree of this repository Hunch could not ground (never silently).
5625
+ if (!targets.length) {
5626
+ if (routed.ungrounded)
5627
+ emitContext(provider, "PreToolUse", routed.ungrounded);
5587
5628
  return;
5629
+ }
5588
5630
  // Grounding below stays about the first in-repo file; the strict gate checks every target.
5589
5631
  const { abs, target } = targets[0];
5590
5632
  // A compiled red→green probe is only meaningful if its red receipt exists
@@ -5646,12 +5688,12 @@ program
5646
5688
  : `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
5689
  emitDeny(provider, reason);
5648
5690
  for (const d of denials)
5649
- observeHookDenial(root, provider, evt, d.target, d.hit);
5691
+ observeHookDenial(processRoot, provider, evt, d.target, d.hit);
5650
5692
  return;
5651
5693
  }
5652
5694
  }
5653
5695
  // advisory / firm / strict(non-blocking): inject the relevant Hunch slice.
5654
- const grounded = fileGrounding(root, store, provider, evt, target, abs);
5696
+ const grounded = fileGrounding(root, store, provider, evt, target, abs, processRoot);
5655
5697
  if (!grounded)
5656
5698
  return; // no noise on files Hunch hasn't learned yet
5657
5699
  emitContext(provider, "PreToolUse", grounded.text, grounded.mode === "full" ? grounded.recalled ?? undefined : undefined);
@@ -5830,7 +5872,7 @@ program
5830
5872
  store.reindex();
5831
5873
  if (home === "public") {
5832
5874
  publicGroundingChanged = true;
5833
- if (!store.autoCommit)
5875
+ if (!publicFlushCommitsGrounding(store, root))
5834
5876
  refreshExistingGrounding(root, store);
5835
5877
  }
5836
5878
  console.log(opts.approveImport
@@ -5890,7 +5932,7 @@ program
5890
5932
  store.reindex();
5891
5933
  if (home === "public") {
5892
5934
  publicGroundingChanged = true;
5893
- if (!store.autoCommit)
5935
+ if (!publicFlushCommitsGrounding(store, root))
5894
5936
  refreshExistingGrounding(root, store);
5895
5937
  }
5896
5938
  }
@@ -5909,7 +5951,7 @@ program
5909
5951
  store.reindex();
5910
5952
  if (home === "public") {
5911
5953
  publicGroundingChanged = true;
5912
- if (!store.autoCommit)
5954
+ if (!publicFlushCommitsGrounding(store, root))
5913
5955
  refreshExistingGrounding(root, store);
5914
5956
  }
5915
5957
  console.log(`✓ accepted ${opts.accept} (now ${source}, confidence 0.95${armed ? `, ${armed} tripwire(s) now blocking` : ""})`);
@@ -5968,7 +6010,7 @@ program
5968
6010
  armedTotal += accepted.armed;
5969
6011
  }
5970
6012
  store.reindex();
5971
- if (publicGroundingChanged && !store.autoCommit)
6013
+ if (publicGroundingChanged && !publicFlushCommitsGrounding(store, root))
5972
6014
  refreshExistingGrounding(root, store);
5973
6015
  console.log(`✓ accepted ${ready.length} verified draft(s); ${armedTotal} tripwire(s) now blocking.`);
5974
6016
  for (const it of ready)
@@ -6128,7 +6170,7 @@ program
6128
6170
  }
6129
6171
  if (accepted || rejected) {
6130
6172
  store.reindex();
6131
- if (publicAccepted && !store.autoCommit)
6173
+ if (publicAccepted && !publicFlushCommitsGrounding(store, root))
6132
6174
  refreshExistingGrounding(root, store);
6133
6175
  pumpMemoryHomes(store, root, touchedHomes, "hunch: auto-review decision lifecycle");
6134
6176
  }
@@ -7599,15 +7641,64 @@ function toRepoRel(root, abs) {
7599
7641
  return "";
7600
7642
  return rel;
7601
7643
  }
7644
+ /** The repository root a hook event belongs to. Normally the process root, but an
7645
+ * edit to a file in another LINKED worktree of the same repository (or a subagent
7646
+ * started in one) belongs to that worktree's memory: grounded against the process
7647
+ * root it was dropped as "outside the repo", with no output at all. Re-roots only
7648
+ * to a worktree sharing this repository's git common dir that carries its own
7649
+ * .hunch/; one without it is reported as ungrounded rather than skipped silently.
7650
+ * Checkouts are told apart by their `.git` entry, never by a .hunch/ hit, so a
7651
+ * nested package .hunch/ inside this checkout (a monorepo) never displaces the
7652
+ * process root and its blocking constraints. Git is spawned only for a file in a
7653
+ * different checkout than the process root's. */
7654
+ function hookEventRoot(processRoot, evt) {
7655
+ const toolEvent = evt.hook_event_name === "PreToolUse" || evt.hook_event_name === "PostToolUse" || evt.hook_event_name === "PostToolUseFailure";
7656
+ const subject = evt.hook_event_name === "SubagentStart" ? evt.cwd
7657
+ : toolEvent ? (evt.tool_input?.patch_files?.[0]?.path ?? evt.tool_input?.file_path) : undefined;
7658
+ if (!subject || !isAbsolute(subject))
7659
+ return { root: processRoot };
7660
+ try {
7661
+ const ownerTop = checkoutTop(evt.hook_event_name === "SubagentStart" ? subject : dirname(subject));
7662
+ const processTop = checkoutTop(processRoot);
7663
+ if (!ownerTop || !processTop || sameFilesystemEntry(ownerTop, processTop))
7664
+ return { root: processRoot };
7665
+ const common = checkoutCommonDir(processRoot);
7666
+ const ownerCommon = common ? checkoutCommonDir(ownerTop) : "";
7667
+ if (!common || !ownerCommon || !sameFilesystemEntry(common, ownerCommon))
7668
+ return { root: processRoot };
7669
+ // The same Hunch root inside the other worktree: its top, or the package dir a
7670
+ // subdirectory-rooted process uses.
7671
+ const owner = join(ownerTop, relative(processTop, processRoot));
7672
+ if (isDir(join(owner, ".hunch")))
7673
+ return { root: owner };
7674
+ return { root: processRoot, ungrounded: `Hunch: ${toPosixTarget(subject)} is in linked worktree ${toPosixTarget(ownerTop)}, which has no .hunch/ — this edit proceeds ungrounded.` };
7675
+ }
7676
+ catch {
7677
+ return { root: processRoot };
7678
+ }
7679
+ }
7680
+ /** The nearest directory at or above `start` holding a `.git` entry (a checkout's top;
7681
+ * a linked worktree's `.git` is a file), or "" outside every checkout. Pure fs. */
7682
+ function checkoutTop(start) {
7683
+ let cur = resolve(start);
7684
+ for (;;) {
7685
+ if (existsSync(join(cur, ".git")))
7686
+ return cur;
7687
+ const parent = dirname(cur);
7688
+ if (parent === cur)
7689
+ return "";
7690
+ cur = parent;
7691
+ }
7692
+ }
7602
7693
  /** Files a shell command wrote get the grounding the edit tools would have
7603
7694
  * delivered before the edit — late, but while the agent can still revise.
7604
7695
  * Already-served grounding (a delta) is not repeated. */
7605
7696
  const MAX_SHELL_GROUNDED = 3;
7606
- function shellWriteGrounding(root, store, provider, evt, written) {
7697
+ function shellWriteGrounding(root, store, provider, evt, written, ledgerRoot = root) {
7607
7698
  const parts = [];
7608
7699
  const grounded = [];
7609
7700
  for (const target of written.slice(0, MAX_SHELL_GROUNDED)) {
7610
- const g = fileGrounding(root, store, provider, evt, target, join(root, target));
7701
+ const g = fileGrounding(root, store, provider, evt, target, join(root, target), ledgerRoot);
7611
7702
  if (g?.mode === "full") {
7612
7703
  parts.push(g.text);
7613
7704
  grounded.push(target);
@@ -7726,8 +7817,9 @@ const SIBLING_MEMORY_BUDGET_TOKENS = 800;
7726
7817
  * retired code, doc anchors, recent tasks and sibling-fix lessons. `delta` when
7727
7818
  * identical grounding was already served this session, null when Hunch knows
7728
7819
  * 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) {
7820
+ * wrote (which never pass through the edit tools). `ledgerRoot` is where the prompt's
7821
+ * task lives — the process root when the event was re-rooted to a linked worktree. */
7822
+ function fileGrounding(root, store, provider, evt, target, abs, ledgerRoot = root) {
7731
7823
  // Decision-grounding for PROSE (doc≠graph): a markdown target that declares
7732
7824
  // <!-- hunch:topic … --> anchors gets each topic's CURRENT decision — the
7733
7825
  // graph outranks the prose being edited, and a stale pin is called out inline.
@@ -7744,7 +7836,7 @@ function fileGrounding(root, store, provider, evt, target, abs) {
7744
7836
  // at warning, not relevance guesses. No selection (a host without a prompt
7745
7837
  // hook, a legacy session, a prompt that selected nothing) keeps the unfiltered
7746
7838
  // grounding.
7747
- const selection = loadTaskSelection(hookReportTaskId(root, provider, evt));
7839
+ const selection = loadTaskSelection(hookReportTaskId(ledgerRoot, provider, evt));
7748
7840
  const assembled = store.assembleContext(target);
7749
7841
  const selected = selection ? new Set(selection.qualifying) : null;
7750
7842
  const ctx = selected ? {
@@ -7779,7 +7871,7 @@ function fileGrounding(root, store, provider, evt, target, abs) {
7779
7871
  const retired = store.retiredForFile(target).filter((r) => r.symbols.length || r.deps.length);
7780
7872
  // Recent-task history is advisory; under a task selection it is dropped
7781
7873
  // (`hunch task list` still has it).
7782
- const recentTasks = selected ? [] : taskSelectionSupplements(store.selectTasksAuto(target, buildTaskRankingQuery(root, hookReportTaskId(root, provider, evt), target, { excludeTargetDeliveries: true })), target);
7874
+ const recentTasks = selected ? [] : taskSelectionSupplements(store.selectTasksAuto(target, buildTaskRankingQuery(ledgerRoot, hookReportTaskId(ledgerRoot, provider, evt), target, { excludeTargetDeliveries: true })), target);
7783
7875
  const hasContent = ctx.constraints.length ||
7784
7876
  ctx.decisions.length ||
7785
7877
  ctx.bugs.length ||
@@ -7843,7 +7935,7 @@ function fileGrounding(root, store, provider, evt, target, abs) {
7843
7935
  delivery_profile: envelope.profile, ranking_policy: envelope.ranking_policy,
7844
7936
  })),
7845
7937
  ]);
7846
- const reportTaskId = hookReportTaskId(root, provider, evt);
7938
+ const reportTaskId = hookReportTaskId(ledgerRoot, provider, evt);
7847
7939
  // A new authoritative prompt gets its own full delivery. An earlier
7848
7940
  // prompt's session-level delta cannot establish this task's receipt.
7849
7941
  // A subagent reports to the prompt's task but starts with FRESH context:
@@ -7870,8 +7962,8 @@ function fileGrounding(root, store, provider, evt, target, abs) {
7870
7962
  const snapshots = snapshotDeliveredRecords(store, envelope);
7871
7963
  // The first time a lesson reaches this prompt's task, tell the USER in one
7872
7964
  // 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);
7965
+ recalled = reportPresentationEnabled(ledgerRoot) ? renderRecalledLine(unseenLessons(ledgerRoot, reportTaskId, snapshots)) : null;
7966
+ const occurrence = recordTaskDelivery(ledgerRoot, reportTaskId, envelope, snapshots, undefined, target);
7875
7967
  reportNotice = `\n\nHunch task ${reportTaskId} · delivery ${occurrence}. Inspect exact application references with hunch_report(task_id).`;
7876
7968
  }
7877
7969
  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,23 @@ 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) and
34
+ * injected context (`<system-reminder>`, hook output). Their words are not the
35
+ * human's, so "never use X" quoted from a constraint is not a correction. A tag
36
+ * left unclosed drops only the rest of its own line, never the human's text below. */
37
+ const HOST_TAGS = "task-notification|system-reminder|local-command-stdout|local-command-stderr|command-message|command-name|command-args";
38
+ const HOST_BLOCK = new RegExp(`<(${HOST_TAGS})>[\\s\\S]*?<\\/\\1>`, "gi");
39
+ const HOST_OPEN_LINE = new RegExp(`<(?:${HOST_TAGS})>[^\\n]*`, "gi");
40
+ /** The prompt with host-generated blocks removed: what the human actually typed. */
41
+ export function stripHostBlocks(prompt) {
42
+ return prompt.replace(HOST_BLOCK, " ").replace(HOST_OPEN_LINE, " ");
43
+ }
32
44
  export function looksLikeCorrection(prompt) {
33
45
  if (!prompt || typeof prompt !== "string")
34
46
  return false;
35
- return CORRECTION_PATTERNS.some((re) => re.test(prompt));
47
+ const human = stripHostBlocks(prompt);
48
+ return CORRECTION_PATTERNS.some((re) => re.test(human));
36
49
  }
37
50
  /** One-line nudge appended to the UserPromptSubmit hook context when a prompt
38
51
  * reads like a correction — surfaces the write tool so the rule gets ENFORCED,
@@ -35,7 +35,7 @@ export declare function computeSupersedes(record: TaskRecord, others: readonly T
35
35
  export interface PersistedTaskRecord {
36
36
  record: TaskRecord;
37
37
  home: TaskRecordHome;
38
- flushed: "pushed" | "committed" | null;
38
+ flushed: "pushed" | "committed" | "held" | null;
39
39
  /** False when the same report revision was already in the graph. */
40
40
  changed: boolean;
41
41
  }
@@ -15,7 +15,7 @@ import { join } from "node:path";
15
15
  import { flushCapture } from "../integrations/sync.js";
16
16
  import { hunchPaths } from "./paths.js";
17
17
  import { episodeTasks, isEmptyTaskReport, readTaskReport, reportHash, sessionsOverlap } from "./taskReport.js";
18
- import { reportSourceSnapshot } from "./taskReportEvidence.js";
18
+ import { currentSource } from "./taskReportEvidence.js";
19
19
  import { ENTITY_KINDS, TaskRecordSchema } from "./types.js";
20
20
  import { refreshRankEval } from "./taskRankingMode.js";
21
21
  import { gitTouchedFiles } from "./taskTouched.js";
@@ -172,7 +172,7 @@ export function computeSupersedes(record, others, limit = 20) {
172
172
  export function persistTaskRecord(root, store, taskId, options = {}) {
173
173
  if (!taskRecordsEnabled(root))
174
174
  return null;
175
- const snapshot = reportSourceSnapshot(root).hash;
175
+ const snapshot = currentSource(root);
176
176
  const own = readTaskReport(root, taskId, snapshot);
177
177
  if (own.task.state === "open")
178
178
  return null;
@@ -30,6 +30,7 @@ export declare const ReportCheckSchema: z.ZodObject<{
30
30
  snapshot_limitations: z.ZodArray<z.ZodString>;
31
31
  timed_out: z.ZodBoolean;
32
32
  cancelled: z.ZodOptional<z.ZodBoolean>;
33
+ work_root: z.ZodOptional<z.ZodString>;
33
34
  source: z.ZodLiteral<"local-command-runner">;
34
35
  }, z.core.$strict>;
35
36
  export type ReportCheck = z.infer<typeof ReportCheckSchema>;
@@ -321,8 +322,8 @@ export interface TaskSummary {
321
322
  }
322
323
  /** One bounded summary per recent task for status lines and host views; the
323
324
  * card and evidence view remain the authoritative renderings. */
324
- export declare function summarizeTaskReport(root: string, taskId: string, currentSnapshot?: string | null): TaskSummary;
325
- export declare function listTaskSummaries(root: string, limit?: number, currentSnapshot?: string | null): TaskSummary[];
325
+ export declare function summarizeTaskReport(root: string, taskId: string, currentSnapshot?: CurrentSnapshot): TaskSummary;
326
+ export declare function listTaskSummaries(root: string, limit?: number, currentSnapshot?: CurrentSnapshot): TaskSummary[];
326
327
  /** One line for a terminal status line. Empty string when nothing was observed
327
328
  * for the task, so a bare prompt shows no Hunch noise at all. */
328
329
  export declare function renderTaskStatusLine(summary: TaskSummary | null): string;
@@ -348,5 +349,9 @@ export declare function reportActivity(root: string): string;
348
349
  export declare function forgetReportTask(root: string, taskId: string): void;
349
350
  export declare function pruneReportHistory(root: string, olderThanDays?: number): number;
350
351
  export declare function reportPresentationEnabled(root: string): boolean;
351
- export declare function readTaskReport(root: string, taskId: string, currentSnapshot?: string | null): TaskReport;
352
+ /** What a reader compares evidence snapshots against: the report root's current source
353
+ * hash, or a resolver that also hashes another worktree a check ran in. A plain hash
354
+ * never vouches for a check that ran elsewhere. */
355
+ export type CurrentSnapshot = string | null | ((dir?: string) => string | null);
356
+ export declare function readTaskReport(root: string, taskId: string, currentSnapshot?: CurrentSnapshot): TaskReport;
352
357
  export {};
@@ -43,6 +43,9 @@ export const ReportCheckSchema = z.object({
43
43
  snapshot_limitations: z.array(z.string().max(300)).max(16),
44
44
  timed_out: z.boolean(),
45
45
  cancelled: z.boolean().optional(),
46
+ /** Where the command ran, recorded only when that is not this ledger's root (a
47
+ * sibling worktree): its currentness is judged against that tree's source. */
48
+ work_root: z.string().min(1).max(4_096).optional(),
46
49
  source: z.literal("local-command-runner"),
47
50
  }).strict();
48
51
  export const ReportSaveSchema = z.object({
@@ -717,6 +720,7 @@ export function reportPresentationEnabled(root) {
717
720
  }
718
721
  }
719
722
  export function readTaskReport(root, taskId, currentSnapshot = null) {
723
+ const snapshotAt = (dir) => typeof currentSnapshot === "function" ? currentSnapshot(dir) : dir === undefined ? currentSnapshot : null;
720
724
  const { task, events } = taskDb(root, db => transaction(db, () => {
721
725
  const task = readTask(db, root, taskId);
722
726
  const { bytes } = db.prepare("SELECT COALESCE(SUM(length(CAST(body AS BLOB))), 0) AS bytes FROM report_events WHERE task_id = ?").get(taskId);
@@ -743,7 +747,7 @@ export function readTaskReport(root, taskId, currentSnapshot = null) {
743
747
  claims.push({ ...ReportClaimSchema.parse(value), at: event.at, attribution: "agent-reported", supported_by: null });
744
748
  else if (event.kind === "conformance") {
745
749
  const rule = ReportConformanceSchema.parse(value);
746
- conformance.push({ ...rule, event_id: event.event_id, at: event.at, current: currentSnapshot !== null && rule.snapshot === currentSnapshot });
750
+ conformance.push({ ...rule, event_id: event.event_id, at: event.at, current: snapshotAt() !== null && rule.snapshot === snapshotAt() });
747
751
  }
748
752
  else if (event.kind === "save")
749
753
  saves.push({ ...ReportSaveSchema.parse(value), event_id: event.event_id, at: event.at, durability: "local", proofs: [] });
@@ -760,7 +764,8 @@ export function readTaskReport(root, taskId, currentSnapshot = null) {
760
764
  refusals.push({ ...ReportRefusalSchema.parse(value), event_id: event.event_id, at: event.at });
761
765
  else if (event.kind === "check") {
762
766
  const check = ReportCheckSchema.parse(value);
763
- checks.push({ ...check, at: event.at, current: currentSnapshot !== null && check.before_snapshot === currentSnapshot && check.after_snapshot === currentSnapshot });
767
+ const now = snapshotAt(check.work_root);
768
+ checks.push({ ...check, at: event.at, current: now !== null && check.before_snapshot === now && check.after_snapshot === now });
764
769
  }
765
770
  else if (event.kind !== "check-start")
766
771
  throw new Error("unsupported report event kind; update Hunch before reading this report");
@@ -9,6 +9,9 @@ export interface ReportSnapshot {
9
9
  /** Bounded source snapshot, not a committed change proof. Ignored files and
10
10
  * Hunch's own memory/cache are excluded and explicitly disclosed. */
11
11
  export declare function reportSourceSnapshot(root: string): ReportSnapshot;
12
+ /** The current source of `root` for report readers, as a resolver that also hashes the
13
+ * sibling worktree a check ran in (its `work_root`). Each tree is hashed at most once. */
14
+ export declare function currentSource(root: string): (dir?: string) => string | null;
12
15
  /** Evaluate every delivered lesson's DECLARED rule against the files this task
13
16
  * changed (working tree vs HEAD). Deterministic and Hunch-owned: the agent
14
17
  * neither chooses the rule nor submits the verdict. A constraint is judged by its
@@ -28,4 +31,8 @@ export declare function runReportCheck(root: string, taskId: string, command: st
28
31
  signal?: AbortSignal;
29
32
  onStdout?: (chunk: Buffer) => void;
30
33
  onStderr?: (chunk: Buffer) => void;
34
+ /** Where the command runs and whose source is snapshotted, when the task's
35
+ * ledger lives in a sibling worktree of the same repository (the hook opened
36
+ * the task at the main checkout, the agent works in a linked one). */
37
+ workRoot?: string;
31
38
  }): Promise<ReportCheck>;
@@ -87,6 +87,16 @@ export function reportSourceSnapshot(root) {
87
87
  return { hash: null, limitations: [...limitations, "A complete bounded source snapshot was unavailable; no current-change verification is claimed."] };
88
88
  }
89
89
  }
90
+ /** The current source of `root` for report readers, as a resolver that also hashes the
91
+ * sibling worktree a check ran in (its `work_root`). Each tree is hashed at most once. */
92
+ export function currentSource(root) {
93
+ const seen = new Map();
94
+ return (dir = root) => {
95
+ if (!seen.has(dir))
96
+ seen.set(dir, reportSourceSnapshot(dir).hash);
97
+ return seen.get(dir);
98
+ };
99
+ }
90
100
  /** Evaluate every delivered lesson's DECLARED rule against the files this task
91
101
  * changed (working tree vs HEAD). Deterministic and Hunch-owned: the agent
92
102
  * neither chooses the rule nor submits the verdict. A constraint is judged by its
@@ -210,7 +220,8 @@ export async function runReportCheck(root, taskId, command, label, timeoutMs = D
210
220
  const task = readTaskReport(root, taskId).task;
211
221
  if (task.state !== "open" && task.closed_by !== "host")
212
222
  throw new Error("cannot verify a closed task");
213
- const before = reportSourceSnapshot(root);
223
+ const workRoot = options.workRoot ?? root;
224
+ const before = reportSourceSnapshot(workRoot);
214
225
  // Validate sensitive arguments before executing or writing anything.
215
226
  ReportCheckSchema.parse({ label, command, exit_code: null, output_hash: reportHash(""), before_snapshot: before.hash, after_snapshot: null, snapshot_limitations: before.limitations, timed_out: false, source: "local-command-runner" });
216
227
  const checkId = beginReportCheck(root, taskId, label, timeoutMs);
@@ -228,7 +239,7 @@ export async function runReportCheck(root, taskId, command, label, timeoutMs = D
228
239
  const started = (() => {
229
240
  try {
230
241
  const resolved = resolveSpawnCommand(command);
231
- return spawn(resolved.file, resolved.args, { cwd: root, shell: false, stdio: ["ignore", "pipe", "pipe"], windowsHide: true, detached: process.platform !== "win32", windowsVerbatimArguments: resolved.windowsVerbatimArguments === true });
242
+ return spawn(resolved.file, resolved.args, { cwd: workRoot, shell: false, stdio: ["ignore", "pipe", "pipe"], windowsHide: true, detached: process.platform !== "win32", windowsVerbatimArguments: resolved.windowsVerbatimArguments === true });
232
243
  }
233
244
  catch (error) {
234
245
  // An argument the launcher cannot carry, or a synchronous spawn refusal
@@ -319,9 +330,10 @@ export async function runReportCheck(root, taskId, command, label, timeoutMs = D
319
330
  if (options.signal?.aborted)
320
331
  cancel();
321
332
  });
322
- const after = reportSourceSnapshot(root);
333
+ const after = reportSourceSnapshot(workRoot);
323
334
  const check = { check_id: checkId, label, command, exit_code: result.code, output_hash: result.hash, before_snapshot: before.hash, after_snapshot: after.hash,
324
- snapshot_limitations: [...new Set([...before.limitations, ...after.limitations])], timed_out: result.timedOut, cancelled: result.cancelled, source: "local-command-runner" };
335
+ snapshot_limitations: [...new Set([...before.limitations, ...after.limitations])], timed_out: result.timedOut, cancelled: result.cancelled,
336
+ ...(workRoot !== root ? { work_root: workRoot } : {}), source: "local-command-runner" };
325
337
  recordReportCheck(root, taskId, check);
326
338
  return check;
327
339
  }
@@ -6,7 +6,7 @@ import { findRoot } from "./paths.js";
6
6
  import { canonicalReportRoot } from "./taskReportPaths.js";
7
7
  import { isCredentialFreeText } from "./types.js";
8
8
  import { aliasReportTask, continuationLinks, finishReportTask, isEmptyTaskReport, latestSessionTask, readTaskReport, recordReportRefusal, reportHash, reportPresentationEnabled, reportTaskExists, resolveReportTask, settleSessionTasks, startReportTask } from "./taskReport.js";
9
- import { reportSourceSnapshot } from "./taskReportEvidence.js";
9
+ import { currentSource } from "./taskReportEvidence.js";
10
10
  import { renderTaskReport } from "./taskReportRender.js";
11
11
  import { verificationLauncher } from "./verifyLauncher.js";
12
12
  import { injectionMode } from "./hookcache.js";
@@ -290,7 +290,7 @@ export function stopHookReport(root, provider, event) {
290
290
  if (id === "legacy")
291
291
  return { systemMessage: "Hunch hook active. This host version does not provide an exact prompt identifier, so contribution for this response is unverified. Explicit task reports remain available with hunch report." };
292
292
  try {
293
- const report = readTaskReport(root, id, reportSourceSnapshot(root).hash);
293
+ const report = readTaskReport(root, id, currentSource(root));
294
294
  if (isEmptyTaskReport(report))
295
295
  return null;
296
296
  // The HTML evidence view is a rendering of the local ledger, generated on
@@ -3,9 +3,9 @@
3
3
  import { JsonStore } from "../store/jsonStore.js";
4
4
  import { hunchPaths } from "./paths.js";
5
5
  import { readTaskReport, reportHash } from "./taskReport.js";
6
- import { reportSourceSnapshot } from "./taskReportEvidence.js";
6
+ import { currentSource } from "./taskReportEvidence.js";
7
7
  export function publicTaskReport(root, taskId) {
8
- const report = readTaskReport(root, taskId, reportSourceSnapshot(root).hash);
8
+ const report = readTaskReport(root, taskId, currentSource(root));
9
9
  const publicStore = new JsonStore(hunchPaths(root));
10
10
  const records = new Map();
11
11
  const deliveries = [];
@@ -1,11 +1,11 @@
1
1
  import { mkdirSync } from "node:fs";
2
2
  import { writeFileAtomic } from "./io.js";
3
3
  import { readTaskReport, readLessonHistory } from "./taskReport.js";
4
- import { reportSourceSnapshot } from "./taskReportEvidence.js";
4
+ import { currentSource } from "./taskReportEvidence.js";
5
5
  import { assertReportPath } from "./taskReportPaths.js";
6
6
  import { publicTaskReport, renderPublicTaskReportHtml } from "./taskReportPublic.js";
7
7
  export function writeTaskReportHtml(root, taskId, publicOnly = false) {
8
- const report = readTaskReport(root, taskId, reportSourceSnapshot(root).hash);
8
+ const report = readTaskReport(root, taskId, currentSource(root));
9
9
  const directory = assertReportPath(root, ".hunch-cache", "reports");
10
10
  mkdirSync(directory, { recursive: true });
11
11
  const file = assertReportPath(root, ".hunch-cache", "reports", `${report.task.task_id}${publicOnly ? ".public" : ""}.html`);
@@ -266,6 +266,16 @@ export declare function pathKnownToHistory(root: string, file: string): boolean;
266
266
  /** Current branch name (e.g. "main", "feat/x"), or "" in detached HEAD / non-repo.
267
267
  * Stamped onto auto-captured decisions so branch-scoped work stays filterable. */
268
268
  export declare function currentBranch(cwd: string): string;
269
+ /** Whether `cwd` has its repository's default branch checked out: origin/HEAD's branch
270
+ * when the remote names one, else `main` or `master`. False on a detached HEAD or
271
+ * outside a repository, so a caller keeps its pre-existing behavior there. */
272
+ export declare function onDefaultBranch(cwd: string): boolean;
273
+ /** Whether a memory commit of `hunchDir` would have anything to stage: a tracked file git
274
+ * sees as changed (after its own line-ending normalization) or an untracked, non-ignored
275
+ * file, skipping the derived artifacts and the per-machine local.json the commit never
276
+ * stages. A hook's inherited GIT_DIR/GIT_WORK_TREE is stripped, so the repository is
277
+ * resolved from `hunchDir` itself. False when git cannot answer. */
278
+ export declare function hunchDirHasChanges(hunchDir: string): boolean;
269
279
  /** Whether HEAD is somewhere a write must not land: mid rebase/merge/cherry-pick/revert/bisect
270
280
  * ("git-operation-in-progress", which wins when both hold), or detached ("detached-head").
271
281
  * null when HEAD is a settled branch — and also when git cannot answer at all (not a repo,
@@ -2283,6 +2283,33 @@ export function currentBranch(cwd) {
2283
2283
  const b = gitSafe(["rev-parse", "--abbrev-ref", "HEAD"], cwd);
2284
2284
  return b === "HEAD" ? "" : b; // detached HEAD reports "HEAD" — treat as no branch
2285
2285
  }
2286
+ /** Whether `cwd` has its repository's default branch checked out: origin/HEAD's branch
2287
+ * when the remote names one, else `main` or `master`. False on a detached HEAD or
2288
+ * outside a repository, so a caller keeps its pre-existing behavior there. */
2289
+ export function onDefaultBranch(cwd) {
2290
+ const branch = currentBranch(cwd);
2291
+ if (!branch)
2292
+ return false;
2293
+ const remoteHead = gitSafe(["symbolic-ref", "-q", "--short", "refs/remotes/origin/HEAD"], cwd);
2294
+ if (remoteHead)
2295
+ return branch === remoteHead.replace(/^origin\//, "");
2296
+ return branch === "main" || branch === "master";
2297
+ }
2298
+ /** Whether a memory commit of `hunchDir` would have anything to stage: a tracked file git
2299
+ * sees as changed (after its own line-ending normalization) or an untracked, non-ignored
2300
+ * file, skipping the derived artifacts and the per-machine local.json the commit never
2301
+ * stages. A hook's inherited GIT_DIR/GIT_WORK_TREE is stripped, so the repository is
2302
+ * resolved from `hunchDir` itself. False when git cannot answer. */
2303
+ export function hunchDirHasChanges(hunchDir) {
2304
+ const changed = [
2305
+ ...gitSafeIsolated(["diff", "--name-only", "--relative", "--no-renames", "HEAD", "--", "."], hunchDir).split("\n"),
2306
+ ...gitSafeIsolated(["ls-files", "--others", "--exclude-standard", "--", "."], hunchDir).split("\n"),
2307
+ ];
2308
+ return changed.some((raw) => {
2309
+ const path = raw.trim().replace(/\\/g, "/");
2310
+ return path !== "" && path !== "local.json" && !isDerivedStoreArtifact(path);
2311
+ });
2312
+ }
2286
2313
  /** The state files git writes while it is replaying history. `git rev-parse --git-path`
2287
2314
  * resolves each against THIS worktree's private git dir (a linked worktree keeps its own
2288
2315
  * rebase-merge/MERGE_HEAD), so a rebase in one worktree never reads as one in another. */
@@ -197,7 +197,7 @@ export function inspectIntegrations(root, selected) {
197
197
  try {
198
198
  const launcher = readLauncher(root, harness);
199
199
  recordPins(spec.mcp, [launcher.command, ...launcher.args]);
200
- capabilities.mcp.detail = "Configured locally; use --probe to verify a fresh server, then reconnect the host";
200
+ capabilities.mcp.detail = `Configured locally; run \`hunch integrations check --probe --harness ${harness}\` to verify a fresh server, then reconnect the host`;
201
201
  }
202
202
  catch (e) {
203
203
  // A harness can be detected here via its hooks file alone —
@@ -1,4 +1,15 @@
1
1
  import { type Harness, type IntegrationHealth } from "./health.js";
2
+ /** A launcher that runs THIS Hunch build directly (`node <build>/cli/index.js mcp`) —
3
+ * a local checkout or an unpacked test build. It is exactly the code already
4
+ * running the probe, so starting it executes nothing new; any other custom
5
+ * command stays inspect-only. The server identity/version check still applies.
6
+ * The command is a bare `node` (PATH lookup) or this exact Node binary — a
7
+ * directory-qualified `./tools/node` in a cloned repo is not, whatever its name —
8
+ * and the probe spawns process.execPath itself, never the configured name. */
9
+ export declare function isSelfLauncher(launcher: {
10
+ command: string;
11
+ args: string[];
12
+ }, entry?: string): boolean;
2
13
  /** Opt-in: starts a fresh configured MCP process, never claims the current
3
14
  * harness connection or hook delivery has been tested. No model is invoked. */
4
15
  export declare function probeIntegration(root: string, harness: Harness, report: IntegrationHealth, timeoutMs?: number): Promise<void>;
@@ -1,6 +1,27 @@
1
1
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2
2
  import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
3
+ import { existsSync } from "node:fs";
4
+ import { isAbsolute } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { sameFilesystemEntry } from "../extractors/git.js";
3
7
  import { HARNESSES, readLauncher } from "./health.js";
8
+ /** A launcher that runs THIS Hunch build directly (`node <build>/cli/index.js mcp`) —
9
+ * a local checkout or an unpacked test build. It is exactly the code already
10
+ * running the probe, so starting it executes nothing new; any other custom
11
+ * command stays inspect-only. The server identity/version check still applies.
12
+ * The command is a bare `node` (PATH lookup) or this exact Node binary — a
13
+ * directory-qualified `./tools/node` in a cloned repo is not, whatever its name —
14
+ * and the probe spawns process.execPath itself, never the configured name. */
15
+ export function isSelfLauncher(launcher, entry = fileURLToPath(new URL("../cli/index.js", import.meta.url))) {
16
+ const bareNode = /^node(?:\.exe)?$/i.test(launcher.command);
17
+ const node = bareNode || (isAbsolute(launcher.command) && existsSync(launcher.command) && sameFilesystemEntry(launcher.command, process.execPath));
18
+ if (!node || launcher.args.length !== 2 || launcher.args[1] !== "mcp")
19
+ return false;
20
+ const script = launcher.args[0];
21
+ // Generated launchers name the build entry absolutely; a relative script would resolve
22
+ // against whatever cwd the host spawns in.
23
+ return isAbsolute(script) && existsSync(script) && existsSync(entry) && sameFilesystemEntry(script, entry);
24
+ }
4
25
  /** Opt-in: starts a fresh configured MCP process, never claims the current
5
26
  * harness connection or hook delivery has been tested. No model is invoked. */
6
27
  export async function probeIntegration(root, harness, report, timeoutMs = 15_000) {
@@ -16,11 +37,15 @@ export async function probeIntegration(root, harness, report, timeoutMs = 15_000
16
37
  throw new Error("custom MCP environment or working directory requires a host-side probe; no settings were ignored");
17
38
  // Only execute the exact published launcher Hunch generates. Custom
18
39
  // commands may need credentials or have unrelated effects; inspect only.
19
- if (!/^npx(?:\.cmd)?$/.test(launcher.command) || JSON.stringify(launcher.args) !== JSON.stringify([
40
+ const generated = /^npx(?:\.cmd)?$/.test(launcher.command) && JSON.stringify(launcher.args) === JSON.stringify([
20
41
  "-y", `--package=hunch-exact@npm:@davesheffer/hunch@${report.expectedVersion}`, "hunch", "mcp",
21
- ]))
22
- throw new Error("probe requires the generated exact-version npx launcher; repair stale pins first; custom launchers remain untested");
23
- transport = new StdioClientTransport({ ...launcher, cwd: root, stderr: "pipe" });
42
+ ]);
43
+ const self = !generated && isSelfLauncher(launcher);
44
+ if (!generated && !self)
45
+ throw new Error("probe requires the generated exact-version npx launcher, or a `node <build>/cli/index.js mcp` launcher probed by that same build; repair stale pins first; other custom launchers remain untested");
46
+ // A self launcher runs this exact Node binary: no PATH/cwd lookup of `node` can
47
+ // resolve to an executable planted in the repository.
48
+ transport = new StdioClientTransport({ ...launcher, ...(self ? { command: process.execPath } : {}), cwd: root, stderr: "pipe" });
24
49
  transport.stderr?.on("data", () => { });
25
50
  await Promise.race([
26
51
  (async () => {
@@ -11,11 +11,11 @@ export type MemoryHome = "public" | "private";
11
11
  /** Flush one exact artifact home. Constitution repositories can explicitly
12
12
  * choose public even in unified mode, so this must not infer routing through
13
13
  * captureHome(isPrivate). The caller supplies the repository's actual home. */
14
- export declare function flushMemoryHome(store: HunchStore, publicHunchDir: string, home: MemoryHome, message: string, remoteOverride?: HunchRemoteContract): "pushed" | "committed" | null;
14
+ export declare function flushMemoryHome(store: HunchStore, publicHunchDir: string, home: MemoryHome, message: string, remoteOverride?: HunchRemoteContract): "pushed" | "committed" | "held" | null;
15
15
  /** One completion flush per touched home. A mixed ingest/bootstrap can write
16
16
  * both homes; unchanged homes are cheap no-ops after the memory-only stage
17
17
  * check, while each real home becomes durable exactly once. */
18
- export declare function flushMemoryHomes(store: HunchStore, publicHunchDir: string, homes: Iterable<MemoryHome>, message: string, remoteOverride?: HunchRemoteContract): Partial<Record<MemoryHome, "pushed" | "committed" | null>>;
18
+ export declare function flushMemoryHomes(store: HunchStore, publicHunchDir: string, homes: Iterable<MemoryHome>, message: string, remoteOverride?: HunchRemoteContract): Partial<Record<MemoryHome, "pushed" | "committed" | "held" | null>>;
19
19
  /** Auto-commit + push the overlay after a private write, when auto-commit is on. No-op
20
20
  * otherwise (manual `hunch private --sync` still works). Never throws. */
21
21
  export declare function flushPrivate(store: HunchStore, message: string): void;
@@ -24,7 +24,19 @@ export declare function flushPrivate(store: HunchStore, message: string): void;
24
24
  * "committed" (commit created but not pushed — public .hunch/ rides the next push; an
25
25
  * overlay commit whose merge/push failed retries on the next flush), or null (auto-commit
26
26
  * off, no overlay for a private record, or the commit was skipped — lock held, safety
27
- * backstop, nothing staged; the record stays on disk and the next flush sweeps it up). */
27
+ * backstop, nothing staged; the record stays on disk and the next flush sweeps it up),
28
+ * or "held" (a public capture left uncommitted because the default branch is checked
29
+ * out; callers say so with HELD_ON_DEFAULT_BRANCH). */
28
30
  export declare function flushCapture(store: HunchStore, publicHunchDir: string, isPrivate: boolean, message: string,
29
31
  /** Long-lived callers can pin the route snapshot that admitted the write. */
30
- remoteOverride?: HunchRemoteContract, observe?: GitMemoryObserver): "pushed" | "committed" | null;
32
+ remoteOverride?: HunchRemoteContract, observe?: GitMemoryObserver): "pushed" | "committed" | "held" | null;
33
+ /** Auto-commit a state-contract write (`nuryel_write`, `hunch serve`). A state root is a
34
+ * dedicated data repository whose writes commit on whatever branch it serves, so the
35
+ * default-branch hold for captures never applies; outcomes as flushCapture otherwise. */
36
+ export declare function flushStateWrite(store: HunchStore, publicHunchDir: string, isPrivate: boolean, message: string, remoteOverride?: HunchRemoteContract): "pushed" | "committed" | null;
37
+ /** What a caller prints for a "held" flush, so a solo user on main is never left
38
+ * wondering why the capture commit stopped appearing. */
39
+ export declare const HELD_ON_DEFAULT_BRANCH = "not auto-committed: the default branch is checked out \u2014 your next commit that adds .hunch/ carries it (to restore auto-commit there, set \"autoCommitDefaultBranch\": true in .hunch/local.json)";
40
+ /** Whether an automatic public memory commit must be held: the default branch is checked
41
+ * out and the user has not opted back in. Shared by flushStore and `hunch sync`. */
42
+ export declare function holdsOnDefaultBranch(publicHunchDir: string): boolean;
@@ -7,8 +7,9 @@
7
7
  * commits (bug_overlay_clobber); the memory commit rides their next push instead.
8
8
  * Used by every CLI/MCP path that writes a record.
9
9
  */
10
- import { dirname } from "node:path";
11
- import { commitAndPushHunch } from "../extractors/git.js";
10
+ import { readFileSync } from "node:fs";
11
+ import { dirname, join } from "node:path";
12
+ import { commitAndPushHunch, hunchDirHasChanges, onDefaultBranch } from "../extractors/git.js";
12
13
  import { refreshCommittableGrounding } from "./providers.js";
13
14
  import { advertisedTeamRemoteContract } from "./team.js";
14
15
  const pinnedSharedRoutes = new WeakMap();
@@ -41,6 +42,10 @@ export function flushMemoryHome(store, publicHunchDir, home, message, remoteOver
41
42
  }
42
43
  if (!store.autoCommit)
43
44
  return null;
45
+ // Same hold as flushCapture: the public commit stages all of .hunch/, so it would
46
+ // otherwise sweep a capture that was just reported as held onto the default branch.
47
+ if (holdsOnDefaultBranch(publicHunchDir))
48
+ return heldOrNothing(publicHunchDir);
44
49
  const grounding = refreshCommittableGrounding(dirname(publicHunchDir), store);
45
50
  return commitAndPushHunch(publicHunchDir, message, { push: false, alsoStage: grounding });
46
51
  }
@@ -70,10 +75,21 @@ export function flushPrivate(store, message) {
70
75
  * "committed" (commit created but not pushed — public .hunch/ rides the next push; an
71
76
  * overlay commit whose merge/push failed retries on the next flush), or null (auto-commit
72
77
  * off, no overlay for a private record, or the commit was skipped — lock held, safety
73
- * backstop, nothing staged; the record stays on disk and the next flush sweeps it up). */
78
+ * backstop, nothing staged; the record stays on disk and the next flush sweeps it up),
79
+ * or "held" (a public capture left uncommitted because the default branch is checked
80
+ * out; callers say so with HELD_ON_DEFAULT_BRANCH). */
74
81
  export function flushCapture(store, publicHunchDir, isPrivate, message,
75
82
  /** Long-lived callers can pin the route snapshot that admitted the write. */
76
83
  remoteOverride, observe) {
84
+ return flushStore(store, publicHunchDir, isPrivate, message, remoteOverride, observe, true);
85
+ }
86
+ /** Auto-commit a state-contract write (`nuryel_write`, `hunch serve`). A state root is a
87
+ * dedicated data repository whose writes commit on whatever branch it serves, so the
88
+ * default-branch hold for captures never applies; outcomes as flushCapture otherwise. */
89
+ export function flushStateWrite(store, publicHunchDir, isPrivate, message, remoteOverride) {
90
+ return flushStore(store, publicHunchDir, isPrivate, message, remoteOverride, undefined, false);
91
+ }
92
+ function flushStore(store, publicHunchDir, isPrivate, message, remoteOverride, observe, holdOnDefaultBranch) {
77
93
  // Follow the same routing as HunchStore.captureHome: unified ("shared") mode homes
78
94
  // EVERY capture in the overlay, so the flush must go there too — one source of truth.
79
95
  if (store.captureHome(isPrivate) === "private") {
@@ -89,10 +105,45 @@ remoteOverride, observe) {
89
105
  }
90
106
  if (!store.autoCommit)
91
107
  return null;
108
+ // A capture never commits onto the default branch by itself: task and capture commits
109
+ // landed on main between the user's own. The record stays on disk; the user's next
110
+ // commit carries it, or the next capture on a feature branch sweeps it up. Held before
111
+ // the grounding refresh, so an automatic capture (hook sync, MCP) leaves tracked docs at
112
+ // HEAD instead of counting a record that `git commit -a` would leave behind; by-hand CLI
113
+ // commands refresh them on disk themselves (publicFlushCommitsGrounding in the CLI).
114
+ if (holdOnDefaultBranch && holdsOnDefaultBranch(publicHunchDir))
115
+ return heldOrNothing(publicHunchDir);
92
116
  // A public capture changes record counts, so refresh git-clean grounding docs and fold
93
117
  // them into the SAME memory commit — otherwise every capture re-stales the committed
94
118
  // counts and the release gate's clean-tree check fails on the next CI index.
95
119
  const grounding = refreshCommittableGrounding(dirname(publicHunchDir), store);
96
120
  return commitAndPushHunch(publicHunchDir, message, { push: false, alsoStage: grounding, observe });
97
121
  }
122
+ /** What a caller prints for a "held" flush, so a solo user on main is never left
123
+ * wondering why the capture commit stopped appearing. */
124
+ export const HELD_ON_DEFAULT_BRANCH = "not auto-committed: the default branch is checked out — your next commit that adds .hunch/ carries it (to restore auto-commit there, set \"autoCommitDefaultBranch\": true in .hunch/local.json)";
125
+ /** A hold reports "held" only when there is something left for the next commit; a clean
126
+ * .hunch/ is null, as an unheld flush with nothing staged would be. */
127
+ function heldOrNothing(publicHunchDir) {
128
+ return hunchDirHasChanges(publicHunchDir) ? "held" : null;
129
+ }
130
+ /** Whether an automatic public memory commit must be held: the default branch is checked
131
+ * out and the user has not opted back in. Shared by flushStore and `hunch sync`. */
132
+ export function holdsOnDefaultBranch(publicHunchDir) {
133
+ return !defaultBranchCommitsAllowed(publicHunchDir) && onDefaultBranch(dirname(publicHunchDir));
134
+ }
135
+ /** Opt back in to capture commits on the default branch: `autoCommitDefaultBranch: true`
136
+ * in the per-machine .hunch/local.json, or HUNCH_AUTO_COMMIT_DEFAULT_BRANCH=1. */
137
+ function defaultBranchCommitsAllowed(publicHunchDir) {
138
+ if (process.env.HUNCH_AUTO_COMMIT_DEFAULT_BRANCH === "1")
139
+ return true;
140
+ try {
141
+ const raw = readFileSync(join(publicHunchDir, "local.json"), "utf8");
142
+ const local = JSON.parse(raw.startsWith("\uFEFF") ? raw.slice(1) : raw);
143
+ return !!local && typeof local === "object" && local.autoCommitDefaultBranch === true;
144
+ }
145
+ catch {
146
+ return false;
147
+ }
148
+ }
98
149
  //# sourceMappingURL=sync.js.map
@@ -47,7 +47,7 @@ export type SnapshotOutcome = {
47
47
  status: "written";
48
48
  record: Workspace;
49
49
  home: "private" | "public";
50
- flushed: "pushed" | "committed" | null;
50
+ flushed: "pushed" | "committed" | "held" | null;
51
51
  };
52
52
  /** Record this machine's snapshot. Honors `workspaces.publish`, skips a write when the
53
53
  * content is unchanged and the stored record is under a day old (an idle machine's hooks
@@ -34,7 +34,7 @@ import { refreshExistingGrounding } from "../integrations/providers.js";
34
34
  import { workspaceLedgerView, renderWorktreeTable, renderBranchTable, workspaceSummaryLine, snapshotHasHome, recordWorkspaceSnapshot, branchRows, worktreeRows } from "../integrations/workspaceLedger.js";
35
35
  import { workspacesConfig } from "../core/config.js";
36
36
  import { revParse, asOfDate, revExists, lastChangeDate, checkFreshnessRevision, teamPullIsFresh, rangeFiles, rangeGateDiff, commitFiles, commitGateDiff, stagedFiles, stagedGateDiff, workingFiles, workingGateDiff, pullHunchStatusDetailed, describePullFailure, sameRemoteUrl, currentBranch, worktreePaths, pathKnownToHistory } from "../extractors/git.js";
37
- import { flushCapture, flushMemoryHome, pinSharedRemote } from "../integrations/sync.js";
37
+ import { flushCapture, flushStateWrite, flushMemoryHome, pinSharedRemote, HELD_ON_DEFAULT_BRANCH } from "../integrations/sync.js";
38
38
  import { withWriteLock } from "../serve/writelock.js";
39
39
  import { advertisedTeamRemoteContract, ensureTeamOverlay, isTeamStoreTrusted, overlayMatchesTeamRemote, teamWiringConsented, untrustedTeamStoreMessage, readTeamConfig, teamRemoteContract, teamSharedRef } from "../integrations/team.js";
40
40
  import { formatSearchHit, formatStructure } from "../core/format.js";
@@ -113,12 +113,13 @@ function cwdHintOf(input) {
113
113
  /** Honest auto-commit suffix: reports only what flushCapture ACTUALLY did. A skipped
114
114
  * commit (backstop/lock/nothing staged) says nothing — the record is on disk and the
115
115
  * next flush sweeps it up; claiming "auto-committed" there would be a lie. */
116
- const flushNote = (flush, home, mode) => flush === "pushed" ? ` (committed + pushed to the ${mode === "shared" ? "shared team store" : "private repo"})`
117
- : flush === "committed"
118
- ? home === "private"
119
- ? " (committed to the overlay repo — push deferred: offline, no upstream, or merge conflict; the next capture or `hunch private --sync` retries)"
120
- : " (auto-committed to .hunch/ — rides your next push)"
121
- : "";
116
+ const flushNote = (flush, home, mode) => flush === "held" ? ` (${HELD_ON_DEFAULT_BRANCH})`
117
+ : flush === "pushed" ? ` (committed + pushed to the ${mode === "shared" ? "shared team store" : "private repo"})`
118
+ : flush === "committed"
119
+ ? home === "private"
120
+ ? " (committed to the overlay repo — push deferred: offline, no upstream, or merge conflict; the next capture or `hunch private --sync` retries)"
121
+ : " (auto-committed to .hunch/ — rides your next push)"
122
+ : "";
122
123
  /** When an overlay exists, a PUBLIC write deserves one visible line: a record that
123
124
  * lands in the committed store publishes on the next push, and an agent writing
124
125
  * strategy/competitive content there is a leak nobody notices until it ships
@@ -2767,7 +2768,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
2767
2768
  // not race the HTTP server between the ledger read and the record write.
2768
2769
  const { hunchDir } = stateHomeFor(store, input.scope);
2769
2770
  const result = await withWriteLock(hunchDir, () => writeState(store, { schema: STATE_WRITE_VERSION, ...input }, {
2770
- flush: (isPrivate, message) => flushCapture(store, hunchPaths(root).hunch, isPrivate, message, startupTeamRoute ?? undefined),
2771
+ flush: (isPrivate, message) => flushStateWrite(store, hunchPaths(root).hunch, isPrivate, message, startupTeamRoute ?? undefined),
2771
2772
  }));
2772
2773
  return stateResult(`${result.outcome} ${result.record_id} (${result.durability}) ${result.record_hash}`, result);
2773
2774
  }
@@ -2784,7 +2785,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
2784
2785
  try {
2785
2786
  const { hunchDir } = stateHomeFor(store, input.scope);
2786
2787
  const result = await withWriteLock(hunchDir, () => captureState(store, { schema: STATE_CAPTURE_VERSION, ...input }, {
2787
- flush: (isPrivate, message) => flushCapture(store, hunchPaths(root).hunch, isPrivate, message, startupTeamRoute ?? undefined),
2788
+ flush: (isPrivate, message) => flushStateWrite(store, hunchPaths(root).hunch, isPrivate, message, startupTeamRoute ?? undefined),
2788
2789
  }));
2789
2790
  return stateResult(`${result.outcome} observation ${result.record_id} (${result.durability}); this does not assert currentness. ${result.record_hash}`, result);
2790
2791
  }
@@ -2801,7 +2802,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
2801
2802
  try {
2802
2803
  const { hunchDir } = stateHomeFor(store, input.scope);
2803
2804
  const result = await withWriteLock(hunchDir, () => captureBatchState(store, { schema: STATE_CAPTURE_BATCH_VERSION, ...input }, {
2804
- flush: (isPrivate, message) => flushCapture(store, hunchPaths(root).hunch, isPrivate, message, startupTeamRoute ?? undefined),
2805
+ flush: (isPrivate, message) => flushStateWrite(store, hunchPaths(root).hunch, isPrivate, message, startupTeamRoute ?? undefined),
2805
2806
  }));
2806
2807
  return stateResult(`Capture batch: ${result.results.filter(r => r.status === "saved").length} saved/replayed, ${result.results.filter(r => r.status === "refused").length} refused.${result.reviews ? ` Reviews: ${result.reviews.filter(r => r.status === "saved").length} withdrawn/replayed, ${result.reviews.filter(r => r.status === "refused").length} refused.` : ''} Inspect each indexed result.`, result);
2807
2808
  }
@@ -2,7 +2,7 @@ import { z } from "zod";
2
2
  import { verificationLauncherFor } from "../core/verifyLauncher.js";
3
3
  import { TaskIdSchema, ReportClaimSchema, LessonReferenceSchema, finishReportTask, listReportTasks, readTaskReport, readLessonHistory, recordReportClaim, reportPresentationEnabled, startReportTask } from "../core/taskReport.js";
4
4
  import { persistTaskRecord } from "../core/taskRecord.js";
5
- import { reportSourceSnapshot, runReportConformance } from "../core/taskReportEvidence.js";
5
+ import { currentSource, runReportConformance } from "../core/taskReportEvidence.js";
6
6
  import { renderTaskReport, writeTaskReportHtml } from "../core/taskReportRender.js";
7
7
  function applicationReferences(report) {
8
8
  return report.deliveries.flatMap(d => d.records.map(r => ({ occurrence_id: d.occurrence_id, record_id: r.record_id, content_hash: r.content_hash, title: r.title }))).slice(-100);
@@ -85,7 +85,7 @@ export function registerTaskReportTools(server, getRoot, getStore) {
85
85
  // from the model must not fork a second report or fail the start.
86
86
  if (!task_id || !/different title/.test(error.message))
87
87
  throw error;
88
- task = readTaskReport(root, task_id, reportSourceSnapshot(root).hash).task;
88
+ task = readTaskReport(root, task_id, currentSource(root)).task;
89
89
  }
90
90
  const launcher = verificationLauncher();
91
91
  // Compact by design (#370): the rules an agent needs to act, and exact
@@ -120,7 +120,7 @@ export function registerTaskReportTools(server, getRoot, getStore) {
120
120
  catch (error) {
121
121
  graphNote = `\nGraph not saved: ${error.message}`;
122
122
  }
123
- const report = readTaskReport(root, task_id, reportSourceSnapshot(root).hash);
123
+ const report = readTaskReport(root, task_id, currentSource(root));
124
124
  const show = reportPresentationEnabled(root);
125
125
  // The HTML evidence view is rendered on demand (hunch_report(html: true),
126
126
  // `hunch report <id> --html`, or the VS Code view); finish writes no file.
@@ -165,7 +165,7 @@ export function registerTaskReportTools(server, getRoot, getStore) {
165
165
  const tasks = listReportTasks(root);
166
166
  return { content: [{ type: "text", text: tasks.length ? tasks.map(t => `${t.task_id} · ${t.state} · ${t.title}`).join("\n") : "No task reports yet. Start with hunch_task." }], structuredContent: { tasks } };
167
167
  }
168
- const report = readTaskReport(root, task_id, reportSourceSnapshot(root).hash);
168
+ const report = readTaskReport(root, task_id, currentSource(root));
169
169
  const file = html ? writeTaskReportHtml(root, task_id) : null;
170
170
  return { content: [{ type: "text", text: `${renderTaskReport(report)}${file ? `\nLocal evidence view: ${file}` : ""}` }], structuredContent: { ...boundedTaskReportForHost(report), application_references: applicationReferences(report), contribution_card: renderTaskReport(report), report_path: file } };
171
171
  }
package/dist/serve/app.js CHANGED
@@ -23,7 +23,7 @@ import { STATE_PROOF_CAPABILITY } from '../core/stateProof.js';
23
23
  import { createServer } from "node:http";
24
24
  import { HunchStore } from "../store/hunchStore.js";
25
25
  import { hunchPaths } from "../core/paths.js";
26
- import { flushCapture } from "../integrations/sync.js";
26
+ import { flushStateWrite } from "../integrations/sync.js";
27
27
  import { StateRefusal, capabilities, mergeReadResponses, readState, recordsState, stateHomeFor, subscribeState, writeState } from "../store/stateBinding.js";
28
28
  import { STATE_READ_VERSION, STATE_RECORDS_VERSION, STATE_SUBSCRIBE_VERSION, STATE_WRITE_VERSION, ReadScopesSchema, ScopeSchema, scopePath } from "../core/stateContract.js";
29
29
  import { partitionFor, resolveCredential, readServeConfig } from "./config.js";
@@ -174,7 +174,7 @@ export function createServeApp(config, opts = {}) {
174
174
  // Only authenticated grants select stores for cross-partition source visibility.
175
175
  const accessOptions = { additionalStores: principal.grants.map(grant => requestStore(grant).store) };
176
176
  const { store, root } = requestStore(scope);
177
- const flush = (isPrivate, message) => flushCapture(store, hunchPaths(root).hunch, isPrivate, message);
177
+ const flush = (isPrivate, message) => flushStateWrite(store, hunchPaths(root).hunch, isPrivate, message);
178
178
  if (route === "read") {
179
179
  if (body.observed_page !== undefined && body.scopes !== undefined)
180
180
  throw problem(400, 'malformed', 'observation pages require a single partition without scopes');
@@ -47,6 +47,7 @@ export declare function createTaskReporter(root: string): {
47
47
  source: "local-command-runner";
48
48
  check_id?: string | undefined;
49
49
  cancelled?: boolean | undefined;
50
+ work_root?: string | undefined;
50
51
  }>;
51
52
  /** Hunch evaluates each delivered lesson's declared rule against the changed
52
53
  * files. Deterministic and local; the harness supplies no verdict. */
@@ -2,7 +2,7 @@
2
2
  * lifecycle and display; neither operation depends on model compliance. */
3
3
  import { z } from "zod";
4
4
  import { finishReportTask, listReportTasks, readTaskReport, readLessonHistory, recordReportClaim, recordTaskDelivery, reportHash, reportPresentationEnabled, startReportTask } from "./core/taskReport.js";
5
- import { DEFAULT_CHECK_TIMEOUT_MS, reportSourceSnapshot, runReportCheck, runReportConformance } from "./core/taskReportEvidence.js";
5
+ import { DEFAULT_CHECK_TIMEOUT_MS, currentSource, runReportCheck, runReportConformance } from "./core/taskReportEvidence.js";
6
6
  export { DEFAULT_CHECK_TIMEOUT_MS, MAX_CHECK_TIMEOUT_MS } from "./core/taskReportEvidence.js";
7
7
  import { renderTaskReport, writeTaskReportHtml } from "./core/taskReportRender.js";
8
8
  import { hunchPaths } from "./core/paths.js";
@@ -17,7 +17,7 @@ const IdentitySchema = z.object({ task: z.string().min(1).max(1024), attempt: z.
17
17
  * their own principal/partition boundary before reaching this local API. */
18
18
  export function createTaskReporter(root) {
19
19
  const scope = canonicalReportRoot(root);
20
- const report = (taskId) => readTaskReport(scope, taskId, reportSourceSnapshot(scope).hash);
20
+ const report = (taskId) => readTaskReport(scope, taskId, currentSource(scope));
21
21
  return {
22
22
  /** Omit identity for a fresh task, or supply the harness's stable task AND
23
23
  * execution attempt for idempotent retries. Raw identities are not retained. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davesheffer/hunch",
3
- "version": "1.47.0",
3
+ "version": "1.47.1",
4
4
  "mcpName": "io.github.davesheffer/hunch",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Dave Sheffer <dave.sheffer1@gmail.com>",
package/server.json CHANGED
@@ -7,13 +7,13 @@
7
7
  "source": "github"
8
8
  },
9
9
  "websiteUrl": "https://www.hunchmemory.com",
10
- "version": "1.47.0",
10
+ "version": "1.47.1",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "npm",
14
14
  "registryBaseUrl": "https://registry.npmjs.org",
15
15
  "identifier": "@davesheffer/hunch",
16
- "version": "1.47.0",
16
+ "version": "1.47.1",
17
17
  "runtimeHint": "npx",
18
18
  "packageArguments": [
19
19
  {