@davesheffer/hunch 1.45.2 → 1.47.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 (91) hide show
  1. package/dist/cli/index.js +290 -353
  2. package/dist/cli/invocation.d.ts +22 -13
  3. package/dist/cli/invocation.js +49 -29
  4. package/dist/cli/verify.d.ts +28 -0
  5. package/dist/cli/verify.js +265 -0
  6. package/dist/constitution/delta.js +2 -2
  7. package/dist/constitution/experiment.d.ts +1 -1
  8. package/dist/constitution/sourceMutation.js +28 -18
  9. package/dist/core/agenthook.js +26 -6
  10. package/dist/core/boundedSpawn.d.ts +57 -0
  11. package/dist/core/boundedSpawn.js +321 -0
  12. package/dist/core/commitrepair.d.ts +43 -138
  13. package/dist/core/commitrepair.js +76 -167
  14. package/dist/core/config.d.ts +6 -0
  15. package/dist/core/config.js +1 -0
  16. package/dist/core/correction.js +9 -9
  17. package/dist/core/delivery.d.ts +6 -1
  18. package/dist/core/delivery.js +71 -5
  19. package/dist/core/docanchors.js +9 -9
  20. package/dist/core/drift.js +22 -27
  21. package/dist/core/escalations.d.ts +16 -58
  22. package/dist/core/escalations.js +39 -121
  23. package/dist/core/glob.d.ts +7 -7
  24. package/dist/core/glob.js +12 -11
  25. package/dist/core/groundingLag.d.ts +2 -9
  26. package/dist/core/groundingLag.js +5 -11
  27. package/dist/core/groundingMerge.d.ts +2 -7
  28. package/dist/core/groundingMerge.js +35 -54
  29. package/dist/core/migrate.d.ts +10 -1
  30. package/dist/core/migrate.js +17 -6
  31. package/dist/core/paths.d.ts +17 -24
  32. package/dist/core/paths.js +37 -43
  33. package/dist/core/projectDna.js +11 -1
  34. package/dist/core/refrepair.d.ts +2 -1
  35. package/dist/core/refrepair.js +4 -4
  36. package/dist/core/relativeImports.js +6 -2
  37. package/dist/core/repairqueue.d.ts +17 -34
  38. package/dist/core/repairqueue.js +48 -98
  39. package/dist/core/taskSelection.js +48 -6
  40. package/dist/core/testSelection.d.ts +122 -0
  41. package/dist/core/testSelection.js +588 -0
  42. package/dist/core/types.d.ts +3 -3
  43. package/dist/core/types.js +1 -1
  44. package/dist/extractors/diff.d.ts +4 -4
  45. package/dist/extractors/diff.js +25 -29
  46. package/dist/extractors/git.d.ts +8 -23
  47. package/dist/extractors/git.js +77 -95
  48. package/dist/extractors/helm.d.ts +10 -0
  49. package/dist/extractors/helm.js +43 -59
  50. package/dist/extractors/indexer.d.ts +6 -0
  51. package/dist/extractors/indexer.js +168 -184
  52. package/dist/extractors/k8sManifest.d.ts +23 -80
  53. package/dist/extractors/k8sManifest.js +319 -690
  54. package/dist/extractors/languages.d.ts +37 -37
  55. package/dist/extractors/languages.js +83 -54
  56. package/dist/extractors/nativeTreeSitter.d.ts +4 -4
  57. package/dist/extractors/nativeTreeSitter.js +17 -14
  58. package/dist/extractors/parse.d.ts +27 -13
  59. package/dist/extractors/parse.js +120 -56
  60. package/dist/extractors/workspaces.js +3 -1
  61. package/dist/integrations/claudemd.js +1 -0
  62. package/dist/integrations/gitignore.js +3 -5
  63. package/dist/integrations/health.d.ts +6 -0
  64. package/dist/integrations/health.js +33 -12
  65. package/dist/integrations/hooks.d.ts +13 -16
  66. package/dist/integrations/hooks.js +31 -30
  67. package/dist/integrations/mergeDriver.js +18 -17
  68. package/dist/integrations/optout.d.ts +3 -0
  69. package/dist/integrations/optout.js +17 -0
  70. package/dist/integrations/providers.d.ts +6 -2
  71. package/dist/integrations/providers.js +19 -2
  72. package/dist/integrations/team.js +4 -2
  73. package/dist/integrations/uninstall.d.ts +10 -4
  74. package/dist/integrations/uninstall.js +22 -18
  75. package/dist/mcp/server.d.ts +4 -5
  76. package/dist/mcp/server.js +107 -81
  77. package/dist/serve/app.js +9 -1
  78. package/dist/serve/config.js +5 -0
  79. package/dist/serve/writelock.d.ts +3 -1
  80. package/dist/serve/writelock.js +17 -3
  81. package/dist/store/hunchStore.d.ts +2 -1
  82. package/dist/store/hunchStore.js +20 -23
  83. package/dist/store/jsonStore.d.ts +13 -3
  84. package/dist/store/jsonStore.js +37 -13
  85. package/dist/store/stateBinding.js +17 -4
  86. package/dist/synthesis/provider.d.ts +19 -21
  87. package/dist/synthesis/provider.js +93 -154
  88. package/dist/synthesis/synthesize.d.ts +8 -8
  89. package/dist/synthesis/synthesize.js +33 -50
  90. package/package.json +3 -3
  91. package/server.json +2 -2
