@davesheffer/hunch 1.45.0 → 1.45.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.
@@ -14,15 +14,15 @@
14
14
  */
15
15
  import { existsSync, lstatSync, readFileSync, readdirSync, rmdirSync, rmSync, statSync, writeFileSync, chmodSync } from "node:fs";
16
16
  import { spawnSync } from "node:child_process";
17
- import { basename, join, relative } from "node:path";
17
+ import { basename, dirname, join, relative, resolve } from "node:path";
18
18
  import { parse as parseToml } from "smol-toml";
19
19
  import { writeFileAtomic } from "../core/io.js";
20
- import { gitCommonDir, isGitRepo, separateGitDirLink } from "../extractors/git.js";
20
+ import { currentBranch, gitCommonDir, isGitRepo, isLinkedWorktree, separateGitDirLink } from "../extractors/git.js";
21
21
  import { HUNCH_END, HUNCH_START } from "./claudemd.js";
22
22
  import { ciWorkflowYaml } from "./ciAction.js";
23
23
  import { assertSafeTopLevelConfigFile, stripManagedGitignore } from "./gitignore.js";
24
24
  import { isHunchHookCommand } from "./hookmatch.js";
25
- import { planHookRemoval } from "./hooks.js";
25
+ import { gitRun, isInside, planHookRemoval, worktreeTops } from "./hooks.js";
26
26
  import { ATTR_LINES, targetRepositoryEnv } from "./mergeDriver.js";
27
27
  import { antigravityMcpFile, isHunchProviderHook, readJsonObj, TOML_END, TOML_START, windsurfMcpFile } from "./providers.js";
28
28
  import { SLASH_COMMAND_FILES } from "./scaffold.js";
@@ -36,6 +36,24 @@ export function isOptedOut(root) {
36
36
  return false;
37
37
  }
38
38
  }
39
+ /** Whether `start`, or the repository containing it, opted out: walks up through
40
+ * `start`'s ancestors (which need not exist yet), stopping after the first one holding
41
+ * `.git` or at the filesystem root, so a path inside an opted-out repository (`serve
42
+ * init --root ./partitions/x`) is gated like the repository itself (#458). A `.hunch`
43
+ * store is deliberately not a boundary: a partition store nested in an opted-out
44
+ * repository stays gated (fail closed). */
45
+ export function optedOutAt(start) {
46
+ for (let dir = resolve(start);;) {
47
+ if (isOptedOut(dir))
48
+ return true;
49
+ if (existsSync(join(dir, ".git")))
50
+ return false;
51
+ const parent = dirname(dir);
52
+ if (parent === dir)
53
+ return false;
54
+ dir = parent;
55
+ }
56
+ }
39
57
  export function optOutMessage() {
40
58
  return `Hunch was removed from this repository on purpose (${OPT_OUT_FILE}), so it will not set itself up here again. To reinstall, delete ${OPT_OUT_FILE} and run \`hunch init\`.`;
41
59
  }
@@ -195,6 +213,31 @@ function gitConfigHasDrivers(root) {
195
213
  const r = spawnSync("git", ["config", "--local", "--get-regexp", "^merge\\.hunch(-grounding)?\\."], { cwd: root, env: targetRepositoryEnv(), encoding: "utf8" });
196
214
  return r.status === 0 && r.stdout.trim() !== "";
197
215
  }
