@davesheffer/hunch 1.45.1 → 1.46.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/dist/cli/index.js +165 -33
  2. package/dist/cli/invocation.d.ts +14 -0
  3. package/dist/cli/invocation.js +23 -0
  4. package/dist/cli/serve.d.ts +2 -0
  5. package/dist/cli/serve.js +2 -1
  6. package/dist/cli/verify.d.ts +28 -0
  7. package/dist/cli/verify.js +265 -0
  8. package/dist/constitution/experiment.d.ts +1 -1
  9. package/dist/core/agenthook.d.ts +1 -0
  10. package/dist/core/agenthook.js +82 -26
  11. package/dist/core/boundedSpawn.d.ts +57 -0
  12. package/dist/core/boundedSpawn.js +321 -0
  13. package/dist/core/config.d.ts +6 -0
  14. package/dist/core/config.js +1 -0
  15. package/dist/core/delivery.d.ts +10 -0
  16. package/dist/core/delivery.js +117 -9
  17. package/dist/core/migrate.d.ts +10 -1
  18. package/dist/core/migrate.js +17 -6
  19. package/dist/core/pipeline.d.ts +9 -2
  20. package/dist/core/pipeline.js +13 -3
  21. package/dist/core/procstart.d.ts +17 -3
  22. package/dist/core/procstart.js +26 -5
  23. package/dist/core/projectDna.js +11 -1
  24. package/dist/core/relativeImports.js +6 -2
  25. package/dist/core/taskSelection.d.ts +1 -0
  26. package/dist/core/taskSelection.js +132 -11
  27. package/dist/core/testSelection.d.ts +122 -0
  28. package/dist/core/testSelection.js +588 -0
  29. package/dist/core/whyRecord.d.ts +47 -0
  30. package/dist/core/whyRecord.js +105 -0
  31. package/dist/extractors/git.d.ts +32 -0
  32. package/dist/extractors/git.js +206 -94
  33. package/dist/extractors/indexer.js +42 -6
  34. package/dist/extractors/languages.d.ts +9 -0
  35. package/dist/extractors/languages.js +28 -0
  36. package/dist/extractors/parse.d.ts +20 -0
  37. package/dist/extractors/parse.js +81 -5
  38. package/dist/extractors/workspaces.js +3 -1
  39. package/dist/integrations/health.d.ts +6 -0
  40. package/dist/integrations/health.js +33 -12
  41. package/dist/integrations/hooks.d.ts +4 -2
  42. package/dist/integrations/hooks.js +12 -2
  43. package/dist/integrations/mergeDriver.js +5 -3
  44. package/dist/integrations/optout.d.ts +3 -0
  45. package/dist/integrations/optout.js +17 -0
  46. package/dist/integrations/providers.d.ts +6 -2
  47. package/dist/integrations/providers.js +19 -2
  48. package/dist/integrations/team.js +6 -4
  49. package/dist/integrations/uninstall.d.ts +17 -4
  50. package/dist/integrations/uninstall.js +35 -13
  51. package/dist/mcp/server.js +179 -30
  52. package/dist/serve/app.js +9 -1
  53. package/dist/serve/config.js +5 -0
  54. package/dist/serve/writelock.d.ts +6 -5
  55. package/dist/serve/writelock.js +26 -21
  56. package/dist/store/hunchStore.d.ts +12 -1
  57. package/dist/store/hunchStore.js +86 -4
  58. package/dist/store/jsonStore.d.ts +24 -4
  59. package/dist/store/jsonStore.js +75 -26
  60. package/dist/store/stateBinding.js +17 -4
  61. package/dist/synthesis/provider.js +26 -78
  62. package/dist/synthesis/synthesize.js +1 -1
  63. package/package.json +2 -2
  64. package/server.json +2 -2
@@ -12,9 +12,9 @@
12
12
  *
13
13
  * `.hunch/` holds the engineering memory, so it stays unless `--purge`.
14
14
  */