@@ -69,7 +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
+ import { formatWhyRecord, more, WHY_CAP, WHY_RECORD_ID } from "../core/whyRecord.js"; // WHY_CAP: per record-type cap in hunch_why
73
73
  import { pendingEscalations, policyEscalations, commitRepairEscalations, actionableEscalations, escalationHeadline } from "../core/escalations.js";
74
74
  import { readActivePendingRepairs, withheldRewrites } from "../core/repairqueue.js";
75
75
  import { scanRecord, publicationWarning, loadVocabulary } from "../core/publication.js";
@@ -90,24 +90,25 @@ const err = (text) => ({ content: [{ type: "text", text }], isError: true });
90
90
  * Transient states that say "retry" in their own words stay unprefixed. */
91
91
  const refused = (text) => err(`Refused: ${text}`);
92
92
  const invalid = (text) => err(`Invalid: ${text}`);
93
- /** Shared by every auto-committing write tool (issue #20): the MCP `roots` protocol
94
- * cannot see an agent-driven `cd`/EnterWorktree, so a stdio server's cached root
95
- * never moves on its own — this is the client-agnostic fallback, resolved fresh on
96
- * every call by the generic tool wrapper below (see extractCwdHint). */
93
+ /** Shared by every auto-committing write tool. The MCP roots protocol cannot observe an
94
+ * agent-driven directory change or worktree entry, so a stdio server's cached root never
95
+ * moves by itself; this field is the client-agnostic fallback, resolved fresh on every
96
+ * call by the generic tool wrapper. */
97
97
  const cwdHintField = z.string().optional().describe("Your current directory, when it differs from where this session started (e.g. a worktree); " +
98
98
  "writes then commit there, not to the original root.");
99
99
  /** Shared by hunch_record_decision/correction/finding — identical text repeated 3x
100
100
  * otherwise. hunch_context's task_id field differs (delivery vs. save) and keeps its
101
101
  * own description. */
102
102
  const saveTaskIdField = TaskIdSchema.optional().describe("Task ID, to attribute this save to it; never affects capture authority.");