216
+ /** The branch the clone treats as default: origin's HEAD, else a local main, else
217
+ * master; "" when none can be named. */
218
+ function defaultBranch(root) {
219
+ const head = gitRun(["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"], root);
220
+ if (head.status === 0 && head.stdout)
221
+ return head.stdout.replace(/^origin\//, "");
222
+ for (const b of ["main", "master"]) {
223
+ if (gitRun(["show-ref", "--verify", "--quiet", `refs/heads/${b}`], root).status === 0)
224
+ return b;
225
+ }
226
+ return "";
227
+ }
228
+ /** The marker is committed per branch: off the default branch, say the rest of the clone still has Hunch. */
229
+ function branchWarning(root) {
230
+ try {
231
+ const branch = currentBranch(root);
232
+ const def = branch ? defaultBranch(root) : "";
233
+ if (!branch || !def || branch === def)
234
+ return null;
235
+ return `the ${OPT_OUT_FILE} marker is only on branch ${branch}; other branches of this clone still have Hunch's instruction blocks and config — commit and merge it, or run uninstall there too`;
236
+ }
237
+ catch {
238
+ return null;
239
+ }
240
+ }
198
241
  /** The overlay clone a pointer names, for the "kept" note. Never deleted: it is
199
242
  * its own repository holding memory that may not be pushed yet. */
200
243
  function pointerOverlay(file) {
@@ -295,22 +338,49 @@ export function planUninstall(root, opts = {}) {
295
338
  p.steps.push({ path: ciRel, kind: "keep", note: "it differs from the workflow Hunch generates (edited?); delete it yourself if you no longer want the Hunch Guard check" });
296
339
  }
297
340
  }
298
- // Git hooks + merge driver.
341
+ // Git hooks + merge driver. Both live in the clone's common git dir, so they
342
+ // serve every worktree and every branch (#459): a linked worktree leaves them to
343
+ // the main checkout, and the main checkout says which worktrees lose them too.
344
+ // A bare common dir has no main checkout, so any worktree removes them and warns.
345
+ const warnings = [];
299
346
  if (isGitRepo(root)) {
347
+ const commonDir = gitCommonDir(root);
348
+ const top = gitRun(["rev-parse", "--show-toplevel"], root).stdout;
349
+ const here = top || root;
350
+ const same = (a, b) => isInside(a, b) && isInside(b, a);
351
+ // The main checkout as git lists it, not the common dir's parent, which in a
352
+ // bare clone or a --separate-git-dir checkout is no checkout at all.
353
+ const tops = worktreeTops(root);
354
+ const mainCheckout = tops.main;
355
+ const linked = commonDir !== "" && mainCheckout !== null && !same(mainCheckout, here) && isLinkedWorktree(root);
356
+ // The same test `hunch init` uses: in the common dir, or outside this checkout.
357
+ const shared = (file) => (commonDir !== "" && isInside(commonDir, file)) || (top !== "" && !isInside(top, file));
358
+ const elsewhere = (what, it) => `${what} shared by every worktree of this clone — run \`hunch uninstall\` from the main checkout (${mainCheckout}) to remove ${it}`;
359
+ let removesShared = false;
300
360
  for (const h of planHookRemoval(root)) {
301
361
  const path = p.rel(h.path);
302
362
  if (h.reason)
303
363
  p.steps.push({ path, kind: "keep", note: h.reason });
304
- else if (h.next === null)
305
- p.steps.push({ path, kind: "delete", note: `Hunch's ${h.hookName} git hook`, run: () => rmSync(h.path, { force: true }) });
364
+ else if (linked && shared(h.path))
365
+ p.steps.push({ path, kind: "keep", note: elsewhere(`Hunch's ${h.hookName} git hook is`, "it") });
306
366
  else {
307
- const next = h.next;
308
- p.steps.push({ path, kind: "edit", note: `removed Hunch's block from the ${h.hookName} git hook (your own lines are kept)`, run: () => { writeFileSync(h.path, next); chmodSync(h.path, 0o755); } });
367
+ if (shared(h.path))
368
+ removesShared = true;
369
+ if (h.next === null)
370
+ p.steps.push({ path, kind: "delete", note: `Hunch's ${h.hookName} git hook`, run: () => rmSync(h.path, { force: true }) });
371
+ else {
372
+ const next = h.next;
373
+ p.steps.push({ path, kind: "edit", note: `removed Hunch's block from the ${h.hookName} git hook (your own lines are kept)`, run: () => { writeFileSync(h.path, next); chmodSync(h.path, 0o755); } });
374
+ }
309
375
  }
310
376
  }
311
- if (gitConfigHasDrivers(root)) {
377
+ if (gitConfigHasDrivers(root) && linked) {
378
+ p.steps.push({ path: p.rel(join(commonDir, "config")), kind: "keep", note: elsewhere("the merge.hunch and merge.hunch-grounding drivers are", "them") });
379
+ }
380
+ else if (gitConfigHasDrivers(root)) {
381
+ removesShared = true;
312
382
  p.steps.push({
313
- path: ".git/config", kind: "edit", note: "unregistered the merge.hunch and merge.hunch-grounding drivers",
383
+ path: commonDir ? p.rel(join(commonDir, "config")) : ".git/config", kind: "edit", note: "unregistered the merge.hunch and merge.hunch-grounding drivers",
314
384
  run: () => {
315
385
  for (const section of ["merge.hunch", "merge.hunch-grounding"]) {
316
386
  spawnSync("git", ["config", "--local", "--remove-section", section], { cwd: root, env: targetRepositoryEnv(), stdio: "ignore" });
@@ -318,6 +388,13 @@ export function planUninstall(root, opts = {}) {
318
388
  },
319
389
  });
320
390
  }
391
+ const others = linked ? [] : tops.linked.filter((w) => !same(w, here)).map((w) => resolve(w));
392
+ if (removesShared && others.length) {
393
+ warnings.push(`the git hooks and merge drivers are shared with ${others.length === 1 ? "the linked worktree" : `${others.length} linked worktrees`} ${others.join(", ")}, so they are removed there too, though ${others.length === 1 ? "it still has" : "those still have"} Hunch and no ${OPT_OUT_FILE} marker`);
394
+ }
395
+ const onBranch = branchWarning(root);
396
+ if (onBranch)
397
+ warnings.push(onBranch);
321
398
  }
322
399
  const attrs = p.text(".gitattributes");
323
400
  if (attrs !== null) {
@@ -378,7 +455,7 @@ export function planUninstall(root, opts = {}) {
378
455
  for (const dir of overlays) {
379
456
  p.steps.push({ path: p.rel(dir), kind: "keep", note: "a private/shared overlay clone: its own repository with memory that may not be pushed; never deleted here" });
380
457
  }
381
- return { root, purge, steps: p.steps, refused: p.refused };
458
+ return { root, purge, steps: p.steps, refused: p.refused, warnings };
382
459
  }
383
460
  /** Directories Hunch's writers create; removed only when left empty. Deepest first. */
384
461
  const PRUNE_DIRS = [
@@ -430,6 +507,8 @@ export function formatUninstallPlan(plan, dryRun) {
430
507
  out.push(` ✗ ${r.path}: ${r.reason}`);
431
508
  for (const s of plan.steps)
432
509
  out.push(` ${icon(s.kind)} ${verb(s.kind)} ${s.path} — ${s.note}`);
510
+ for (const w of plan.warnings ?? [])
511
+ out.push(` ⚠ ${w}`);
433
512
  return out;
434
513
  }
435
514
  //# sourceMappingURL=uninstall.js.map
@@ -33,7 +33,7 @@ import { knownRepoDeps } from "../synthesis/tripwires.js";
33
33
  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
- import { revParse, asOfDate, revExists, lastChangeDate, checkFreshnessRevision, teamPullIsFresh, rangeFiles, rangeGateDiff, commitFiles, commitGateDiff, stagedFiles, stagedGateDiff, workingFiles, workingGateDiff, pullHunchStatus, sameRemoteUrl, currentBranch, worktreePaths, pathKnownToHistory } from "../extractors/git.js";
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
37
  import { flushCapture, flushMemoryHome, pinSharedRemote } 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";
@@ -69,6 +69,7 @@ import { nowData, wikiStatus, publicHome, readWikiManifestAt } from "../wiki/wik
69
69
  import { HUNCH_VERSION } from "../core/version.js";
70
70
  import { assertCompleteRepoScan, indexRepo, scanRepo } from "../extractors/indexer.js";
71
71
  import { liveForTopic, historyForTopic, rejectedForTopic, captureConflicts } from "../core/topics.js";
72
+ import { formatWhyRecord, more, WHY_CAP } from "../core/whyRecord.js"; // WHY_CAP: per record-type cap in hunch_why
72
73
  import { pendingEscalations, policyEscalations, commitRepairEscalations, actionableEscalations, escalationHeadline } from "../core/escalations.js";
73
74
  import { readActivePendingRepairs, withheldRewrites } from "../core/repairqueue.js";
74
75
  import { scanRecord, publicationWarning, loadVocabulary } from "../core/publication.js";
@@ -595,7 +596,6 @@ const resolveDestRoot = (home, store, root) => home === "private" && store.priva
595
596
  // session, so an uncapped list pollutes the context window. Cap each list to its
596
597
  // highest-signal head (records are pre-sorted by severity/confidence) and tell the
597
598
  // caller what was withheld rather than truncating silently.
598
- const WHY_CAP = 6; // per record-type in hunch_why
599
599
  const DEP_CAP = 25; // dependents in hunch_get_dependents
600
600
  const QUERY_HITS = 8; // hunch_query matches (was 12)
601
601
  const FINDINGS_CAP = 12; // hunch_findings listing
@@ -624,7 +624,6 @@ export const firstSentence = (text) => {
624
624
  };
625
625
  const SEV_CONSTRAINT = { blocking: 3, warning: 2, advisory: 1 };
626
626
  const SEV_BUG = { critical: 4, high: 3, medium: 2, low: 1 };
627
- const more = (total, cap, hint = "") => total > cap ? `\n …(+${total - cap} more${hint ? ` — ${hint}` : ""})` : "";
628
627
  const EXECUTION_OBLIGATION_SCHEMA = z.object({
629
628
  id: z.string(),
630
629
  origin: z.enum(["memory", "episode", "manual"]),
@@ -1088,10 +1087,11 @@ function prepareRoot(root, explicitOverlay, requireIndex) {
1088
1087
  let consecutivePullFailures = 0;
1089
1088
  if (store.privateDir) {
1090
1089
  try {
1091
- const status = pullHunchStatus(store.privateDir, {
1092
- timeoutMs: 5_000,
1090
+ const { status, detail } = pullHunchStatusDetailed(store.privateDir, {
1093
1091
  remote: startupTeamRoute ?? advertisedTeamRemoteContract(root, join(store.privateDir, "..")),
1094
1092
  });
1093
+ if (detail)
1094
+ console.error(`[hunch-mcp] team memory pull failed (${describePullFailure(detail)})`);
1095
1095
  ({ nextRemotePullAt, consecutivePullFailures } = pullBackoff(status, Date.now(), 0));
1096
1096
  }
1097
1097
  catch {
@@ -1145,6 +1145,11 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
1145
1145
  let store = initial.store;
1146
1146
  let nextRemotePullAt = initial.nextRemotePullAt;
1147
1147
  let consecutivePullFailures = initial.consecutivePullFailures;
1148
+ // Consecutive FAILED forced pulls (any subject); reset by any fresh pull.
1149
+ let forcedFailureStreak = 0;
1150
+ // When the last fresh pull (ordinary or forced) finished; a forced failure
1151
+ // older than this is never reused, since team memory was refreshed since.
1152
+ let lastFreshPullAt = 0;
1148
1153
  let indexedSourceStamp = initial.indexedSourceStamp;
1149
1154
  const matchesStartupTeamRoute = () => {
1150
1155
  if (!teamAdvertised || !store.privateDir || !startupTeamConfig || !startupTeamRoute)
@@ -1165,6 +1170,10 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
1165
1170
  const notePull = (status, finishedAt) => {
1166
1171
  ({ nextRemotePullAt, consecutivePullFailures } =
1167
1172
  pullBackoff(status, finishedAt, consecutivePullFailures));
1173
+ if (teamPullIsFresh(status)) {
1174
+ forcedFailureStreak = 0;
1175
+ lastFreshPullAt = finishedAt;
1176
+ }
1168
1177
  };
1169
1178
  const pullTeamMemory = (force = false) => {
1170
1179
  if (!store.privateDir)
@@ -1174,10 +1183,12 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
1174
1183
  return "cooldown";
1175
1184
  let status;
1176
1185
  try {
1177
- status = pullHunchStatus(store.privateDir, {
1178
- timeoutMs: 5_000,
1186
+ const pulled = pullHunchStatusDetailed(store.privateDir, {
1179
1187
  remote: startupTeamRoute ?? advertisedTeamRemoteContract(root, join(store.privateDir, "..")),
1180
1188
  });
1189
+ status = pulled.status;
1190
+ if (pulled.detail)
1191
+ console.error(`[hunch-mcp] team memory pull failed (${describePullFailure(pulled.detail)})`);
1181
1192
  }
1182
1193
  catch {
1183
1194
  status = "failed";
@@ -1191,6 +1202,70 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
1191
1202
  const refreshIndex = () => {
1192
1203
  indexedSourceStamp = rebuildFreshIndex(store);
1193
1204
  };
1205
+ const FORCED_PULL_RETRY_MS = 2_000;
1206
+ const FORCED_FAILURE_LIMIT = 3;
1207
+ let lastForcedFailure = null;
1208
+ // True only during the synchronous prefix of a tool callback whose request
1209
+ // wrapper just pulled shared memory fresh (set/cleared around the callback).
1210
+ let requestPulledFresh = false;
1211
+ // An exact local miss in shared mode is an authority claim: local absence must
1212
+ // never be presented as team absence. Ordinary request pulls honour cooldown and
1213
+ // failure backoff, so bypass it once. Returns null after a fresh convergence
1214
+ // (the caller re-reads), else the explicit abstention to return instead.
1215
+ // Two throttles keep the synchronous fetch off the hot path: a miss in a
1216
+ // request whose ordinary pull already converged needs no second fetch, and a
1217
+ // forced pull that failed within FORCED_PULL_RETRY_MS for the SAME subject is
1218
+ // reused, not retried, so a client looping on one miss against a dead remote
1219
+ // cannot block stdio on each call. A different subject still fetches: a remote
1220
+ // restored moments later must converge for the next distinct read — until
1221
+ // FORCED_FAILURE_LIMIT forced pulls have failed in a row (any subject); then
1222
+ // misses reuse the last failure until the ordinary backoff window has passed.
1223
+ // Any fresh pull resets the streak (notePull).
1224
+ const confirmSharedMiss = (absenceClaim, subject) => {
1225
+ let status;
1226
+ if (requestPulledFresh) {
1227
+ status = "current";
1228
+ }
1229
+ else if (lastForcedFailure && lastForcedFailure.at > lastFreshPullAt && ((lastForcedFailure.subject === subject
1230
+ && Date.now() - lastForcedFailure.at < FORCED_PULL_RETRY_MS)
1231
+ || (forcedFailureStreak >= FORCED_FAILURE_LIMIT && Date.now() < nextRemotePullAt))) {
1232
+ status = lastForcedFailure.status;
1233
+ }
1234
+ else {
1235
+ status = pullTeamMemory(true);
1236
+ if (status === "cooldown" || status === "not_shared" || !teamPullIsFresh(status)) {
1237
+ lastForcedFailure = { at: Date.now(), status, subject };
1238
+ forcedFailureStreak++;
1239
+ }
1240
+ else {
1241
+ lastForcedFailure = null;
1242
+ }
1243
+ }
1244
+ if (status === "cooldown" || status === "not_shared" || !teamPullIsFresh(status)) {
1245
+ return `Shared team memory refresh is ${status}; Hunch cannot confirm that ${absenceClaim}. Retry when the shared store is available.`;
1246
+ }
1247
+ try {
1248
+ if (store.sourceStamp() !== indexedSourceStamp)
1249
+ refreshIndex();
1250
+ }
1251
+ catch {
1252
+ return `Shared team memory refreshed, but its derived index could not be rebuilt; Hunch cannot confirm that ${subject} is absent. Retry this read.`;
1253
+ }
1254
+ return null;
1255
+ };
1256
+ // Policy reads: confirm a local policy (or its referenced proof) miss against
1257
+ // fresh shared memory before the service reports "not found". Public-only reads
1258
+ // and non-shared stores never consult the team remote.
1259
+ const confirmSharedPolicy = (policyId, publicOnly) => {
1260
+ if (publicOnly || store.mode !== "shared" || !store.privateDir)
1261
+ return null;
1262
+ const repository = new PolicyRepository(root, store);
1263
+ const policy = repository.getPolicy(policyId);
1264
+ if (policy && (!policy.proof || repository.getProof(policy.proof)))
1265
+ return null;
1266
+ const subject = policy ? `proof ${policy.proof} of policy ${policyId}` : `policy ${policyId}`;
1267
+ return confirmSharedMiss(`${subject} is absent from shared team memory`, subject);
1268
+ };
1194
1269
  // Resolve the embedder ONCE for this long-lived process (never throws; null when
1195
1270
  // the optional model isn't installed). The model then loads lazily on the first
1196
1271
  // hunch_query and stays warm — and hybridSearch degrades to FTS until then.
@@ -1231,6 +1306,8 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
1231
1306
  nextRemotePullAt = prepared.nextRemotePullAt;
1232
1307
  consecutivePullFailures = prepared.consecutivePullFailures;
1233
1308
  indexedSourceStamp = prepared.indexedSourceStamp;
1309
+ lastForcedFailure = null;
1310
+ forcedFailureStreak = 0;
1234
1311
  previous.close();
1235
1312
  console.error(`[hunch-mcp] serving Hunch at ${root} (client root)`);
1236
1313
  };
@@ -1339,8 +1416,10 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
1339
1416
  || !overlayMatchesTeamRemote(root, join(store.privateDir, "..")))) {
1340
1417
  return refused("The committed team memory destination is invalid or no longer matches this process. Refusing the stale graph; reconnect Hunch first.");
1341
1418
  }
1419
+ let pulledFresh = false;
1342
1420
  if (store.mode === "shared" && store.privateDir) {
1343
- pullTeamMemory();
1421
+ const pulled = pullTeamMemory();
1422
+ pulledFresh = pulled !== "cooldown" && pulled !== "not_shared" && teamPullIsFresh(pulled);
1344
1423
  // Recompute the full semantic + physical snapshot after the synchronous
1345
1424
  // network seam. A paired team.json/origin change can occur while fetch is
1346
1425
  // blocked; serving after that race would attach the old checkout to a new
@@ -1360,7 +1439,17 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
1360
1439
  refreshIndex();
1361
1440
  }
1362
1441
  catch { /* corrupt/churning local source — serve the last durable indexed view */ }
1363
- const result = await withInitiator(initiatorFromClient(server.server.getClientVersion()?.name), () => callback(...args));
1442
+ // A shared-miss check in the callback's synchronous prefix may reuse this
1443
+ // request's fresh pull; any later (post-await) check falls back to a forced pull.
1444
+ const result = await withInitiator(initiatorFromClient(server.server.getClientVersion()?.name), () => {
1445
+ requestPulledFresh = pulledFresh;
1446
+ try {
1447
+ return callback(...args);
1448
+ }
1449
+ finally {
1450
+ requestPulledFresh = false;
1451
+ }
1452
+ });
1364
1453
  if (teamAdvertised && !matchesStartupTeamRoute()) {
1365
1454
  return refused("The team-memory route changed while the tool was running. Its startup destination was not published; reconnect Hunch before retrying.");
1366
1455
  }
@@ -1409,16 +1498,28 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
1409
1498
  // -- hunch_why ------------------------------------------------------------
1410
1499
  server.registerTool("hunch_why", {
1411
1500
  title: "Explain why a file/symbol is the way it is",
1412
- description: "Return the decisions, bugs, and constraints that explain a file path or symbol — the 'why' and the 'what must not break', with evidence. Pass `as_of` (a commit/tag/branch) to time-travel: see what was believed at that point in history. Use when you need the full rationale for ONE target. Not for a budgeted task brief (hunch_context), keyword search (hunch_query), or where-is-it questions (hunch_structure).",
1501
+ description: "Return the decisions, bugs, and constraints that explain a file path or symbol, or one record by id (dec_/con_/bug_/fnd_/cmp_) — the 'why' and the 'what must not break', with evidence. Pass `as_of` (a commit/tag/branch) to time-travel: see what was believed at that point in history. Use when you need the full rationale for ONE target. Not for a budgeted task brief (hunch_context), keyword search (hunch_query), or where-is-it questions (hunch_structure).",
1413
1502
  inputSchema: {
1414
- target: z.string().describe("A file path (e.g. src/auth/session.ts) or symbol name."),
1503
+ target: z.string().describe("A file path (e.g. src/auth/session.ts), symbol name, or record id (e.g. dec_0b5d2be516)."),
1415
1504
  as_of: z.string().optional().describe("Time-travel ref: a commit sha, tag, or branch (e.g. v0.7.0). Omit for the current view."),
1416
1505
  },
1417
1506
  }, async ({ target, as_of }) => {
1418
1507
  const asOf = as_of ? asOfDate(as_of, root) : undefined;
1419
1508
  if (as_of && !asOf)
1420
1509
  return invalid(`Could not resolve as_of "${as_of}" to a commit.`);
1421
- const w = store.why(target, { asOf });
1510
+ let w = store.why(target, { asOf });
1511
+ // "No record <id>" is a team-absence claim in shared mode: confirm the local
1512
+ // miss against a fresh pull first, like hunch_current_decision. A record held
1513
+ // locally but outside its as_of window is not a team-absence claim: no pull.
1514
+ if (w.byId && !w.byId.record && store.mode === "shared" && store.privateDir
1515
+ && !(asOf && store.why(target).byId?.record)) {
1516
+ const abstention = confirmSharedMiss(`record ${w.byId.id} is absent from shared team memory`, `record ${w.byId.id}`);
1517
+ if (abstention)
1518
+ return err(abstention);
1519
+ w = store.why(target, { asOf });
1520
+ }
1521
+ if (w.byId?.record)
1522
+ return ok(formatWhyRecord(w.byId).join("\n"));
1422
1523
  // Highest-signal first, then cap: invariants by severity, decisions by
1423
1524
  // confidence, bugs by severity — so a hot file's trim drops the tail, not
1424
1525
  // the records that matter most.
@@ -1426,6 +1527,8 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
1426
1527
  const constraints = [...w.constraints].sort((a, b) => (SEV_CONSTRAINT[b.severity] ?? 0) - (SEV_CONSTRAINT[a.severity] ?? 0));
1427
1528
  const bugs = [...w.bugs].sort((a, b) => (SEV_BUG[b.severity] ?? 0) - (SEV_BUG[a.severity] ?? 0));
1428
1529
  const parts = [`Why for "${target}":`];
1530
+ if (w.byId)
1531
+ parts.push(`\n${formatWhyRecord(w.byId).join("\n")}`);
1429
1532
  if (decisions.length)
1430
1533
  parts.push(`\nDECISIONS:\n${decisions.slice(0, WHY_CAP).map((d) => ` • ${d.id} [${d.status}] ${d.title}\n ${d.decision}${provLine(d)}`).join("\n")}${more(decisions.length, WHY_CAP, "narrow the target")}`);
1431
1534
  if (constraints.length)
@@ -2071,21 +2174,11 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
2071
2174
  let live = liveForTopic(decs, topic);
2072
2175
  // A prior transient fetch failure may have placed ordinary tool traffic in
2073
2176
  // backoff. That is acceptable for cached positive reads, but an exact topic
2074
- // miss is an authority claim: local absence must never be presented as team
2075
- // absence. Bypass the cooldown once, then reread only after a successful
2076
- // bounded convergence. An unavailable remote produces an explicit abstention.
2177
+ // miss must be confirmed against fresh team memory (confirmSharedMiss).
2077
2178
  if (live.length === 0 && store.mode === "shared" && store.privateDir) {
2078
- const status = pullTeamMemory(true);
2079
- if (status === "cooldown" || status === "not_shared" || !teamPullIsFresh(status)) {
2080
- return err(`Shared team memory refresh is ${status}; Hunch cannot confirm that topic "${topic}" has no current decision. Retry when the shared store is available.`);
2081
- }
2082
- try {
2083
- if (store.sourceStamp() !== indexedSourceStamp)
2084
- refreshIndex();
2085
- }
2086
- catch {
2087
- return err(`Shared team memory refreshed, but its derived index could not be rebuilt; Hunch cannot confirm that topic "${topic}" is absent. Retry this read.`);
2088
- }
2179
+ const abstention = confirmSharedMiss(`topic "${topic}" has no current decision`, `topic "${topic}"`);
2180
+ if (abstention)
2181
+ return err(abstention);
2089
2182
  decs = store.recs("decisions");
2090
2183
  live = liveForTopic(decs, topic);
2091
2184
  }
@@ -3002,6 +3095,9 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
3002
3095
  },
3003
3096
  }, async ({ policy_id, public_only }) => {
3004
3097
  try {
3098
+ const abstention = confirmSharedPolicy(policy_id, public_only);
3099
+ if (abstention)
3100
+ return err(abstention);
3005
3101
  const service = new ConstitutionService(store, root);
3006
3102
  const plan = service.plan(policy_id, { publicOnly: public_only });
3007
3103
  const home = public_only ? "public" : service.repository.homeOfPolicy(policy_id);
@@ -3024,6 +3120,9 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
3024
3120
  },
3025
3121
  }, async ({ policy_id, public_only }) => {
3026
3122
  try {
3123
+ const abstention = confirmSharedPolicy(policy_id, public_only);
3124
+ if (abstention)
3125
+ return err(abstention);
3027
3126
  return ok(JSON.stringify(new ConstitutionService(store, root).card(policy_id, { publicOnly: public_only }), null, 2));
3028
3127
  }
3029
3128
  catch (e) {
@@ -3039,6 +3138,9 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
3039
3138
  },
3040
3139
  }, async ({ policy_id, public_only }) => {
3041
3140
  try {
3141
+ const abstention = confirmSharedPolicy(policy_id, public_only);
3142
+ if (abstention)
3143
+ return err(abstention);
3042
3144
  return ok(JSON.stringify(new ConstitutionService(store, root).shadowReport(policy_id, {}, { publicOnly: public_only }), null, 2));
3043
3145
  }
3044
3146
  catch (e) {
@@ -3054,6 +3156,9 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
3054
3156
  },
3055
3157
  }, async ({ policy_id, public_only }) => {
3056
3158
  try {
3159
+ const abstention = confirmSharedPolicy(policy_id, public_only);
3160
+ if (abstention)
3161
+ return err(abstention);
3057
3162
  const service = new ConstitutionService(store, root);
3058
3163
  const policy = service.get(policy_id, { publicOnly: public_only });
3059
3164
  if (!policy.proof)
@@ -3080,6 +3185,11 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
3080
3185
  throw new Error("choose either workspace or commit for executable-behavior evaluation");
3081
3186
  if (commit && !revExists(commit, root))
3082
3187
  throw new Error(`commit ref ${JSON.stringify(commit)} does not resolve`);
3188
+ if (policy_id) {
3189
+ const abstention = confirmSharedPolicy(policy_id, public_only);
3190
+ if (abstention)
3191
+ return err(abstention);
3192
+ }
3083
3193
  const exactCommit = commit ? revParse(`${commit}^{commit}`, root) : undefined;
3084
3194
  const behavior = workspace ? { workspace }
3085
3195
  : exactCommit ? { commit: exactCommit }
@@ -1,3 +1,6 @@
1
+ /** Shared with jsonStore's `.rmw-lock` (core/procstart.ts); re-exported so this
2
+ * module's surface is unchanged. */
3
+ export { CREATED_AFTER_SLACK_MS } from "../core/procstart.js";
1
4
  /** Longest a write may hold the lock before another process may steal it: a put plus a
2
5
  * git commit and push — seconds, not minutes. */
3
6
  export declare const STALE_AFTER_MS = 60000;
@@ -7,10 +10,6 @@ export declare const ACQUIRE_TIMEOUT_MS = 10000;
7
10
  * claimed section is a few syscalls, so anything this old means its claimer
8
11
  * crashed inside it. */
9
12
  export declare const CLAIM_STALE_MS = 10000;
10
- /** How much later than the lock's recorded `at` the pid's current process must
11
- * have been created before it is proven not to be the owner. A genuine owner
12
- * started BEFORE it took the lock; the slack only absorbs clock granularity. */
13
- export declare const CREATED_AFTER_SLACK_MS = 5000;
14
13
  export interface LockOwner {
15
14
  pid: number;
16
15
  host: string;
@@ -22,7 +22,10 @@ import { hostname } from "node:os";
22
22
  import { randomBytes } from "node:crypto";
23
23
  import { closeSync, mkdirSync, openSync, readFileSync, rmSync, rmdirSync, statSync, writeSync } from "node:fs";
24
24
  import { join } from "node:path";
25
- import { heldLockNonces, judgeSameHostOwner, processStartedAt, selfStartToken, startTokenOf } from "../core/procstart.js";
25
+ import { createdAfterLock, heldLockNonces, judgeSameHostOwner, processStartedAt, selfStartToken, startTokenOf } from "../core/procstart.js";
26
+ /** Shared with jsonStore's `.rmw-lock` (core/procstart.ts); re-exported so this
27
+ * module's surface is unchanged. */
28
+ export { CREATED_AFTER_SLACK_MS } from "../core/procstart.js";
26
29
  /** Longest a write may hold the lock before another process may steal it: a put plus a
27
30
  * git commit and push — seconds, not minutes. */
28
31
  export const STALE_AFTER_MS = 60_000;
@@ -33,10 +36,6 @@ export const ACQUIRE_TIMEOUT_MS = 10_000;
33
36
  * crashed inside it. */
34
37
  export const CLAIM_STALE_MS = 10_000;
35
38
  const POLL_MS = 25;
36
- /** How much later than the lock's recorded `at` the pid's current process must
37
- * have been created before it is proven not to be the owner. A genuine owner
38
- * started BEFORE it took the lock; the slack only absorbs clock granularity. */
39
- export const CREATED_AFTER_SLACK_MS = 5_000;
40
39
  export class WriteLockTimeout extends Error {
41
40
  path;
42
41
  heldBy;
@@ -104,9 +103,10 @@ function stealable(path, owner, now, token) {
104
103
  // and any skew between them would "prove" a live owner recycled and steal its
105
104
  // lock. Anything the token cannot settle stays "not proven".
106
105
  //
107
- // The whole table lives in core/procstart.ts so this lock and jsonStore's
108
- // `.rmw-lock` can never drift apart; "age" means it proved nothing and the
109
- // fallback below governs, exactly as for a foreign host.
106
+ // The whole table, and the creation-time check for an "unproven" pid, live in
107
+ // core/procstart.ts so this lock and jsonStore's `.rmw-lock` apply one rule;
108
+ // "age" means it proved nothing and the fallback below governs, exactly as
109
+ // for a foreign host.
110
110
  if (owner && owner.host === hostname()) {
111
111
  const verdict = judgeSameHostOwner(owner, {
112
112
  alive: pidAlive,
@@ -117,21 +117,12 @@ function stealable(path, owner, now, token) {
117
117
  heldNonces: heldLockNonces,
118
118
  });
119
119
  if (verdict === "unproven")
120
- return ageMs > STALE_AFTER_MS && createdAfterLock(owner, token);
120
+ return ageMs > STALE_AFTER_MS && createdAfterLock(owner, token.startedAt);
121
121
  if (verdict !== "age")
122
122
  return verdict === "stale";
123
123
  }
124
124
  return ageMs > STALE_AFTER_MS;
125
125
  }
126
- /** Is the process now holding the owner's pid provably newer than the lock?
127
- * Unreadable either way means not proven, and a live pid keeps its lock. */
128
- function createdAfterLock(owner, token) {
129
- const at = Date.parse(owner.at);
130
- if (!Number.isFinite(at))
131
- return false;
132
- const started = (token.startedAt ?? processStartedAt)(owner.pid);
133
- return started !== null && started > at + CREATED_AFTER_SLACK_MS;
134
- }
135
126
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
136
127
  /** The claim a contender must hold to REMOVE a stale lock. A sibling directory,
137
128
  * created with the same exclusive `mkdir` the commit lock uses
@@ -4,6 +4,7 @@ import { type DB } from "./db.js";
4
4
  import { type Embedder } from "./embedder.js";
5
5
  import { JsonStore } from "./jsonStore.js";
6
6
  import { type RankingContext, type RankingQuery, type RankingWeights, type SlotOptions, type TaskSelection } from "../core/taskRanking.js";
7
+ import { type WhyById } from "../core/whyRecord.js";
7
8
  import { type VetoTier } from "../core/strictgate.js";
8
9
  import { type DiffAnalysis, type DiffStatus } from "../extractors/diff.js";
9
10
  import type { CheckReport, CausalWhy, ImpactReport } from "../core/checkreport.js";
@@ -43,6 +44,8 @@ export interface WhyResult {
43
44
  constraints: Constraint[];
44
45
  symbols: Symbol[];
45
46
  components: Component[];
47
+ /** Set when the target is a record id (`dec_…`, `con_…`, …; issue #474). */
48
+ byId?: WhyById;
46
49
  }
47
50
  export interface FragileNode {
48
51
  id: string;
@@ -391,6 +394,13 @@ export declare class HunchStore {
391
394
  asOf?: string;
392
395
  canRead?: (record: unknown) => boolean;
393
396
  }): WhyResult;
397
+ /** Look a record up by id across the record kinds why() explains (public ∪ private
398
+ * via recs(), narrowed by `canRead`). A decision carries its supersession chain.
399
+ * With `asOf`, a record outside its valid window then is "no record", and only
400
+ * chain links/neighbours already in effect then are shown. Status is the STORED
401
+ * status, exactly as the path/symbol view prints it.
402
+ * Neighbour ids the caller may not read are never printed. */
403
+ private whyRecord;
394
404
  /** Transitive blast radius: every symbol/component that (in)directly depends on
395
405
  * `id`, via a recursive CTE over the edges graph (hunch_get_dependents). We
396
406
  * walk edges BACKWARD (edges.to = current) following call/dep/import/contains. */