@davesheffer/hunch 1.45.1 → 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.
@@ -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. */
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * - reindex(): JSON -> SQLite (rebuild the derived index + FTS)
6
6
  * - search(): FTS5 ranked query (hunch_query)
7
- * - why(): decisions/bugs/constraints explaining a path/symbol
7
+ * - why(): decisions/bugs/constraints explaining a path/symbol, or one record by id
8
8
  * - getDependents(): recursive-CTE blast radius over the call/dep graph
9
9
  * - checkConstraints(): constraints whose scope matches a glob/path
10
10
  * - bugLineage(): bugs matching a symptom/symbol + their lineage
@@ -26,6 +26,7 @@ import { normalizePath, rankTaskRecords, recordIdsOf, selectLatestTasks, selectT
26
26
  import { resolveTaskRankingMode } from "../core/taskRankingMode.js";
27
27
  import { currentForTopic, isInForce } from "../core/topics.js";
28
28
  import { edgeId } from "../core/ids.js";
29
+ import { WHY_GENERATED_ID, WHY_RECORD_ID } from "../core/whyRecord.js";
29
30
  import { isStrictBlocker, isVetoBlocker } from "../core/strictgate.js";
30
31
  import { effectiveForbids, matchForbids } from "../core/constraintmatch.js";
31
32
  import { analyzeDiff, diffContentGaps } from "../extractors/diff.js";
@@ -1343,6 +1344,14 @@ export class HunchStore {
1343
1344
  // An absolute target (an agent's edit-payload path, verbatim) never matched
1344
1345
  // the repo-relative stored file paths below (issue #299).
1345
1346
  target = repoRelativeTarget(target, this.paths.root);
1347
+ // A record id (every delivered brief line ends in `hunch_why("dec_…")`) resolves
1348
+ // to that record directly (issue #474). With `asOf`, a record not in force then
1349
+ // is "no record"; status stays as stored, like the path view (see whyRecord).
1350
+ const lookup = WHY_RECORD_ID.test(target) ? this.whyRecord(target, opts.canRead, opts.asOf) : undefined;
1351
+ if (lookup?.record)
1352
+ return { target, decisions: [], bugs: [], constraints: [], symbols: [], components: [], byId: lookup };
1353
+ // A miss is reported only for a generated-shape id; anything else resolves as before.
1354
+ const byId = lookup && WHY_GENERATED_ID.test(target) ? lookup : undefined;
1346
1355
  const decisions = this.recs("decisions").filter(opts.canRead ?? (() => true));
1347
1356
  const bugs = this.recs("bugs").filter(opts.canRead ?? (() => true));
1348
1357
  const constraints = this.recs("constraints").filter(opts.canRead ?? (() => true));
@@ -1379,8 +1388,62 @@ export class HunchStore {
1379
1388
  && inWindow(c.valid_from, c.valid_to, asOf)),
1380
1389
  symbols: matchedSymbols,
1381
1390
  components: components.filter((c) => c.paths.some((g) => pathMatchesGlob(target, g) || [...fileSet].some((f) => pathMatchesGlob(f, g)))),
1391
+ ...(byId ? { byId } : {}),
1382
1392
  };
1383
1393
  }
1394
+ /** Look a record up by id across the record kinds why() explains (public ∪ private
1395
+ * via recs(), narrowed by `canRead`). A decision carries its supersession chain.
1396
+ * With `asOf`, a record outside its valid window then is "no record", and only
1397
+ * chain links/neighbours already in effect then are shown. Status is the STORED
1398
+ * status, exactly as the path/symbol view prints it.
1399
+ * Neighbour ids the caller may not read are never printed. */
1400
+ whyRecord(id, canRead = () => true, asOf) {
1401
+ const find = (kind) => this.recs(kind).find((r) => r.id === id && canRead(r));
1402
+ // Decisions the caller may read that had taken effect by `asOf` (all when no asOf).
1403
+ const decs = new Map(this.recs("decisions").filter((x) => canRead(x) && inWindow(x.valid_from, null, asOf)).map((x) => [x.id, x]));
1404
+ const visible = (ref) => (ref && decs.has(ref) ? ref : null);
1405
+ const existed = (instant) => !asOf || instant <= asOf;
1406
+ let record = null;
1407
+ switch (id.slice(0, id.indexOf("_"))) {
1408
+ case "dec": {
1409
+ const d = find("decisions");
1410
+ if (!d || !inWindow(d.valid_from, d.valid_to, asOf))
1411
+ break;
1412
+ // Stored status, as the path view prints it; asOf only hides neighbours not yet in effect.
1413
+ const shown = { ...d, supersedes: visible(d.supersedes), superseded_by: visible(d.superseded_by) };
1414
+ record = { kind: "decision", record: shown, chain: supersessionChain(shown, decs) };
1415
+ break;
1416
+ }
1417
+ case "con": {
1418
+ const c = find("constraints");
1419
+ if (!c || !inWindow(c.valid_from, c.valid_to, asOf))
1420
+ break;
1421
+ record = { kind: "constraint", record: { ...c, source_decision: visible(c.source_decision) } };
1422
+ break;
1423
+ }
1424
+ case "bug": {
1425
+ const b = find("bugs");
1426
+ if (b)
1427
+ record = { kind: "bug", record: b };
1428
+ break;
1429
+ }
1430
+ case "fnd": {
1431
+ const f = find("findings");
1432
+ if (f && existed(f.observed_at))
1433
+ record = { kind: "finding", record: f };
1434
+ break;
1435
+ }
1436
+ // Not gated by asOf: the path view lists components regardless of asOf too.
1437
+ // Findings stay gated by observed_at (the path view does not list findings).
1438
+ case "cmp": {
1439
+ const c = find("components");
1440
+ if (c)
1441
+ record = { kind: "component", record: c };
1442
+ break;
1443
+ }
1444
+ }
1445
+ return { id, record, ...(asOf ? { asOf } : {}) };
1446
+ }
1384
1447
  /** Transitive blast radius: every symbol/component that (in)directly depends on
1385
1448
  * `id`, via a recursive CTE over the edges graph (hunch_get_dependents). We
1386
1449
  * walk edges BACKWARD (edges.to = current) following call/dep/import/contains. */