103
- /** Pull `cwd` out of a tool call's already-parsed input without assuming any one
104
- * tool's exact input shape — every write tool spreads the same cwdHintField in,
105
- * but the wrapper below runs for every tool, read or write. */
106
- function extractCwdHint(input) {
107
- if (!input || typeof input !== "object")
103
+ /** Pulls the cwd hint out of a tool call's already-parsed first argument. The wrapper
104
+ * runs for EVERY tool (read and write), so this assumes nothing about any one tool's
105
+ * input shape: anything that is not an object with a non-blank string `cwd` yields
106
+ * undefined. The original (untrimmed) string is returned. */
107
+ function cwdHintOf(input) {
108
+ if (typeof input !== "object" || input === null)
108
109
  return undefined;
109
110
  const cwd = input.cwd;
110
- return typeof cwd === "string" && cwd.trim() ? cwd : undefined;
111
+ return typeof cwd === "string" && cwd.trim() !== "" ? cwd : undefined;
111
112
  }
112
113
  /** Honest auto-commit suffix: reports only what flushCapture ACTUALLY did. A skipped
113
114
  * commit (backstop/lock/nothing staged) says nothing — the record is on disk and the
@@ -150,11 +151,9 @@ const publicHomeNote = (home, hasPrivate, record, hunchDir) => {
150
151
  return risk;
151
152
  return "\nℹ Landed in the COMMITTED PUBLIC store (publishes with the repo). For sensitive/strategy content, re-record with private:true — the overlay store." + risk;
152
153
  };
153
- /** Self-diagnosing destination report (issue #17/#20): the exact failure mode this
154
- * guards against is silent — a capture landing in the wrong repo/branch with no
155
- * sign of it short of a manual `git log` audit. Every auto-committing write tool
156
- * appends this so the destination is always visible in the response, whether or
157
- * not a cwd hint was involved in choosing it. */
154
+ /** Self-diagnosing destination suffix for every auto-committing write tool. A capture
155
+ * silently landing in the wrong repo or branch is only discoverable by auditing git log
156
+ * by hand, so the destination is ALWAYS shown, whether or not a cwd hint chose it. */
158
157
  const destinationNote = (destRoot) => {
159
158
  const branch = currentBranch(destRoot);
160
159
  return ` [captured${branch ? ` on branch ${branch}` : ""} in ${destRoot}]`;
@@ -586,12 +585,10 @@ function nuryelWriteSubject(facet, record) {
586
585
  const kind = facet.endsWith("s") ? facet.slice(0, -1) : facet;
587
586
  return `${kind}${typeof label === "string" ? ` "${label.slice(0, 60)}"` : ""} (nuryel_write)`;
588
587
  }
589
- /** Where a capture keyed to `home` actually lands: the private overlay directory when
590
- * one is configured, else the public repo root. Centralizes the branch used at every
591
- * destination-reporting call site below — `hunch_policy_upgrade_correction` once
592
- * diverged from this (computing its own `artifactHome` but reporting the public root
593
- * regardless), silently misreporting the destination for a private-homed proof. */
594
- const resolveDestRoot = (home, store, root) => home === "private" && store.privateDir ? store.privateDir : root;
588
+ /** Where a capture keyed to a storage home actually lands. Centralized so every
589
+ * destination-reporting call site agrees: hunch_policy_upgrade_correction once computed
590
+ * its own home but reported the public root regardless, misreporting a private-homed proof. */
591
+ const destinationRoot = (home, store, root) => home === "private" && store.privateDir ? store.privateDir : root;
595
592
  // Read-side token budgets: every tool result is injected into a Claude Code
596
593
  // session, so an uncapped list pollutes the context window. Cap each list to its
597
594
  // highest-signal head (records are pre-sorted by severity/confidence) and tell the
@@ -993,13 +990,12 @@ function qualityNudge(rec) {
993
990
  }
994
991
  return "";
995
992
  }
996
- /** Resolve a free-form target (symbol id / name / file path) to symbol records.
997
- * Tiered exact-id > exact-name > exact-file > segment-anchored-suffix matching,
998
- * shared with `HunchStore.why()` via `matchSymbolsTiered` so the two don't drift
999
- * apart on the same question. */
993
+ /** Resolve a free-form target (symbol id, name, or file path) to symbol records using
994
+ * tiered matching — exact id, then exact name, then exact file, then a path-segment-anchored
995
+ * suffix. Shared with HunchStore.why() through matchSymbolsTiered so the two never disagree. */
1000
996
  export function resolveSymbols(store, target) {
1001
- // An absolute target (an agent's edit-payload path, verbatim) never matched
1002
- // the repo-relative stored file paths below (issue #296).
997
+ // An absolute path (an agent's verbatim edit-payload path) must match the repo-relative
998
+ // stored file paths (issue #296).
1003
999
  target = repoRelativeTarget(target, store.publicRoot);
1004
1000
  const syms = store.json.loadAll("symbols");
1005
1001
  const components = store.json.loadAll("components");
@@ -1253,6 +1249,10 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
1253
1249
  }
1254
1250
  return null;
1255
1251
  };
1252
+ // Exact-id record reads (findings, corrections, imported ADRs, why-by-id): the
1253
+ // same confirmation, gated to shared mode. Null means "re-read; absence is
1254
+ // confirmed or the store is not shared", else the abstention to return instead.
1255
+ const confirmSharedRecord = (subject) => store.mode === "shared" && store.privateDir ? confirmSharedMiss(`${subject} is absent from shared team memory`, subject) : null;
1256
1256
  // Policy reads: confirm a local policy (or its referenced proof) miss against
1257
1257
  // fresh shared memory before the service reports "not found". Public-only reads
1258
1258
  // and non-shared stores never consult the team remote.
@@ -1368,23 +1368,20 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
1368
1368
  };
1369
1369
  const registerTool = server.registerTool.bind(server);