15
- import { existsSync, lstatSync, readFileSync, readdirSync, rmdirSync, rmSync, statSync, writeFileSync, chmodSync } from "node:fs";
15
+ import { existsSync, readFileSync, readdirSync, rmdirSync, rmSync, statSync, writeFileSync, chmodSync } from "node:fs";
16
16
  import { spawnSync } from "node:child_process";
17
- import { basename, join, relative, resolve } 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
20
  import { currentBranch, gitCommonDir, isGitRepo, isLinkedWorktree, separateGitDirLink } from "../extractors/git.js";
@@ -26,18 +26,39 @@ 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";
29
- /** The opt-out marker, at the repository root. Commit it so every clone stays opted out. */
30
- export const OPT_OUT_FILE = ".hunch-removed";
31
- export function isOptedOut(root) {
32
- try {
33
- return lstatSync(join(root, OPT_OUT_FILE)).isFile();
34
- }
35
- catch {
36
- return false;
29
+ import { isOptedOut, OPT_OUT_FILE } from "./optout.js";
30
+ export { isOptedOut, OPT_OUT_FILE };
31
+ /** Whether `start`, or the repository containing it, opted out: walks up through
32
+ * `start`'s ancestors (which need not exist yet), stopping after the first one holding
33
+ * `.git` or at the filesystem root, so a path inside an opted-out repository (`serve
34
+ * init --root ./partitions/x`) is gated like the repository itself (#458). A `.hunch`
35
+ * store is deliberately not a boundary: a partition store nested in an opted-out
36
+ * repository stays gated (fail closed). */
37
+ export function optedOutAt(start) {
38
+ return optOutMarkerAt(start) !== null;
39
+ }
40
+ /** The opt-out marker that gates `start` (walked as in `optedOutAt`), or null. With
41
+ * `withinRepo`, a marker counts only when the walk reaches a repository: outside one,
42
+ * an ancestor's marker gates nothing. */
43
+ export function optOutMarkerAt(start, opts = {}) {
44
+ let found = null;
45
+ for (let dir = resolve(start);;) {
46
+ if (!found && isOptedOut(dir)) {
47
+ found = join(dir, OPT_OUT_FILE);
48
+ if (!opts.withinRepo)
49
+ return found;
50
+ }
51
+ if (existsSync(join(dir, ".git")))
52
+ return found;
53
+ const parent = dirname(dir);
54
+ if (parent === dir)
55
+ return opts.withinRepo ? null : found;
56
+ dir = parent;
37
57
  }
38
58
  }
39
- export function optOutMessage() {
40
- 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\`.`;
59
+ /** The refusal, naming the marker of the repository actually checked (#488). */
60
+ export function optOutMessage(marker = OPT_OUT_FILE) {
61
+ return `Hunch was removed from this repository on purpose (${marker}), so it will not set itself up here again. To reinstall, delete ${marker} and run \`hunch init\`.`;
41
62
  }
42
63
  function markerText() {
43
64
  return [
@@ -292,6 +313,7 @@ export function planUninstall(root, opts = {}) {
292
313
  p.hookConfig(".codex/hooks.json", providerEntry);
293
314
  p.hookConfig(".github/hooks/hunch.json", providerEntry);
294
315
  p.hookConfig(".windsurf/hooks.json", providerEntry);
316
+ p.hookConfig(".devin/hooks.json", providerEntry);
295
317
  p.json(".agents/hooks.json", "removed the hunch hook group", (json) => {
296
318
  if (!("hunch" in json))
297
319
  return false;
@@ -441,7 +463,7 @@ export function planUninstall(root, opts = {}) {
441
463
  }
442
464
  /** Directories Hunch's writers create; removed only when left empty. Deepest first. */
443
465
  const PRUNE_DIRS = [
444
- ".claude/commands", ".claude", ".cursor/rules", ".cursor", ".windsurf/rules", ".windsurf",
466
+ ".claude/commands", ".claude", ".cursor/rules", ".cursor", ".windsurf/rules", ".windsurf", ".devin",
445
467
  ".vscode", ".github/hooks", ".github/workflows", ".github", ".codex", ".agents",
446
468
  ];
447
469
  export function applyUninstall(plan) {
@@ -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, WHY_RECORD_ID } 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,74 @@ 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
+ // Exact-id record reads (findings, corrections, imported ADRs, why-by-id): the
1257
+ // same confirmation, gated to shared mode. Null means "re-read; absence is
1258
+ // confirmed or the store is not shared", else the abstention to return instead.
1259
+ const confirmSharedRecord = (subject) => store.mode === "shared" && store.privateDir ? confirmSharedMiss(`${subject} is absent from shared team memory`, subject) : null;
1260
+ // Policy reads: confirm a local policy (or its referenced proof) miss against
1261
+ // fresh shared memory before the service reports "not found". Public-only reads
1262
+ // and non-shared stores never consult the team remote.
1263
+ const confirmSharedPolicy = (policyId, publicOnly) => {
1264
+ if (publicOnly || store.mode !== "shared" || !store.privateDir)
1265
+ return null;
1266
+ const repository = new PolicyRepository(root, store);
1267
+ const policy = repository.getPolicy(policyId);
1268
+ if (policy && (!policy.proof || repository.getProof(policy.proof)))
1269
+ return null;
1270
+ const subject = policy ? `proof ${policy.proof} of policy ${policyId}` : `policy ${policyId}`;
1271
+ return confirmSharedMiss(`${subject} is absent from shared team memory`, subject);
1272
+ };
1194
1273
  // Resolve the embedder ONCE for this long-lived process (never throws; null when
1195
1274
  // the optional model isn't installed). The model then loads lazily on the first
1196
1275
  // hunch_query and stays warm — and hybridSearch degrades to FTS until then.
@@ -1231,6 +1310,8 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
1231
1310
  nextRemotePullAt = prepared.nextRemotePullAt;
1232
1311
  consecutivePullFailures = prepared.consecutivePullFailures;
1233
1312
  indexedSourceStamp = prepared.indexedSourceStamp;
1313
+ lastForcedFailure = null;
1314
+ forcedFailureStreak = 0;
1234
1315
  previous.close();
1235
1316
  console.error(`[hunch-mcp] serving Hunch at ${root} (client root)`);
1236
1317
  };
@@ -1339,8 +1420,10 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
1339
1420
  || !overlayMatchesTeamRemote(root, join(store.privateDir, "..")))) {
1340
1421
  return refused("The committed team memory destination is invalid or no longer matches this process. Refusing the stale graph; reconnect Hunch first.");
1341
1422
  }
1423
+ let pulledFresh = false;
1342
1424
  if (store.mode === "shared" && store.privateDir) {
1343
- pullTeamMemory();
1425
+ const pulled = pullTeamMemory();
1426
+ pulledFresh = pulled !== "cooldown" && pulled !== "not_shared" && teamPullIsFresh(pulled);
1344
1427
  // Recompute the full semantic + physical snapshot after the synchronous
1345
1428
  // network seam. A paired team.json/origin change can occur while fetch is
1346
1429
  // blocked; serving after that race would attach the old checkout to a new
@@ -1360,7 +1443,17 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
1360
1443
  refreshIndex();
1361
1444
  }
1362
1445
  catch { /* corrupt/churning local source — serve the last durable indexed view */ }
1363
- const result = await withInitiator(initiatorFromClient(server.server.getClientVersion()?.name), () => callback(...args));
1446
+ // A shared-miss check in the callback's synchronous prefix may reuse this
1447
+ // request's fresh pull; any later (post-await) check falls back to a forced pull.
1448
+ const result = await withInitiator(initiatorFromClient(server.server.getClientVersion()?.name), () => {
1449
+ requestPulledFresh = pulledFresh;
1450
+ try {
1451
+ return callback(...args);
1452
+ }
1453
+ finally {
1454
+ requestPulledFresh = false;
1455
+ }
1456
+ });
1364
1457
  if (teamAdvertised && !matchesStartupTeamRoute()) {
1365
1458
  return refused("The team-memory route changed while the tool was running. Its startup destination was not published; reconnect Hunch before retrying.");
1366
1459
  }
@@ -1409,16 +1502,35 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
1409
1502
  // -- hunch_why ------------------------------------------------------------
1410
1503
  server.registerTool("hunch_why", {
1411
1504
  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).",
1505
+ 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
1506
  inputSchema: {
1414
- target: z.string().describe("A file path (e.g. src/auth/session.ts) or symbol name."),
1507
+ target: z.string().describe("A file path (e.g. src/auth/session.ts), symbol name, or record id (e.g. dec_0b5d2be516)."),
1415
1508
  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
1509
  },
1417
1510
  }, async ({ target, as_of }) => {
1418
1511
  const asOf = as_of ? asOfDate(as_of, root) : undefined;
1419
1512
  if (as_of && !asOf)
1420
1513
  return invalid(`Could not resolve as_of "${as_of}" to a commit.`);
1421
- const w = store.why(target, { asOf });
1514
+ let w = store.why(target, { asOf });
1515
+ // An id-shaped target is an id miss when the store reported one (generated
1516
+ // shape) or, for a hand-written id, when nothing else matched it either.
1517
+ const idMiss = (r) => !r.byId?.record && (r.byId !== undefined || (WHY_RECORD_ID.test(target)
1518
+ && !r.decisions.length && !r.constraints.length && !r.bugs.length && !r.components.length && !r.symbols.length));
1519
+ // "No record <id>" is a team-absence claim in shared mode: confirm the local
1520
+ // miss against a fresh pull first, like hunch_current_decision. A record held
1521
+ // locally but outside its as_of window is not a team-absence claim: no pull.
1522
+ const heldNow = () => !!(asOf && store.why(target).byId?.record);
1523
+ if (idMiss(w) && !heldNow()) {
1524
+ const abstention = confirmSharedRecord(`record ${w.byId?.id ?? target}`);
1525
+ if (abstention)
1526
+ return err(abstention);
1527
+ w = store.why(target, { asOf });
1528
+ }
1529
+ // A hand-written id held now but not in force at as_of: say so, like a generated id.
1530
+ if (asOf && !w.byId && idMiss(w) && heldNow())
1531
+ w = { ...w, byId: { id: target, record: null, asOf } };
1532
+ if (w.byId?.record)
1533
+ return ok(formatWhyRecord(w.byId).join("\n"));
1422
1534
  // Highest-signal first, then cap: invariants by severity, decisions by
1423
1535
  // confidence, bugs by severity — so a hot file's trim drops the tail, not
1424
1536
  // the records that matter most.
@@ -1426,6 +1538,8 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
1426
1538
  const constraints = [...w.constraints].sort((a, b) => (SEV_CONSTRAINT[b.severity] ?? 0) - (SEV_CONSTRAINT[a.severity] ?? 0));
1427
1539
  const bugs = [...w.bugs].sort((a, b) => (SEV_BUG[b.severity] ?? 0) - (SEV_BUG[a.severity] ?? 0));
1428
1540
  const parts = [`Why for "${target}":`];
1541
+ if (w.byId)
1542
+ parts.push(`\n${formatWhyRecord(w.byId).join("\n")}`);
1429
1543
  if (decisions.length)
1430
1544
  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
1545
  if (constraints.length)
@@ -1963,7 +2077,14 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
1963
2077
  },
1964
2078
  }, async ({ decision_id, expected_source_hash, expected_review_hash, disposition, reviewed_by }) => {
1965
2079
  try {
1966
- const decision = store.advisoryRecs("decisions").find((candidate) => candidate.id === decision_id);
2080
+ const find = () => store.advisoryRecs("decisions").find((candidate) => candidate.id === decision_id);
2081
+ let decision = find();
2082
+ if (!decision) {
2083
+ const abstention = confirmSharedRecord(`imported ADR ${decision_id}`);
2084
+ if (abstention)
2085
+ return err(abstention);
2086
+ decision = find();
2087
+ }
1967
2088
  if (!decision)
1968
2089
  return invalid(`Imported ADR ${decision_id} is not present in the current advisory memory home.`);
1969
2090
  // Approval grants human authority, so the agent's report that the human approved
@@ -2071,21 +2192,11 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
2071
2192
  let live = liveForTopic(decs, topic);
2072
2193
  // A prior transient fetch failure may have placed ordinary tool traffic in
2073
2194
  // 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.
2195
+ // miss must be confirmed against fresh team memory (confirmSharedMiss).
2077
2196
  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
- }
2197
+ const abstention = confirmSharedMiss(`topic "${topic}" has no current decision`, `topic "${topic}"`);
2198
+ if (abstention)
2199
+ return err(abstention);
2089
2200
  decs = store.recs("decisions");
2090
2201
  live = liveForTopic(decs, topic);
2091
2202
  }
@@ -2588,7 +2699,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
2588
2699
  });
2589
2700
  server.registerTool("nuryel_read", {
2590
2701
  title: "nuryel.state/1 read — the system-of-record answer for a subject",
2591
- description: "Read organizational state under a delivery receipt. Pass the principal (id, kind, grants) and the scope; optionally a subject (an entity id, a decision topic, an external `object_type:object_key`) to get state_of_record — what is current, in force, done, what it depends on and what invalidates it — plus a task phrase for the ranked delivery envelope. Scopes the principal is not granted are named in denied_scopes, never silently dropped. To read observations beyond the default 64, use observed_page:{} with one scope and a subject, then pass state_of_record.observed_page.next_cursor as observed_page.cursor until null. A conflict means the observations changed: restart from the first page. Never claim complete coverage while a next cursor remains.",
2702
+ description: "Read organizational state under a delivery receipt. Pass the principal (id, kind, grants) and the scope; optionally a subject (an entity id, a decision topic, an external `object_type:object_key`) to get state_of_record — what is current, in force, done, what it depends on and what invalidates it — plus a task phrase for the ranked delivery envelope. Scopes the principal is not granted are named in denied_scopes, never silently dropped. To read observations beyond the default 64, use observed_page:{} with one scope and a subject, then pass state_of_record.observed_page.next_cursor as observed_page.cursor until null. A conflict means the observations changed: restart from the first page. Never claim complete coverage while a next cursor remains. facets filters state_of_record and conventions only; the delivery envelope is ranked independently and keeps the constraints in force.",
2592
2703
  inputSchema: ReadRequestSchema.omit({ schema: true }).shape,
2593
2704
  outputSchema: ReadResponseSchema.shape,
2594
2705
  }, async (input) => {
@@ -2652,7 +2763,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
2652
2763
  });
2653
2764
  server.registerTool("nuryel_write", {
2654
2765
  title: "nuryel.state/1 write — provenance + idempotency in, durability out",
2655
- description: "Write one record into a facet (receipts, commitments, derived, entities, relationships, or the legacy decisions/constraints/bugs/findings). The record must carry provenance; the request must carry an idempotency_key — a replay returns the original, a reused key with a different payload is refused. Ids are derived from the record's facts, never chosen. A second live decision on a topic is refused with the incumbent named; pass supersedes to replace it explicitly. organization/team/user partitions never ride a repository: they require an overlay. To show an existing captured observation under another subject without copying it, write a relationship type observation_about with from=observation id, to=subject, observation_hash, lifecycle=active, reason and hashed external evidence of the explicit association. Retire the relationship to unlink; reactivation requires expected_version. With capability nuryel.field-provenance/1, a derived record may carry field_provenance: [{selector:{kind:json_pointer,path:/field} or {kind:text,start:0,end:10}, value_hash:stateHash(selected scalar or text), dependency_hashes:[stateHash(existing dependency)]}]. Text offsets count Unicode code points, end exclusive. Citations are writer-supplied traceability, not verified support; negotiate support in every shared reader before writing them. With nuryel.record-visibility/1, records may carry visibility:{owner:principal-id,readers:[ids],writers:[ids]}; the owner is implicit in both lists, and writers must be readers. Owner-only audience changes require expected_version, including supersession. Restricted records require a dedicated partition; local stdio principal assertions assume trusted callers.",
2766
+ description: "Write one record into a facet (receipts, commitments, derived, entities, relationships, or the legacy decisions/constraints/bugs/findings). The record must carry provenance; the request must carry an idempotency_key — a replay returns the original, a reused key with a different payload is refused. Ids are derived from the record's facts, never chosen. A second live decision on a topic is refused with the incumbent named; pass supersedes to replace it explicitly. organization/team/user partitions never ride a repository: they require an overlay. To show an existing captured observation under another subject without copying it, write a relationship type observation_about with from=observation id, to=subject, observation_hash, lifecycle=active, reason and hashed external evidence of the explicit association. Retire the relationship to unlink; reactivation requires expected_version. With capability nuryel.field-provenance/1, a derived record may carry field_provenance: [{selector:{kind:json_pointer,path:/field} or {kind:text,start:0,end:10}, value_hash:stateHash(selected scalar or text), dependency_hashes:[stateHash(existing dependency)]}]. Text offsets count Unicode code points, end exclusive. Citations are writer-supplied traceability, not verified support; negotiate support in every shared reader before writing them. With nuryel.record-visibility/1, records may carry visibility:{owner:principal-id,readers:[ids],writers:[ids]}; the owner is implicit in both lists, and writers must be readers. Owner-only audience changes require expected_version, including supersession. Restricted records require a dedicated partition; local stdio principal assertions assume trusted callers. expected_version is compare-and-swap against the incumbent, or with supersedes against the predecessor (its record hash or ledger seq); for a record not on file pass null, or 0 to assert the id was never written — any other value is refused as a conflict.",
2656
2767
  inputSchema: { ...WriteRequestSchema.omit({ schema: true }).shape, cwd: cwdHintField },
2657
2768
  outputSchema: WriteResultSchema.shape,
2658
2769
  }, async ({ cwd: _cwd, ...input }) => {
@@ -2757,7 +2868,14 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
2757
2868
  const concerns = (f) => [...f.affected_files, ...f.affected_symbols].join(", ") || "(unscoped)";
2758
2869
  const links = (f) => [f.violates_constraint ? `violates ${f.violates_constraint}` : "", f.method ? `re-verify via ${f.method}` : "", f.resolved_commit ? `fixed in ${f.resolved_commit.slice(0, 9)}` : ""].filter(Boolean).join("; ");
2759
2870
  if (id) {
2760
- const f = store.recs("findings").find((r) => r.id === id);
2871
+ const find = () => store.recs("findings").find((r) => r.id === id);
2872
+ let f = find();
2873
+ if (!f) {
2874
+ const abstention = confirmSharedRecord(`finding ${id}`);
2875
+ if (abstention)
2876
+ return err(abstention);
2877
+ f = find();
2878
+ }
2761
2879
  if (!f)
2762
2880
  return ok(`No finding "${id}".`);
2763
2881
  return ok(`[${f.triage}/${f.severity}] ${f.title} (${f.id}, observed ${f.observed_at.slice(0, 10)})\n${f.observation}\nconcerns: ${concerns(f)}${links(f) ? `\n${links(f)}` : ""}`);
@@ -2790,6 +2908,16 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
2790
2908
  try {
2791
2909
  if (public_only && private_only)
2792
2910
  return invalid("Choose only one of public_only or private_only.");
2911
+ // A correction missing from every home this call may read is confirmed against
2912
+ // fresh shared memory before the upgrade reports it "not found". Public-only
2913
+ // reads never consult the team remote.
2914
+ const heldCorrection = () => !!(store.getPrivateRec("constraints", constraint_id)
2915
+ || (!private_only && store.json.get("constraints", constraint_id)));
2916
+ if (!public_only && !heldCorrection()) {
2917
+ const abstention = confirmSharedRecord(`correction ${constraint_id}`);
2918
+ if (abstention)
2919
+ return err(abstention);
2920
+ }
2793
2921
  // Resolve the correction's exact home before any writes. Overlay-first is
2794
2922
  // the same selection contract as ConstitutionService.upgradeCorrection;
2795
2923
  // deriving this later from a policy id is unsafe when legacy public and
@@ -3002,6 +3130,9 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
3002
3130
  },
3003
3131
  }, async ({ policy_id, public_only }) => {
3004
3132
  try {
3133
+ const abstention = confirmSharedPolicy(policy_id, public_only);
3134
+ if (abstention)
3135
+ return err(abstention);
3005
3136
  const service = new ConstitutionService(store, root);
3006
3137
  const plan = service.plan(policy_id, { publicOnly: public_only });
3007
3138
  const home = public_only ? "public" : service.repository.homeOfPolicy(policy_id);
@@ -3024,6 +3155,9 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
3024
3155
  },
3025
3156
  }, async ({ policy_id, public_only }) => {
3026
3157
  try {
3158
+ const abstention = confirmSharedPolicy(policy_id, public_only);
3159
+ if (abstention)
3160
+ return err(abstention);
3027
3161
  return ok(JSON.stringify(new ConstitutionService(store, root).card(policy_id, { publicOnly: public_only }), null, 2));
3028
3162
  }
3029
3163
  catch (e) {
@@ -3039,6 +3173,9 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
3039
3173
  },
3040
3174
  }, async ({ policy_id, public_only }) => {
3041
3175
  try {
3176
+ const abstention = confirmSharedPolicy(policy_id, public_only);
3177
+ if (abstention)
3178
+ return err(abstention);
3042
3179
  return ok(JSON.stringify(new ConstitutionService(store, root).shadowReport(policy_id, {}, { publicOnly: public_only }), null, 2));
3043
3180
  }
3044
3181
  catch (e) {
@@ -3054,6 +3191,9 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
3054
3191
  },
3055
3192
  }, async ({ policy_id, public_only }) => {
3056
3193
  try {
3194
+ const abstention = confirmSharedPolicy(policy_id, public_only);
3195
+ if (abstention)
3196
+ return err(abstention);
3057
3197
  const service = new ConstitutionService(store, root);
3058
3198
  const policy = service.get(policy_id, { publicOnly: public_only });
3059
3199
  if (!policy.proof)
@@ -3080,6 +3220,11 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
3080
3220
  throw new Error("choose either workspace or commit for executable-behavior evaluation");
3081
3221
  if (commit && !revExists(commit, root))
3082
3222
  throw new Error(`commit ref ${JSON.stringify(commit)} does not resolve`);
3223
+ if (policy_id) {
3224
+ const abstention = confirmSharedPolicy(policy_id, public_only);
3225
+ if (abstention)
3226
+ return err(abstention);
3227
+ }
3083
3228
  const exactCommit = commit ? revParse(`${commit}^{commit}`, root) : undefined;
3084
3229
  const behavior = workspace ? { workspace }
3085
3230
  : exactCommit ? { commit: exactCommit }
@@ -3369,6 +3514,10 @@ export async function startServer(cwd = process.cwd(), options = {}) {
3369
3514
  wireClientRoots(control, fallback);
3370
3515
  const transport = new StdioServerTransport();
3371
3516
  await control.server.connect(transport);
3517
+ // Exit through process.exit on a termination signal (same conventional codes as
3518
+ // a signal death) so NODE_V8_COVERAGE is flushed for `hunch verify --refresh-map`.
3519
+ process.once("SIGTERM", () => process.exit(143));
3520
+ process.once("SIGINT", () => process.exit(130));
3372
3521
  console.error(control.pinned
3373
3522
  ? `[hunch-mcp] serving Hunch over stdio (pinned root ${control.getRoot()}; client roots ignored)`
3374
3523
  : `[hunch-mcp] serving Hunch over stdio (spawn root ${control.getRoot()}; resolving client roots…)`);
package/dist/serve/app.js CHANGED
@@ -35,6 +35,8 @@ import { operatorHtml, operatorCss, operatorJs } from "./operator.js";
35
35
  import { MCP_PATH, handleMcpRequest } from "./mcpHttp.js";
36
36
  export const BODY_LIMIT_BYTES = 1024 * 1024;
37
37
  const POST_ROUTES = new Map([["/nuryel/v1/read", "read"], ["/nuryel/v1/write", "write"], ["/nuryel/v1/capture", "capture"], ["/nuryel/v1/capture-batch", "capture-batch"], ["/nuryel/v1/subscribe", "subscribe"], ["/nuryel/v1/records", "records"]]);
38
+ /** GET-only routes: any other method is 405 with `Allow: GET`, not a POST-table 404. */
39
+ const GET_ROUTES = new Set(["/nuryel/v1/health", "/nuryel/v1/capabilities"]);
38
40
  export const PROBLEM_TYPE = "https://www.hunchmemory.com/problems/nuryel.state/1/";
39
41
  export class HttpProblem extends Error {
40
42
  status;
@@ -272,6 +274,10 @@ export function createServeApp(config, opts = {}) {
272
274
  return send(res, 200, { ...liveness, partitions: activeConfig.partitions.map((p) => scopePath(p.scope)) });
273
275
  }
274
276
  const principal = await authenticate(req, res, url, activeConfig);
277
+ if (GET_ROUTES.has(url.pathname) && req.method !== "GET") {
278
+ res.setHeader("allow", "GET");
279
+ throw problem(405, "method-not-allowed", `${req.method} is not allowed on ${url.pathname}`);
280
+ }
275
281
  if (url.pathname === "/nuryel/v1/capabilities" && req.method === "GET") {
276
282
  const scope = parseScopeParam(url.searchParams.get("scope"));
277
283
  const { status, payload } = await dispatch("capabilities", principal, scope ? { scope } : {}, activeConfig);
@@ -286,8 +292,10 @@ export function createServeApp(config, opts = {}) {
286
292
  const message = await readBody(req);
287
293
  return await handleMcpRequest(req, res, message, (route, body) => dispatch(route, principal, body, activeConfig), (error) => problemBody(problemOf(error)), version);
288
294
  }
289
- if (req.method !== "POST")
295
+ if (req.method !== "POST") {
296
+ res.setHeader("allow", "POST");
290
297
  throw problem(405, "method-not-allowed", `${req.method} is not allowed on ${url.pathname}`);
298
+ }
291
299
  const route = POST_ROUTES.get(url.pathname);
292
300
  if (!route)
293
301
  throw problem(404, "not-found", `${url.pathname} is not a nuryel.state/1 route`);
@@ -114,6 +114,11 @@ export function initServeConfig(opts) {
114
114
  if (!publicOrigin)
115
115
  throw new Error("key-bound principals require an HTTPS public origin");
116
116
  }
117
+ // Refuse before writing anything: a grant must name a partition this config will serve.
118
+ const served = new Set([...(existing?.partitions ?? []).map((p) => scopePath(p.scope)), scopePath(opts.scope)]);
119
+ for (const g of opts.principal?.grants ?? [])
120
+ if (!served.has(scopePath(g)))
121
+ throw new Error(`cannot grant ${scopePath(g)}: this server does not serve it`);
117
122
  const root = resolve(opts.root);
118
123
  const hunchDir = resolve(root, ".hunch");
119
124
  mkdirSync(hunchDir, { recursive: true });
@@ -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,14 +10,12 @@ 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;
13
+ /** `nonce` is optional on READ only: this lock always writes one, but a legacy or
14
+ * foreign writer's record may lack it (issue #491). */
14
15
  export interface LockOwner {
15
16
  pid: number;
16
17
  host: string;
17
- nonce: string;
18
+ nonce?: string;
18
19
  at: string;
19
20
  start?: string;
20
21
  }