@@ -2334,6 +2397,22 @@ function inWindow(valid_from, valid_to, asOf) {
2334
2397
  return false;
2335
2398
  return true;
2336
2399
  }
2400
+ /** A decision's supersedes/superseded_by chain over `decs` (the readable, already
2401
+ * in-effect decisions), oldest first, cycle-guarded. `d` is the record as shown. */
2402
+ function supersessionChain(d, decs) {
2403
+ const seen = new Set([d.id]);
2404
+ const back = [];
2405
+ for (let cur = d.supersedes ? decs.get(d.supersedes) : undefined; cur && !seen.has(cur.id); cur = cur.supersedes ? decs.get(cur.supersedes) : undefined) {
2406
+ seen.add(cur.id);
2407
+ back.unshift(cur);
2408
+ }
2409
+ const fwd = [];
2410
+ for (let cur = d.superseded_by ? decs.get(d.superseded_by) : undefined; cur && !seen.has(cur.id); cur = cur.superseded_by ? decs.get(cur.superseded_by) : undefined) {
2411
+ seen.add(cur.id);
2412
+ fwd.push(cur);
2413
+ }
2414
+ return [...back, d, ...fwd].map((x) => ({ id: x.id, status: x.status, title: x.title }));
2415
+ }
2337
2416
  function round(n) {
2338
2417
  return Math.round(n * 100) / 100;
2339
2418
  }
@@ -6,8 +6,11 @@ export declare const MAX_JSON_RECORD_BYTES: number;
6
6
  export declare const MAX_JSON_INDEX_BYTES: number;
7
7
  export declare const MAX_JSON_MANIFEST_BYTES: number;
8
8
  export declare const MAX_JSON_DIRECTORY_ENTRIES_PER_KIND = 100000;
9
+ /** When the process now holding `pid` was created (wall-clock ms), or null. */
10
+ type StartedAtProbe = (pid: number) => number | null;
9
11
  export declare class JsonStore {
10
12
  private readonly paths;
13
+ private readonly lockOpts;
11
14
  private _warnedForward;
12
15
  /** Memoized validated records per kind. loadAll() is on every read-path method
13
16
  * (why/checkConstraints/fragility/assembleContext…), each of which previously
@@ -20,7 +23,13 @@ export declare class JsonStore {
20
23
  private readonly lexicalRoot;
21
24
  private readonly canonicalRoot;
22
25
  private readonly lexicalHunch;
23
- constructor(paths: HunchPaths);
26
+ /** `lockOpts.startedAt` is an INTERNAL TEST SEAM, not API: it replaces the OS
27
+ * creation-time probe of the `.rmw-lock` "unproven" rule so the suite can
28
+ * drive it on linux, where the real probe reports nothing. Callers outside
29
+ * this repo must leave it unset — `processStartedAt` is the only sound probe. */
30
+ constructor(paths: HunchPaths, lockOpts?: {
31
+ startedAt?: StartedAtProbe;
32
+ });
24
33
  private lstatOrMissing;
25
34
  private expectedCanonical;
26
35
  /** Require an ordinary, canonically-contained directory. Creation is one
@@ -125,3 +134,4 @@ export declare class JsonStore {
125
134
  skipped: number;
126
135
  };
127
136
  }
137
+ export {};