1370
1370
  server.registerTool = ((name, config, callback) => hiddenTools.has(name) ? undefined : registerTool(name, config, async (...args) => {
1371
- // Claude Code CLI never advertises `roots`/`roots/list_changed` for an agent-driven
1372
- // `cd` or EnterWorktree (issue #20) — the cached `root` above just never moves, so a
1373
- // write silently lands wherever the process was spawned. Write tools accept an
1374
- // optional `cwd` argument (see cwdHintField) as a client-agnostic fallback: resolved
1375
- // fresh on EVERY call instead of trusted from a cache, and re-homing this stdio
1376
- // process the same way a `roots` notification would. Only safe when this is the
1377
- // sole in-flight request — re-homing under a concurrent request would tear its
1378
- // root/store out from under it, so that case is refused rather than risked.
1379
- const cwdHint = extractCwdHint(args[0]);
1371
+ // The Claude Code CLI never advertises roots/list_changed for an agent `cd` or worktree
1372
+ // entry, so the cached root never moves and writes land where the process was spawned.
1373
+ // Write tools therefore accept an optional `cwd` (see cwdHintField) as a client-agnostic
1374
+ // fallback, resolved fresh on every call rather than cached, re-homing this stdio
1375
+ // process the way a roots notification would. That is only safe when no other request
1376
+ // is in flight, so that case is refused instead of risked.
1377
+ const cwdHint = cwdHintOf(args[0]);
1380
1378
  // A pinned root is the whole point of `hunch mcp --root`: a served partition must not
1381
1379
  // follow the caller's working directory into some other checkout.
1382
1380
  if (cwdHint !== undefined && !pinned) {
1383
1381
  const target = canonicalRootPath(findRoot(cwdHint));
1384
1382
  if (target !== canonicalRootPath(root)) {
1385
- if (activeRequests) {
1386
- return err(`Hunch is mid-request against ${root} and cannot safely switch to the working directory you passed ` +
1387
- `(resolves to ${target}) while another call is in flight. Retry this call once the other one completes.`);
1383
+ if (activeRequests !== 0) {
1384
+ return err(`Hunch is serving ${root} and cannot safely switch to your working directory (${target}) while another call is in flight. Retry once the other call completes.`);
1388
1385
  }
1389
1386
  try {
1390
1387
  setRoot(cwdHint);
@@ -1508,16 +1505,23 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
1508
1505
  if (as_of && !asOf)
1509
1506
  return invalid(`Could not resolve as_of "${as_of}" to a commit.`);
1510
1507
  let w = store.why(target, { asOf });
1508
+ // An id-shaped target is an id miss when the store reported one (generated
1509
+ // shape) or, for a hand-written id, when nothing else matched it either.
1510
+ const idMiss = (r) => !r.byId?.record && (r.byId !== undefined || (WHY_RECORD_ID.test(target)
1511
+ && !r.decisions.length && !r.constraints.length && !r.bugs.length && !r.components.length && !r.symbols.length));
1511
1512
  // "No record <id>" is a team-absence claim in shared mode: confirm the local
1512
1513
  // miss against a fresh pull first, like hunch_current_decision. A record held
1513
1514
  // 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}`);
1515
+ const heldNow = () => !!(asOf && store.why(target).byId?.record);
1516
+ if (idMiss(w) && !heldNow()) {
1517
+ const abstention = confirmSharedRecord(`record ${w.byId?.id ?? target}`);
1517
1518
  if (abstention)
1518
1519
  return err(abstention);
1519
1520
  w = store.why(target, { asOf });
1520
1521
  }
1522
+ // A hand-written id held now but not in force at as_of: say so, like a generated id.
1523
+ if (asOf && !w.byId && idMiss(w) && heldNow())
1524
+ w = { ...w, byId: { id: target, record: null, asOf } };
1521
1525
  if (w.byId?.record)
1522
1526
  return ok(formatWhyRecord(w.byId).join("\n"));
1523
1527
  // Highest-signal first, then cap: invariants by severity, decisions by
@@ -1912,13 +1916,13 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
1912
1916
  // -- hunch_now (the hot view: recent activity + roadmap) --------------------
1913
1917
  // PUBLIC store only, per dec_29eff08c69's jurisdiction rule: an assistant may
1914
1918
  // paste this anywhere, so it must be publishable by construction. Union view
1915
- // stays behind `hunch now --private` on the local terminal. EXCEPTION: a
1916
- // queued commit-repair's liveness is checked against the full store (see
1917
- // below), so a private-overlay decision's id and commit shas — never its
1918
- // title — can surface in the escalation line.
1919
+ // is available only through the local terminal command `hunch now --private`.
1920
+ // Exception: a queued commit-repair's liveness is checked against the full store, so a
1921
+ // private-overlay decision's id and commit shas (never its title) may appear in the
1922
+ // escalation line.
1919
1923
  server.registerTool("hunch_now", {
1920
1924
  title: "Recent activity + the roadmap (the hot view)",
1921
- description: "What just happened and what's next, straight from the graph: the last N decisions, the ROADMAP, and any inline human question such as an imported ADR awaiting explicit approve/decline. Call at session start to orient, or before planning what to work on. Same data as the wiki's now.md. Public store only, EXCEPT a queued commit-repair's liveness is checked against the full store (so a private-overlay decision's fully-answerable repair doesn't go silently unanswerable); only its id and the commit shas ever surface, never its title.",
1925
+ description: "What just happened and what is next, straight from the graph: the last N decisions, the ROADMAP, and any inline human question such as an imported ADR awaiting explicit approve/decline. Call at session start to orient, or before planning. Same data as the wiki's now.md. Public store only — except that a queued commit-repair's liveness is checked against the full store, so a private-overlay decision's answerable repair does not go silently unanswerable; only its id and commit shas ever surface, never its title.",
1922
1926
  inputSchema: {
1923
1927
  recent_limit: z.number().optional().describe("How many recent decisions to include (default 5)."),
1924
1928
  roadmap_limit: z.number().optional().describe("How many roadmap entries to include (default 2); the rest are counted."),
@@ -1951,18 +1955,15 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
1951
1955
  L.push("", ws);
1952
1956
  const escalations = pendingEscalations(store.advisoryRecs("decisions"));
1953
1957
  escalations.push(...premiseEscalations(store.advisoryRecs("decisions"), { now: new Date().toISOString(), exists: (p) => existsSync(join(root, p)) }));
1954
- // liveness checked against the full store (repair-provenance reads the
1955
- // full store too) even though the title stays scoped to advisoryRecs —
1956
- // an overlay decision's repair is fully answerable, so it must not go
1957
- // silent here just because its title is private.
1958
- const hunchNowQueue = readActivePendingRepairs(root);
1959
- escalations.push(...commitRepairEscalations(hunchNowQueue, store.advisoryRecs("decisions"), store.recs("decisions"), withheldRewrites(root, hunchNowQueue)));
1960
- // Only ACTIONABLE entries are a question the assistant can put to the human
1961
- // directly — a duplicate-id commit-repair follower whose own resolution
1962
- // says "act on a different entry first" isn't one (#61).
1963
- const actionableNow = actionableEscalations(escalations);
1964
- if (actionableNow.length) {
1965
- L.push("", `⚖ ${actionableNow.length} decision(s) need the human's call — ASK inline (never queue): ${actionableNow.map((e) => e.question).join(" · ")}`);
1958
+ // Liveness is checked against the FULL store (the title stays scoped to advisory
1959
+ // records), so an overlay decision's repair does not go silent.
1960
+ const repairQueue = readActivePendingRepairs(root);
1961
+ escalations.push(...commitRepairEscalations(repairQueue, store.advisoryRecs("decisions"), store.recs("decisions"), withheldRewrites(root, repairQueue)));
1962
+ // A duplicate-id commit-repair follower whose own answer is "act on a different
1963
+ // entry first" is not a question to raise (#61).
1964
+ const actionable = actionableEscalations(escalations);
1965
+ if (actionable.length) {
1966
+ L.push("", `⚖ ${actionable.length} decision(s) need the human's call — ASK inline (never queue): ${actionable.map((e) => e.question).join(" · ")}`);
1966
1967
  }
1967
1968
  return ok(L.join("\n"));
1968
1969
  });
@@ -2028,24 +2029,20 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
2028
2029
  // Premise decay: a live decision whose recorded reason died. Question-framed
2029
2030
  // like every entry here — authority never changes until the human answers.
2030
2031
  items.push(...premiseEscalations(store.advisoryRecs("decisions"), { now: new Date().toISOString(), exists: (p) => existsSync(join(root, p)) }));
2031
- // See the sibling hunch_now handler above for why `live` is the full store.
2032
- const hunchEscalationsQueue = readActivePendingRepairs(root);
2033
- items.push(...commitRepairEscalations(hunchEscalationsQueue, store.advisoryRecs("decisions"), store.recs("decisions"), withheldRewrites(root, hunchEscalationsQueue)));
2032
+ // Live set is the full store, for the reason given in the sibling hunch_now handler.
2033
+ const repairQueue = readActivePendingRepairs(root);
2034
+ items.push(...commitRepairEscalations(repairQueue, store.advisoryRecs("decisions"), store.recs("decisions"), withheldRewrites(root, repairQueue)));
2034
2035
  try {
2035
2036
  items.push(...policyEscalations(new ConstitutionService(store, root).list({ publicOnly: true }).map((p) => ({ ...p, last_action: p.audit.at(-1)?.action ?? null }))));
2036
2037
  }
2037
2038
  catch { /* constitution unavailable — memory escalations still surface */ }
2038
2039
  if (!items.length)
2039
2040
  return ok("✓ Nothing needs a human decision — memory is auto-trusted and self-consistent.");
2040
- // Only ACTIONABLE entries are a question to ask the human directly — a
2041
- // duplicate-id commit-repair follower whose own resolution says "act on
2042
- // a different entry first" still surfaces below for transparency, but
2043
- // isn't itself something to raise as a decision (#61).
2041
+ // Only actionable entries are questions to raise; non-actionable followers still
2042
+ // appear below for transparency (#61).
2044
2043
  const L = [escalationHeadline(items, "mcp"), ""];
2045
2044
  for (const e of items) {
2046
- // Marked distinctly (never the "⚖ question" glyph) when it isn't
2047
- // itself something to raise — an entry whose own answer is "act on a
2048
- // different entry first" reads as informational, not as a question.
2045
+ // Non-actionable entries are marked distinctly so they read as informational, not as a question.
2049
2046
  L.push(`${e.actionable === false ? "·" : "⚖"} ${e.question}`);
2050
2047
  L.push(` ${e.detail}`);
2051
2048
  L.push(` → ${e.resolution}`, "");
@@ -2066,7 +2063,14 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
2066
2063
  },
2067
2064
  }, async ({ decision_id, expected_source_hash, expected_review_hash, disposition, reviewed_by }) => {
2068
2065
  try {
2069
- const decision = store.advisoryRecs("decisions").find((candidate) => candidate.id === decision_id);
2066
+ const find = () => store.advisoryRecs("decisions").find((candidate) => candidate.id === decision_id);
2067
+ let decision = find();
2068
+ if (!decision) {
2069
+ const abstention = confirmSharedRecord(`imported ADR ${decision_id}`);
2070
+ if (abstention)
2071
+ return err(abstention);
2072
+ decision = find();
2073
+ }
2070
2074
  if (!decision)
2071
2075
  return invalid(`Imported ADR ${decision_id} is not present in the current advisory memory home.`);
2072
2076
  // Approval grants human authority, so the agent's report that the human approved
@@ -2456,7 +2460,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
2456
2460
  const where = decision.private
2457
2461
  ? ` [PRIVATE overlay — not committed to this repo]${flushed}`
2458
2462
  : home === "private" ? ` [SHARED store — one source of truth for the whole team]${flushed}` : flushed;
2459
- const dest = destinationNote(resolveDestRoot(home, store, root));
2463
+ const dest = destinationNote(destinationRoot(home, store, root));
2460
2464
  // The chain (nuryel.state/1): a `shipped` receipt in an organization drawer rests on
2461
2465
  // this decision by id + the hash ON FILE + this repository's partition. Hand the ref
2462
2466
  // over now so the agent never rests on a pre-store hash or re-derives the scope.
@@ -2567,7 +2571,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
2567
2571
  const tierNote = `
2568
2572
 
2569
2573
  ⚠ ${why} — this rule is agent_recorded TESTIMONY${capped ? ` and was capped from "${requested.severity}" to "${rec.severity}"` : ""}. It IS enforced: the pre-edit hook and CI surface it on every matching edit from now on. What it cannot do is DENY an edit — only a rule a human confirmed outside the agent channel may block. The human confirms it by running: ${confirmCommand(rec.id, { private: home === "private", severity: requested.severity })}`;
2570
- const dest = destinationNote(resolveDestRoot(home, store, root));
2574
+ const dest = destinationNote(destinationRoot(home, store, root));
2571
2575
  return ok(`${existing ? "Updated" : "Recorded"} ${rec.severity} constraint ${rec.id}: "${rec.statement}" (scope: ${rec.scope.join(", ")}).${where}${dest} It now ${enforce}.${reviewNote}${tierNote}`);
2572
2576
  }
2573
2577
  catch (e) {
@@ -2648,8 +2652,9 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
2648
2652
  ? `\n\n△ violates_constraint ${rec.violates_constraint} resolves to no known constraint — if the rule isn't recorded yet, hunch_record_correction it and re-record this finding with the real id.`
2649
2653
  : "";
2650
2654
  const noEvidence = rec.evidence.length ? "" : "\n\n△ No evidence attached — a finding without the query/output that produced it is an opinion. Re-record with evidence when you have it.";
2651
- const dest = destinationNote(resolveDestRoot(home, store, root));
2652
- return ok(`${existing ? "Updated" : "Recorded"} finding ${id}: "${rec.title}" (${rec.triage}/${rec.severity}, observed ${rec.observed_at.slice(0, 10)}).${where}${dest} It now grounds edits to: ${[...rec.affected_files, ...rec.affected_symbols].join(", ") || "(nothing — add affected_files/symbols so it surfaces at edit time)"}.${danglingCon}${noEvidence}`);
2655
+ const dest = destinationNote(destinationRoot(home, store, root));
2656
+ const grounds = [...rec.affected_files, ...rec.affected_symbols].join(", ") || "(nothing — add affected_files/symbols so it surfaces at edit time)";
2657
+ return ok(`${existing ? "Updated" : "Recorded"} finding ${id}: "${rec.title}" (${rec.triage}/${rec.severity}, observed ${rec.observed_at.slice(0, 10)}).${where}${dest} It now grounds edits to: ${grounds}.${danglingCon}${noEvidence}`);
2653
2658
  }
2654
2659
  catch (e) {
2655
2660
  return err(`Failed to record finding: ${e.message}`);
@@ -2681,7 +2686,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
2681
2686
  });
2682
2687
  server.registerTool("nuryel_read", {
2683
2688
  title: "nuryel.state/1 read — the system-of-record answer for a subject",
2684
- 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.",
2689
+ 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.",
2685
2690
  inputSchema: ReadRequestSchema.omit({ schema: true }).shape,
2686
2691
  outputSchema: ReadResponseSchema.shape,
2687
2692
  }, async (input) => {
@@ -2745,7 +2750,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
2745
2750
  });
2746
2751
  server.registerTool("nuryel_write", {
2747
2752
  title: "nuryel.state/1 write — provenance + idempotency in, durability out",
2748
- 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.",
2753
+ 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.",
2749
2754
  inputSchema: { ...WriteRequestSchema.omit({ schema: true }).shape, cwd: cwdHintField },
2750
2755
  outputSchema: WriteResultSchema.shape,
2751
2756
  }, async ({ cwd: _cwd, ...input }) => {
@@ -2850,7 +2855,14 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
2850
2855
  const concerns = (f) => [...f.affected_files, ...f.affected_symbols].join(", ") || "(unscoped)";
2851
2856
  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("; ");
2852
2857
  if (id) {
2853
- const f = store.recs("findings").find((r) => r.id === id);
2858
+ const find = () => store.recs("findings").find((r) => r.id === id);
2859
+ let f = find();
2860
+ if (!f) {
2861
+ const abstention = confirmSharedRecord(`finding ${id}`);
2862
+ if (abstention)
2863
+ return err(abstention);
2864
+ f = find();
2865
+ }
2854
2866
  if (!f)
2855
2867
  return ok(`No finding "${id}".`);
2856
2868
  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)}` : ""}`);
@@ -2883,6 +2895,16 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
2883
2895
  try {
2884
2896
  if (public_only && private_only)
2885
2897
  return invalid("Choose only one of public_only or private_only.");
2898
+ // A correction missing from every home this call may read is confirmed against
2899
+ // fresh shared memory before the upgrade reports it "not found". Public-only
2900
+ // reads never consult the team remote.
2901
+ const heldCorrection = () => !!(store.getPrivateRec("constraints", constraint_id)
2902
+ || (!private_only && store.json.get("constraints", constraint_id)));
2903
+ if (!public_only && !heldCorrection()) {
2904
+ const abstention = confirmSharedRecord(`correction ${constraint_id}`);
2905
+ if (abstention)
2906
+ return err(abstention);
2907
+ }
2886
2908
  // Resolve the correction's exact home before any writes. Overlay-first is
2887
2909
  // the same selection contract as ConstitutionService.upgradeCorrection;
2888
2910
  // deriving this later from a policy id is unsafe when legacy public and
@@ -2915,7 +2937,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
2915
2937
  if (artifactHome === "private") {
2916
2938
  flushMemoryHome(store, hunchPaths(root).hunch, "private", `hunch: prove correction ${constraint_id}`, startupTeamRoute ?? undefined);
2917
2939
  }
2918
- const destRoot = resolveDestRoot(artifactHome, store, root);
2940
+ const destRoot = destinationRoot(artifactHome, store, root);
2919
2941
  const destination = { root: destRoot, branch: currentBranch(destRoot) };
2920
2942
  if (include_artifacts)
2921
2943
  return ok(JSON.stringify({ ...upgrade, destination }, null, 2));
@@ -3104,7 +3126,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
3104
3126
  if (!home)
3105
3127
  throw new Error(`policy ${policy_id} has no exact storage home`);
3106
3128
  flushMemoryHome(store, hunchPaths(root).hunch, home, `hunch: plan policy ${policy_id}`, startupTeamRoute ?? undefined);
3107
- const destRoot = resolveDestRoot(home, store, root);
3129
+ const destRoot = destinationRoot(home, store, root);
3108
3130
  return ok(JSON.stringify({ ...plan, destination: { root: destRoot, branch: currentBranch(destRoot) } }, null, 2));
3109
3131
  }
3110
3132
  catch (e) {
@@ -3402,7 +3424,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
3402
3424
  dependencyTimeoutMs: dependency_timeout_ms ?? 300_000,
3403
3425
  });
3404
3426
  flushMemoryHome(store, hunchPaths(root).hunch, "private", "hunch: materialize G2 behavior policies", startupTeamRoute ?? undefined);
3405
- const destRoot = resolveDestRoot("private", store, root);
3427
+ const destRoot = destinationRoot("private", store, root);
3406
3428
  return ok(JSON.stringify({ ...materialized, destination: { root: destRoot, branch: currentBranch(destRoot) } }, null, 2));
3407
3429
  }
3408
3430
  catch (e) {
@@ -3479,6 +3501,10 @@ export async function startServer(cwd = process.cwd(), options = {}) {
3479
3501
  wireClientRoots(control, fallback);
3480
3502
  const transport = new StdioServerTransport();
3481
3503
  await control.server.connect(transport);
3504
+ // Exit through process.exit on a termination signal (same conventional codes as
3505
+ // a signal death) so NODE_V8_COVERAGE is flushed for `hunch verify --refresh-map`.
3506
+ process.once("SIGTERM", () => process.exit(143));
3507
+ process.once("SIGINT", () => process.exit(130));
3482
3508
  console.error(control.pinned
3483
3509
  ? `[hunch-mcp] serving Hunch over stdio (pinned root ${control.getRoot()}; client roots ignored)`
3484
3510
  : `[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 });
@@ -10,10 +10,12 @@ export declare const ACQUIRE_TIMEOUT_MS = 10000;
10
10
  * claimed section is a few syscalls, so anything this old means its claimer
11
11
  * crashed inside it. */
12
12
  export declare const CLAIM_STALE_MS = 10000;
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). */
13
15
  export interface LockOwner {
14
16
  pid: number;
15
17
  host: string;
16
- nonce: string;
18
+ nonce?: string;
17
19
  at: string;
18
20
  start?: string;
19
21
  }
@@ -13,7 +13,9 @@
13
13
  * equality and never by clock arithmetic (see core/procstart.ts). Where no token can
14
14
  * compare them (macOS, Windows, legacy records) a stale-looking lock whose live pid
15
15
  * belongs to a process CREATED after the lock's recorded `at` is taken over too: that
16
- * process cannot be the one that took it (issue #287). Removing a stale
16
+ * process cannot be the one that took it (issue #287). That creation-time check
17
+ * runs on macOS and Windows only; on linux `processStartedAt` returns null, so a
18
+ * tokenless live pid there is never taken over. Removing a stale
17
19
  * lock is itself serialized behind an exclusive `.reclaim` claim directory, because
18
20
  * judging and removing are two steps and two contenders can otherwise both "win"
19
21
  * (see `takeOver`).
@@ -56,12 +58,19 @@ export function writeLockPath(hunchDir) {
56
58
  function readOwner(path) {
57
59
  try {
58
60
  const parsed = JSON.parse(readFileSync(path, "utf8"));
59
- if (typeof parsed.pid !== "number" || typeof parsed.nonce !== "string")
61
+ // A missing nonce is not unreadable metadata: the pid still names a
62
+ // same-host owner, so it gets the live-pid judgement `.rmw-lock` gives it,
63
+ // never the age fallback that would steal a live writer's lock (#491).
64
+ // The pid check mirrors `.rmw-lock`'s: `kill(0|-n, 0)` signals a process
65
+ // group and always "succeeds" (a live wedge forever), and a fractional or
66
+ // huge pid throws (stolen even when fresh) — so such a record is unreadable
67
+ // and age decides.
68
+ if (typeof parsed.pid !== "number" || !Number.isInteger(parsed.pid) || parsed.pid < 1)
60
69
  return undefined;
61
70
  return {
62
71
  pid: parsed.pid,
63
72
  host: typeof parsed.host === "string" ? parsed.host : "",
64
- nonce: parsed.nonce,
73
+ ...(typeof parsed.nonce === "string" ? { nonce: parsed.nonce } : {}),
65
74
  at: typeof parsed.at === "string" ? parsed.at : "",
66
75
  ...(typeof parsed.start === "string" ? { start: parsed.start } : {}),
67
76
  };
@@ -268,6 +277,11 @@ export async function withWriteLock(hunchDir, fn, opts = {}) {
268
277
  // from, the file now protects another writer. The nonce is dropped in a
269
278
  // `finally` of its own, so a throw from the conditional remove can never
270
279
  // leave us vouching for a lock we no longer hold.
280
+ //
281
+ // DIFFERS from `.rmw-lock` on release: a readable record WITHOUT a nonce is
282
+ // left in place here (it is not ours — a legacy or foreign writer replaced
283
+ // our file), where `.rmw-lock` removes it. Only an unreadable record is
284
+ // removed unconditionally.
271
285
  try {
272
286
  const current = readOwner(path);
273
287
  if (!current || current.nonce === nonce)
@@ -403,7 +403,8 @@ export declare class HunchStore {
403
403
  private whyRecord;
404
404
  /** Transitive blast radius: every symbol/component that (in)directly depends on
405
405
  * `id`, via a recursive CTE over the edges graph (hunch_get_dependents). We
406
- * walk edges BACKWARD (edges.to = current) following call/dep/import/contains. */
406
+ * walk edges BACKWARD (edges.to = current) following call/dep/import/contains,
407
+ * plus `references` — the only edge YAML/Helm/Kustomize emit (issue #486). */
407
408
  getDependents(id: string, maxDepth?: number): Array<{
408
409
  id: string;
409
410
  depth: number;