@davesheffer/hunch 1.41.6 → 1.42.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 (40) hide show
  1. package/README.md +1 -1
  2. package/dist/cli/index.js +402 -154
  3. package/dist/constitution/experiment.d.ts +3 -3
  4. package/dist/constitution/g3.d.ts +1 -1
  5. package/dist/core/footprint.d.ts +15 -0
  6. package/dist/core/footprint.js +167 -0
  7. package/dist/core/groundingLag.d.ts +15 -0
  8. package/dist/core/groundingLag.js +27 -0
  9. package/dist/core/hookText.d.ts +4 -0
  10. package/dist/core/hookText.js +8 -0
  11. package/dist/core/pipeline.d.ts +28 -0
  12. package/dist/core/pipeline.js +50 -0
  13. package/dist/core/shellwrites.d.ts +7 -0
  14. package/dist/core/shellwrites.js +131 -0
  15. package/dist/core/siblingfix.d.ts +124 -0
  16. package/dist/core/siblingfix.js +814 -0
  17. package/dist/core/taskReportHook.d.ts +1 -1
  18. package/dist/core/taskReportHook.js +19 -3
  19. package/dist/extractors/git.d.ts +31 -0
  20. package/dist/extractors/git.js +180 -1
  21. package/dist/extractors/nativeTreeSitter.d.ts +2 -1
  22. package/dist/extractors/nativeTreeSitter.js +128 -30
  23. package/dist/integrations/claudemd.d.ts +20 -2
  24. package/dist/integrations/claudemd.js +79 -53
  25. package/dist/integrations/providers.d.ts +9 -5
  26. package/dist/integrations/providers.js +34 -22
  27. package/dist/integrations/team.d.ts +24 -4
  28. package/dist/integrations/team.js +154 -16
  29. package/dist/integrations/worktree.d.ts +3 -2
  30. package/dist/integrations/worktree.js +7 -4
  31. package/dist/mcp/server.d.ts +489 -0
  32. package/dist/mcp/server.js +208 -62
  33. package/dist/mcp/taskReportTools.js +12 -9
  34. package/dist/mcp/toolset.d.ts +11 -1
  35. package/dist/mcp/toolset.js +28 -8
  36. package/dist/store/hunchStore.d.ts +25 -1
  37. package/dist/store/hunchStore.js +146 -21
  38. package/dist/store/jsonStore.js +25 -4
  39. package/package.json +1 -1
  40. package/server.json +2 -2
@@ -1,5 +1,6 @@
1
1
  import { conventionSupplements } from '../core/conventionDelivery.js';
2
2
  import { fieldCitationText } from "../core/fieldProvenance.js";
3
+ import { siblingGrounding } from "../core/siblingfix.js";
3
4
  /**
4
5
  * MCP server — the structured two-way API into the Hunch (DESIGN.md §7 / App. A).
5
6
  * Exposes read tools (query/why/bug_lineage/check_constraints/get_dependents) and
@@ -15,6 +16,7 @@ import { z } from "zod";
15
16
  import { hunchPaths, findRoot, toPosixTarget, repoRelativeTarget } from "../core/paths.js";
16
17
  import { matchSymbolsTiered } from "../core/glob.js";
17
18
  import { resolveMcpToolset } from "./toolset.js";
19
+ import { PolicyRepository } from "../constitution/repository.js";
18
20
  import { readConfig } from "../core/config.js";
19
21
  import { canonicalRootPath, resolveActiveRoot } from "./roots.js";
20
22
  import { HunchStore } from "../store/hunchStore.js";
@@ -33,7 +35,7 @@ import { workspacesConfig } from "../core/config.js";
33
35
  import { revParse, asOfDate, revExists, lastChangeDate, rangeFiles, rangeGateDiff, commitFiles, commitGateDiff, stagedFiles, stagedGateDiff, workingFiles, workingGateDiff, pullHunchStatus, sameRemoteUrl, currentBranch, worktreePaths, pathKnownToHistory } from "../extractors/git.js";
34
36
  import { flushCapture, flushMemoryHome, pinSharedRemote } from "../integrations/sync.js";
35
37
  import { withWriteLock } from "../serve/writelock.js";
36
- import { advertisedTeamRemoteContract, ensureTeamOverlay, overlayMatchesTeamRemote, readTeamConfig, teamRemoteContract, teamSharedRef } from "../integrations/team.js";
38
+ import { advertisedTeamRemoteContract, ensureTeamOverlay, isTeamStoreTrusted, overlayMatchesTeamRemote, teamWiringConsented, untrustedTeamStoreMessage, readTeamConfig, teamRemoteContract, teamSharedRef } from "../integrations/team.js";
37
39
  import { formatSearchHit, formatStructure } from "../core/format.js";
38
40
  import { isStateKind, stateSupplements } from "../core/stateDelivery.js";
39
41
  import { taskSelectionSupplements } from "../core/taskDelivery.js";
@@ -90,10 +92,12 @@ const invalid = (text) => err(`Invalid: ${text}`);
90
92
  * cannot see an agent-driven `cd`/EnterWorktree, so a stdio server's cached root
91
93
  * never moves on its own — this is the client-agnostic fallback, resolved fresh on
92
94
  * every call by the generic tool wrapper below (see extractCwdHint). */
93
- const cwdHintField = z.string().optional().describe("Your ACTUAL current working directory for THIS call. Pass it whenever it differs from where this MCP " +
94
- "session started — most commonly after entering a git worktree (EnterWorktree) or `cd`-ing to a different " +
95
- "checkout — so the write commits to that repo/branch instead of silently landing on the server's original " +
96
- "root. Omit only when you are still in the session's starting directory.");
95
+ const cwdHintField = z.string().optional().describe("Your current directory, when it differs from where this session started (e.g. a worktree); " +
96
+ "writes then commit there, not to the original root.");
97
+ /** Shared by hunch_record_decision/correction/finding — identical text repeated 3x
98
+ * otherwise. hunch_context's task_id field differs (delivery vs. save) and keeps its
99
+ * own description. */
100
+ const saveTaskIdField = TaskIdSchema.optional().describe("Task ID, to attribute this save to it; never affects capture authority.");
97
101
  /** Pull `cwd` out of a tool call's already-parsed input without assuming any one
98
102
  * tool's exact input shape — every write tool spreads the same cwdHintField in,
99
103
  * but the wrapper below runs for every tool, read or write. */
@@ -594,6 +598,29 @@ const WHY_CAP = 6; // per record-type in hunch_why
594
598
  const DEP_CAP = 25; // dependents in hunch_get_dependents
595
599
  const QUERY_HITS = 8; // hunch_query matches (was 12)
596
600
  const FINDINGS_CAP = 12; // hunch_findings listing
601
+ const FINDINGS_MAX = 50; // hunch_findings limit ceiling
602
+ const FINDING_GIST_CHARS = 200; // list view: first sentence, clipped
603
+ const FINDING_GIST_MIN = 60; // keep reading past a dateline like "Observed 2026-09-14."
604
+ const NOT_A_SENTENCE_END = /(?:\b(?:e\.g|i\.e|vs|etc|cf|approx|incl)|^\d+|\s\d+)\.$/i;
605
+ /** The list view's gist of an observation: whole sentences until the gist says
606
+ * something (≥ FINDING_GIST_MIN chars), clipped to FINDING_GIST_CHARS. A
607
+ * period after an abbreviation or a list number is not a sentence end. */
608
+ export const firstSentence = (text) => {
609
+ const flat = text.replace(/\s+/g, " ").trim();
610
+ const ends = /[.!?](?=\s|$)/g;
611
+ let cut = flat.length;
612
+ for (let m = ends.exec(flat); m; m = ends.exec(flat)) {
613
+ const end = m.index + 1;
614
+ if (NOT_A_SENTENCE_END.test(flat.slice(0, end)))
615
+ continue;
616
+ if (end >= FINDING_GIST_MIN) {
617
+ cut = end;
618
+ break;
619
+ }
620
+ }
621
+ const gist = flat.slice(0, cut);
622
+ return gist.length > FINDING_GIST_CHARS ? `${gist.slice(0, FINDING_GIST_CHARS - 1)}…` : gist;
623
+ };
597
624
  const SEV_CONSTRAINT = { blocking: 3, warning: 2, advisory: 1 };
598
625
  const SEV_BUG = { critical: 4, high: 3, medium: 2, low: 1 };
599
626
  const more = (total, cap, hint = "") => total > cap ? `\n …(+${total - cap} more${hint ? ` — ${hint}` : ""})` : "";
@@ -683,6 +710,12 @@ const DELIVERY_OUTPUT_SCHEMA = z.object({
683
710
  reason: z.enum(["budget", "stale-provenance", "retired", "actionability-cap", "endpoint-not-delivered", "landscape-cap", "profile-cap", "low-confidence", "insufficient-context", "low-relevance"]),
684
711
  detail: z.string(),
685
712
  })),
713
+ // The per-record list above is a SAMPLE on MCP (see compactOmissions); these
714
+ // two carry the whole picture in constant size. Optional: the CLI envelope
715
+ // keeps its full list and omits them.
716
+ omitted_total: z.number().int().nonnegative().optional(),
717
+ omitted_truncated: z.boolean().optional(),
718
+ omitted_by_reason: z.record(z.string(), z.number().int().positive()).optional(),
686
719
  landscape: LANDSCAPE_FRAGMENT_SCHEMA.nullable(),
687
720
  budget_tokens: z.number().int().nonnegative(),
688
721
  used_chars: z.number().int().nonnegative(),
@@ -699,6 +732,28 @@ const DELIVERY_OUTPUT_SCHEMA = z.object({
699
732
  retry_hint: z.string().nullable(),
700
733
  }),
701
734
  });
735
+ /** What tools/list advertises for hunch_context (#368). The landscape fragment embeds
736
+ * the full Resource and Edge record schemas (~7k of the ~13k chars), which every
737
+ * schema-loading host would pay on every session; it is advertised by its identity
738
+ * fields only. The handler still parses the result with the full
739
+ * DELIVERY_OUTPUT_SCHEMA, so the delivered shape is exactly as strict as before. */
740
+ const DELIVERY_ADVERTISED_OUTPUT_SCHEMA = DELIVERY_OUTPUT_SCHEMA.extend({
741
+ // The MCP result carries drill-down ids only (see compactEnvelope, #371): the
742
+ // per-record receipt facts stay in the served ledger and hunch_report. Optional,
743
+ // so a reader of the full CLI envelope and of the MCP result shares one shape.
744
+ delivered: z.array(DELIVERY_OUTPUT_SCHEMA.shape.delivered.element.partial({
745
+ rank: true, delivery_reason: true, provenance_status: true, token_cost: true,
746
+ })),
747
+ supplements: z.array(DELIVERY_OUTPUT_SCHEMA.shape.supplements.element.partial({
748
+ reason: true, rank: true, token_cost: true,
749
+ })),
750
+ omitted: z.array(DELIVERY_OUTPUT_SCHEMA.shape.omitted.element.partial({ detail: true })),
751
+ landscape: z.object({
752
+ schema: z.literal("hunch.landscape-fragment/1"),
753
+ target: z.string(),
754
+ fragmentHash: z.string(),
755
+ }).passthrough().nullable(),
756
+ });
702
757
  const CHANGE_IDENTITY_OUTPUT_SCHEMA = z.object({
703
758
  schema: z.literal(CHANGE_IDENTITY_SCHEMA_VERSION),
704
759
  algorithm: z.literal(CHANGE_IDENTITY_ALGORITHM),
@@ -815,7 +870,53 @@ function deliveredContext(root, target, envelope, sessionId) {
815
870
  ]);
816
871
  return {
817
872
  content: [{ type: "text", text: structuredContent.text }],
818
- structuredContent,
873
+ structuredContent: compactEnvelope(structuredContent),
874
+ };
875
+ }
876
+ /** What the MCP result names per record: enough to drill down (hunch_why), no
877
+ * more. A host shows the model either `content` or `structuredContent`, and
878
+ * `text` is the only brief a structured-only host sees, so every other field
879
+ * repeats what the brief already says; rank, costs and provenance stay in the
880
+ * receipt. Undelivered supplements are dropped (the brief never showed them). */
881
+ export function compactEnvelope(envelope) {
882
+ return {
883
+ ...compactOmissions(envelope),
884
+ delivered: envelope.delivered.map(({ kind, record_id }) => ({ kind, record_id })),
885
+ supplements: envelope.supplements.filter((s) => s.delivered).map(({ id, kind, delivered }) => ({ id, kind, delivered })),
886
+ };
887
+ }
888
+ /** Omitted records the MCP result names individually; the rest are counted. */
889
+ const OMITTED_SAMPLE = 5;
890
+ /** The budget must govern the whole tool result, not only the brief: a host
891
+ * that shows the model structuredContent otherwise pays for one repeated
892
+ * sentence per withheld record (104 of them measured on this repo, issue #371).
893
+ * Keep a few ids — round-robin across reasons so every reason the brief cites
894
+ * keeps an id to pass to hunch_why, budget first — and count all of them by
895
+ * reason. Runs after recordServed and after the full envelope validated, so
896
+ * receipts and receipt_id are unchanged. */
897
+ export function compactOmissions(envelope) {
898
+ const groups = new Map();
899
+ for (const item of envelope.omitted)
900
+ groups.set(item.reason, [...(groups.get(item.reason) ?? []), item]);
901
+ const byReason = {};
902
+ for (const [reason, items] of groups)
903
+ byReason[reason] = items.length;
904
+ const order = [...groups.keys()].sort((left, right) => Number(right === "budget") - Number(left === "budget") || left.localeCompare(right));
905
+ const sample = [];
906
+ for (let depth = 0; sample.length < OMITTED_SAMPLE && sample.length < envelope.omitted.length; depth++) {
907
+ for (const reason of order) {
908
+ const item = groups.get(reason)[depth];
909
+ // The per-record detail sentence is left out: hunch_why(record_id) explains it.
910
+ if (item && sample.length < OMITTED_SAMPLE)
911
+ sample.push({ kind: item.kind, record_id: item.record_id, reason: item.reason });
912
+ }
913
+ }
914
+ return {
915
+ ...envelope,
916
+ omitted: sample,
917
+ omitted_total: envelope.omitted.length,
918
+ omitted_truncated: sample.length < envelope.omitted.length,
919
+ omitted_by_reason: byReason,
819
920
  };
820
921
  }
821
922
  // Capture-session tokens live in src/core/capturetoken.ts (pure + testable). These
@@ -957,10 +1058,14 @@ function prepareRoot(root, explicitOverlay, requireIndex) {
957
1058
  const overlayWarning = store.overlayResolutionWarning(explicitOverlay && existsSync(teamFile));
958
1059
  if (overlayWarning)
959
1060
  console.error(`[hunch-mcp] ⚠ ${overlayWarning}`);
960
- if (teamAdvertised && (store.mode !== "shared"
1061
+ if (startupTeamConfig && (store.mode !== "shared"
961
1062
  || !store.privateDir
962
1063
  || !existsSync(store.privateDir)
1064
+ || !teamWiringConsented(root, startupTeamConfig, store.privateDir)
963
1065
  || !overlayMatchesTeamRemote(root, join(store.privateDir, "..")))) {
1066
+ const consented = !!store.privateDir && teamWiringConsented(root, startupTeamConfig, store.privateDir);
1067
+ if (!consented && !isTeamStoreTrusted(root, startupTeamConfig))
1068
+ throw new Error(untrustedTeamStoreMessage(startupTeamConfig));
964
1069
  throw new Error("the advertised team memory store is unavailable or tracks a different remote; refusing to start MCP on another graph");
965
1070
  }
966
1071
  const startupTeamRoute = teamAdvertised && store.privateDir
@@ -1082,7 +1187,13 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
1082
1187
  // Everyday tools by default; specialist groups by evidence, config, or env
1083
1188
  // (src/mcp/toolset.ts). Hidden tools are never registered, so tools/list is
1084
1189
  // exactly what the host can call.
1085
- const toolset = resolveMcpToolset(root, { configSpec: readConfig(hunchPaths(root)).mcp_tools ?? null, pinned });
1190
+ let hasPolicies = false;
1191
+ try {
1192
+ hasPolicies = new PolicyRepository(root, store).listPolicies().length > 0;
1193
+ }
1194
+ catch { /* unreadable policies: keep the group hidden */ }
1195
+ const toolset = resolveMcpToolset(root, { configSpec: readConfig(hunchPaths(root)).mcp_tools ?? null, pinned, hasPolicies });
1196
+ const hiddenTools = new Set(toolset.hidden);
1086
1197
  if (toolset.hidden.length)
1087
1198
  process.stderr.write(`[hunch-mcp] tool groups: ${toolset.groups.length ? toolset.groups.join(", ") : "core only"} (${toolset.source}); ${toolset.hidden.length} specialist tool(s) hidden — HUNCH_MCP_TOOLS=all or .hunch/config.json mcp_tools to expose\n`);
1088
1199
  const server = new McpServer({ name: "hunch", version: HUNCH_VERSION }, { instructions: MCP_INSTRUCTIONS });
@@ -1167,7 +1278,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
1167
1278
  priorOnClose?.();
1168
1279
  };
1169
1280
  const registerTool = server.registerTool.bind(server);
1170
- server.registerTool = ((name, config, callback) => registerTool(name, config, async (...args) => {
1281
+ server.registerTool = ((name, config, callback) => hiddenTools.has(name) ? undefined : registerTool(name, config, async (...args) => {
1171
1282
  // Claude Code CLI never advertises `roots`/`roots/list_changed` for an agent-driven
1172
1283
  // `cd` or EnterWorktree (issue #20) — the cached `root` above just never moves, so a
1173
1284
  // write silently lands wherever the process was spawned. Write tools accept an
@@ -1529,19 +1640,27 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
1529
1640
  // -- hunch_context (surgical retrieval) -----------------------------------
1530
1641
  server.registerTool("hunch_context", {
1531
1642
  title: "Assemble the minimal relevant Hunch slice for a task",
1532
- description: "Given a file, symbol, or task phrase you're about to work on, return the MINIMAL relevant memory — invariants to preserve, decisions explaining the design, bug history not to reintroduce, and the blast radius — as a compact brief. Call this FIRST when starting work on something. A task phrase that resolves to no file/symbol falls back to the closest graph matches. Returns a budgeted brief plus a delivery receipt. Not for exhaustive rationale on one file (hunch_why) or keyword search (hunch_query).",
1643
+ description: "Given a file, symbol, or task phrase you're about to work on, return the MINIMAL relevant memory — invariants, decisions, bug history, blast radius — as a compact brief. Call FIRST when starting work. A task phrase with no file/symbol match falls back to the closest graph matches. Returns a budgeted brief plus a delivery receipt. Not for exhaustive rationale on one file (hunch_why) or keyword search (hunch_query).",
1533
1644
  inputSchema: {
1534
1645
  target: z.string().describe("A file path, symbol, or task phrase you're about to work on."),
1535
1646
  budget_tokens: z.number().optional().describe("Rough token budget for the brief (default 1500)."),
1536
1647
  profile: z.enum(DELIVERY_PROFILES).optional().describe("Delivery role: builder (default), reviewer, or architect. Changes non-blocking order only."),
1537
1648
  as_of: z.string().optional().describe("Time-travel ref (commit/tag/branch): assemble the slice as it stood then."),
1538
1649
  task_id: TaskIdSchema.optional().describe("Exact task ID from hunch_task; records this delivery for the task's contribution report."),
1650
+ include: z.array(z.enum(["recent_tasks", "project_dna"])).optional().describe("Opt-in extras; omitted by default to keep the brief small."),
1539
1651
  cwd: cwdHintField,
1540
1652
  },
1541
- outputSchema: DELIVERY_OUTPUT_SCHEMA,
1542
- }, async ({ target, budget_tokens, profile, as_of, task_id }, extra) => {
1653
+ outputSchema: DELIVERY_ADVERTISED_OUTPUT_SCHEMA,
1654
+ }, async ({ target, budget_tokens, profile, as_of, task_id, include }, extra) => {
1543
1655
  const deliver = (envelope) => {
1544
1656
  const result = deliveredContext(root, as_of ? `${target} (as_of:${as_of})` : target, envelope, extra.sessionId);
1657
+ // Same sibling-fix lesson the pre-edit hook injects, for a file target.
1658
+ // Sibling lessons need a repo-relative file; a target outside the repo gets none.
1659
+ const rel = relative(root, resolve(root, target)).replace(/\\/g, "/");
1660
+ const siblings = as_of || !rel || rel.startsWith("..") || isAbsolute(rel) ? null : siblingGrounding(root, rel, store.recs("symbols"));
1661
+ // Leads the result: the most specific lesson must not trail the memory slice.
1662
+ if (siblings?.text)
1663
+ result.content.unshift({ type: "text", text: siblings.text });
1545
1664
  if (task_id) {
1546
1665
  try {
1547
1666
  // Historical contexts must not borrow today's record text/revision.
@@ -1561,14 +1680,18 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
1561
1680
  if (as_of && !asOf)
1562
1681
  return invalid(`Could not resolve as_of "${as_of}" to a commit.`);
1563
1682
  const ctx = store.assembleContext(target, budget_tokens ?? 1500, { asOf });
1683
+ // Both extras below cost brief tokens (and, for DNA/recent-tasks selection, extra
1684
+ // latency) — opt-in only via `include`, omitted from the default brief.
1564
1685
  let dnaSupplement = null;
1565
- try {
1566
- dnaSupplement = projectDnaDeliverySupplement(discoverProjectDna(root, as_of ?? "HEAD"));
1567
- }
1568
- catch {
1569
- // Context retrieval must keep its existing graceful behavior when the
1570
- // Git checkout cannot provide DNA; the dedicated DNA tool reports the
1571
- // exact derivation error when a caller needs diagnostics.
1686
+ if (include?.includes("project_dna")) {
1687
+ try {
1688
+ dnaSupplement = projectDnaDeliverySupplement(discoverProjectDna(root, as_of ?? "HEAD"));
1689
+ }
1690
+ catch {
1691
+ // Context retrieval must keep its existing graceful behavior when the
1692
+ // Git checkout cannot provide DNA; the dedicated DNA tool reports the
1693
+ // exact derivation error when a caller needs diagnostics.
1694
+ }
1572
1695
  }
1573
1696
  // The "State" section (nuryel.state/1): current derived, in-force commitments and the
1574
1697
  // latest receipts whose subject/text matches the target — bounded, ordered, sharing the
@@ -1576,7 +1699,9 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
1576
1699
  const stateGrounding = asOf ? [] : stateSupplements(store.stateSlice(target), target);
1577
1700
  // Recent finished tasks that touched the target: what earlier agent work did
1578
1701
  // here, from graph memory. Advisory history sharing the brief's budget.
1579
- const recentTasks = asOf ? [] : taskSelectionSupplements(store.selectTasksAuto(target, buildTaskRankingQuery(root, task_id ?? null, target)), target);
1702
+ const recentTasks = (asOf || !include?.includes("recent_tasks"))
1703
+ ? []
1704
+ : taskSelectionSupplements(store.selectTasksAuto(target, buildTaskRankingQuery(root, task_id ?? null, target)), target);
1580
1705
  const options = {
1581
1706
  root,
1582
1707
  symbols: store.recs("symbols"),
@@ -1633,7 +1758,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
1633
1758
  // -- hunch_shortlist (bounded correction-stage diagnostic) ----------------
1634
1759
  server.registerTool("hunch_shortlist", {
1635
1760
  title: "Shortlist the likely correction stage and declarations",
1636
- description: "Experimental, deterministic, read-only repository-adaptive diagnostic for a schema/validation issue or reproduction. Preserves a flat top five, adds a transfer-tested hierarchical inspection view, and emits an efficiency-tested advisory progressive inspection queue capped at eleven declarations with deterministic receipts. Optional authenticated same-claim evidence is annotated but cannot reorder candidates because fresh transfer rejected that mechanism. It never claims an exact implementation owner or per-case confidence and does not edit, gate, or capture memory.",
1761
+ description: "Experimental, deterministic, read-only diagnostic for a schema/validation issue or reproduction: a flat top five, a hierarchical inspection view, and an advisory progressive inspection queue (max eleven declarations) with receipts. Same-claim evidence is annotated, never reorders. It never claims an exact implementation owner or per-case confidence and does not edit, gate, or capture memory.",
1637
1762
  inputSchema: {
1638
1763
  issue: z.string().min(1).max(100_000).describe("Issue report or reproduction prose, including observed and expected behavior when available."),
1639
1764
  limit: z.number().int().min(1).max(5).optional().describe("Candidate count, capped at five (default 5)."),
@@ -1680,18 +1805,27 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
1680
1805
  title: "Recent activity + the roadmap (the hot view)",
1681
1806
  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.",
1682
1807
  inputSchema: {
1683
- recent_limit: z.number().optional().describe("How many recent decisions to include (default 10)."),
1808
+ recent_limit: z.number().optional().describe("How many recent decisions to include (default 5)."),
1809
+ roadmap_limit: z.number().optional().describe("How many roadmap entries to include (default 2); the rest are counted."),
1684
1810
  },
1685
- }, async ({ recent_limit }) => {
1686
- const { recent, roadmap, pendingReview } = nowData(store.json.loadAll("decisions"), recent_limit ?? 10);
1811
+ }, async ({ recent_limit, roadmap_limit }) => {
1812
+ // Bounded by default (#371): the hot view is read at session start, so its
1813
+ // size is paid on every session; the rest stays one call away.
1814
+ const decisions = store.json.loadAll("decisions");
1815
+ const { recent, roadmap, pendingReview } = nowData(decisions, recent_limit ?? 5);
1816
+ const shownRoadmap = roadmap.slice(0, Math.max(0, roadmap_limit ?? 2));
1687
1817
  const L = [`🔥 Recent (${recent.length}):`];
1688
1818
  for (const r of recent)
1689
1819
  L.push(` ${r.date} [${r.status}] ${r.title} (${r.id}${r.topic ? `, ${r.topic}` : ""})`);
1820
+ if (decisions.length > recent.length)
1821
+ L.push(` +${decisions.length - recent.length} more — hunch_now(recent_limit) or hunch now`);
1690
1822
  L.push("", `🗺 Roadmap — live proposed decisions (${roadmap.length}):`);
1691
1823
  if (!roadmap.length)
1692
1824
  L.push(" (empty — record intent as a PROPOSED decision and it appears here)");
1693
- for (const r of roadmap)
1825
+ for (const r of shownRoadmap)
1694
1826
  L.push(` • ${r.title} (${r.id}${r.topic ? `, ${r.topic}` : ""}, since ${r.date})\n ${r.note}`);
1827
+ if (roadmap.length > shownRoadmap.length)
1828
+ L.push(` +${roadmap.length - shownRoadmap.length} more — hunch_now(roadmap_limit) or hunch now`);
1695
1829
  if (pendingReview > 0)
1696
1830
  L.push("", `${pendingReview} legacy un-vouched draft(s) — \`hunch adopt-drafts\` auto-trusts them as advisory (new captures land trusted automatically).`);
1697
1831
  // Workspace ledger, from stored PUBLIC records only (same jurisdiction rule as the rest
@@ -1723,7 +1857,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
1723
1857
  // git; other machines come from stored records, which are display-only.
1724
1858
  server.registerTool("hunch_workspaces", {
1725
1859
  title: "Worktrees and branches across machines",
1726
- description: "The workspace ledger: which git worktrees are open on which machine, and every local branch with a deterministic verdict — merged (ancestry / squash / rebase), never pushed, upstream gone, dirty worktree — plus a recommended action per branch. Call this INSTEAD of running git branch / git worktree list / git log to answer 'what is open, what is stale, what can be deleted'. This machine is read live; other machines from memory (a machine older than the staleness window is marked unverified). Read-only: it never deletes anything. Not for design rationale (hunch_why) or code structure (hunch_structure).",
1860
+ description: "Worktrees per machine and every local branch with a deterministic verdict — merged (ancestry / squash / rebase), never pushed, upstream gone, dirty — plus a recommended action. Use INSTEAD of git branch / git worktree list to see what is open, stale, or deletable. This machine is read live; others from memory (stale ones marked unverified). Read-only. Not for design rationale (hunch_why) or code structure (hunch_structure).",
1727
1861
  inputSchema: {
1728
1862
  view: z.enum(["inventory", "branches"]).optional().describe("inventory = one row per worktree (default); branches = one row per branch with its verdict and action."),
1729
1863
  machine: z.string().optional().describe("Only this machine label."),
@@ -1772,7 +1906,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
1772
1906
  // (con_e04226bd05): no Claude-specific behavior.
1773
1907
  server.registerTool("hunch_escalations", {
1774
1908
  title: "Decisions the human must make now (ask inline, not a queue)",
1775
- description: "The rare decisions the graph cannot resolve on its own — surfaced so you ASK THE USER in the prompt at the moment, then act. Auto-captured memory is trusted automatically and never appears here; this returns topic conflicts (>1 live decision for one topic), premise-stale decisions (a live decision whose recorded REASON no longer holds — its authority is unchanged until the human re-attests, supersedes, or retires), one exact imported ADR at a time awaiting approve/decline, a queued commit-provenance repair (the post-merge hook detected a decision's commit was squash-merged away but never applies the fix unattended — apply with `hunch repair-provenance --apply` or leave it queued), and Constitution human moments (candidate policies awaiting review, proposed policies awaiting an activation decision). Normally empty. Raise each question with the user; do NOT decide it for them — an entry is a question, silence is never approval. Reads the public store, or the unified overlay when the repo is in shared mode — never private-mode overlay records, EXCEPT a queued commit-repair, whose liveness is checked against the full store; only its id and commit shas ever surface, never its title.",
1909
+ description: "Decisions only the human can make, so you ASK THE USER before acting: topic conflicts (>1 live decision per topic), premise-stale decisions (the recorded reason no longer holds; authority is unchanged until the human re-attests, supersedes, or retires), one imported ADR at a time awaiting approve/decline, a queued commit-provenance repair (`hunch repair-provenance --apply`, never applied unattended), and Constitution policies awaiting review or activation. Normally empty. Never decide an entry for the user — silence is never approval. Reads the public or shared-mode store, never private-mode records — except a queued repair, whose liveness is checked against the full store and which surfaces only its id and commit shas.",
1776
1910
  inputSchema: {},
1777
1911
  }, async () => {
1778
1912
  const items = pendingEscalations(store.advisoryRecs("decisions"));
@@ -1806,7 +1940,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
1806
1940
  // -- hunch_review_imported_adr (the chat-native countersign) --------------
1807
1941
  server.registerTool("hunch_review_imported_adr", {
1808
1942
  title: "Apply the human's exact imported-ADR answer",
1809
- description: "Use ONLY after the human explicitly answers the currently surfaced imported-ADR question with approve or decline in this conversation. Never infer approval from silence, continued work, a generic earlier sign-off, or the ADR file's own status. The source and review hashes bind the answer to both the exact bytes and mapped meaning shown. Approve grants human-confirmed authority; decline records review but keeps the ADR advisory.",
1943
+ description: "Use ONLY after the human explicitly answers the currently surfaced imported-ADR question with approve or decline, in this conversation. Never infer approval from silence, continued work, an earlier generic sign-off, or the ADR file's own status. The source and review hashes bind the answer to the exact bytes and mapped meaning shown. Approve grants human-confirmed authority; decline records review but keeps the ADR advisory.",
1810
1944
  inputSchema: {
1811
1945
  decision_id: z.string().regex(/^dec_[A-Za-z0-9_-]+$/),
1812
1946
  expected_source_hash: z.string().regex(/^sha256:[a-f0-9]{64}$/),
@@ -1887,7 +2021,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
1887
2021
  // -- hunch_capture_decision (decision-grounding: the grilling front door) --
1888
2022
  server.registerTool("hunch_capture_decision", {
1889
2023
  title: "Capture a decision (grilling interview)",
1890
- description: "Start a decision-capture interview: returns the grilling protocol (interrogate ONE question at a time until the decision tree is resolved) plus a capture-session token. Grill the human, then commit via hunch_record_decision with the token + confirmed topic. Use for '/capture', 'record this decision', 'grill me on this'. The token proves the write is the tail of an interview, not a silent guess — it is NOT a human signature: human-confirmed authority needs the human's own confirmation (a client prompt, or `hunch review --confirm <id>`). Returns the protocol text and the token; it writes nothing. Not for corrections (hunch_record_correction) or observations (hunch_record_finding).",
2024
+ description: "Start a decision-capture interview ('/capture', 'grill me'): returns the protocol — ONE question at a time until resolved — and a capture-session token; writes nothing. Then commit via hunch_record_decision with the token + confirmed topic. The token is NOT a human signature: human-confirmed authority needs the human's own confirmation (client prompt, or `hunch review --confirm <id>`). Not for corrections (hunch_record_correction) or observations (hunch_record_finding).",
1891
2025
  inputSchema: {
1892
2026
  topic: z.string().optional().describe("proposed topic anchor (confirm with the human before committing)"),
1893
2027
  seed: z.string().optional().describe("what the decision is about, to focus the first question"),
@@ -1941,7 +2075,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
1941
2075
  // -- hunch_record_decision (write-back) -----------------------------------
1942
2076
  server.registerTool("hunch_record_decision", {
1943
2077
  title: "Record a decision (write-back)",
1944
- description: "Persist a new Decision (ADR) into Hunch with provenance. Use after making a non-trivial design choice so future sessions are grounded in it. Set private:true to keep a SENSITIVE decision out of a (possibly public) repo — it is written to the HUNCH_PRIVATE_DIR overlay store and stays queryable locally, never committed here. Returns the stored id, home, and status. Not for a rule the agent must obey (hunch_record_correction) or an observation with no choice made (hunch_record_finding). Errors are classed by prefix: 'Refused:' means a gate held (resolve it, do not retry), 'Invalid:' means fix the arguments, 'Failed to' means internal.",
2078
+ description: "Persist a Decision (ADR) with provenance after a non-trivial design choice. private:true keeps it in the HUNCH_PRIVATE_DIR overlay, never committed here. Returns id, home, status. Not for an agent-obeyed rule (hunch_record_correction) or an observation (hunch_record_finding). Errors: 'Refused:' a gate held (don't retry); 'Invalid:' fix arguments; 'Failed to' internal.",
1945
2079
  inputSchema: {
1946
2080
  decision: z.object({
1947
2081
  title: z.string(),
@@ -1951,7 +2085,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
1951
2085
  alternatives_rejected: z.array(z.string()).optional(),
1952
2086
  related_files: z.array(z.string()).optional(),
1953
2087
  related_components: z.array(z.string()).optional(),
1954
- topic: z.string().optional().describe("decision-grounding anchor — one topic per decision; enables doc≠graph drift detection for it. Omit to leave un-anchored."),
2088
+ topic: z.string().optional().describe("decision-grounding anchor — one topic per decision; enables doc≠graph drift detection. Omit to leave un-anchored."),
1955
2089
  // FLAT, matching PremiseSchema exactly. A nested { check: {...} } shape is
1956
2090
  // silently STRIPPED by Zod, leaving a claim-only premise — and a claim-only
1957
2091
  // premise is "documented only (no check attached)", which ALWAYS HOLDS. An
@@ -1959,20 +2093,20 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
1959
2093
  // the exact fail-open this feature exists to prevent. Keep in lockstep with
1960
2094
  // PremiseSchema in src/core/types.ts.
1961
2095
  premises: z.array(z.object({
1962
- claim: z.string().min(1).describe("the human-readable reason this decision rests on"),
1963
- path_absent: z.string().optional().describe("premise holds while this repo-relative path does NOT exist. REQUIRES `under`. Prefer path_exists where you can — a negative probe fails OPEN, a positive one fails closed."),
1964
- under: z.string().optional().describe("required with path_absent: an EXISTING repo-relative ANCESTOR of it (path_absent 'src/gateway' -> under 'src'). When the anchor disappears the premise reads unevaluable instead of silently 'still absent'."),
1965
- path_exists: z.string().optional().describe("premise holds while this repo-relative path exists"),
1966
- review_by: z.string().optional().describe("dated attestation: premise holds until this ISO date, then needs re-attesting"),
1967
- attested: z.string().optional().describe("ISO date a human last attested the claim (informational)"),
1968
- })).optional().describe("the checkable reasons this decision rests on — at most ONE check per premise (path_absent | path_exists | review_by). A dead premise NEVER changes authority; it raises an escalation for the human. Omit on re-record to keep the incumbent's premises."),
2096
+ claim: z.string().min(1).describe("human-readable reason"),
2097
+ path_absent: z.string().optional().describe("holds while this repo-relative path does NOT exist; requires `under`. Prefer path_exists — negative probes fail OPEN, positive fail closed."),
2098
+ under: z.string().optional().describe("required with path_absent: an EXISTING repo-relative ancestor ('src/gateway' -> under 'src'). If it disappears, reads unevaluable, not silently absent."),
2099
+ path_exists: z.string().optional().describe("holds while this repo-relative path exists"),
2100
+ review_by: z.string().optional().describe("holds until this ISO date, then needs re-attesting"),
2101
+ attested: z.string().optional().describe("ISO date a human last attested (informational)"),
2102
+ })).optional().describe("checkable reasons this decision rests on, at most one check per premise. A dead premise never changes authority, only raises a human escalation. Omit on re-record to keep the incumbent's."),
1969
2103
  status: z.enum(["proposed", "accepted", "rejected", "superseded"]).optional(),
1970
2104
  commit: z.string().optional(),
1971
- supersedes: z.string().optional().describe("id of a decision this one replaces — closes its valid-time window (invalidate, don't delete)"),
1972
- private: z.boolean().optional().describe("write into the PRIVATE overlay store (HUNCH_PRIVATE_DIR) instead of the committed repo — for sensitive decisions kept out of a public repo. Errors if no private store is configured."),
2105
+ supersedes: z.string().optional().describe("id of a decision this replaces — closes its valid-time window (invalidate, don't delete)"),
2106
+ private: z.boolean().optional().describe("write into the PRIVATE overlay store (HUNCH_PRIVATE_DIR), not the committed repo — for sensitive decisions. Errors if no private store is configured."),
1973
2107
  }),
1974
- capture_token: z.string().optional().describe("token from hunch_capture_decision — proves this write is the tail of a grilling interview (not a human signature: Hunch asks the human to confirm in the client when supported). Omit only for a quick manual record (a deprecation nudge is returned)."),
1975
- task_id: TaskIdSchema.optional().describe("Exact task ID for observing this successful save; reporting never changes capture authority."),
2108
+ capture_token: z.string().optional().describe("token from hunch_capture_decision — proves this write tails a grilling interview (not a signature: the client may still ask the human to confirm). Omit only for a quick manual record (returns a deprecation nudge)."),
2109
+ task_id: saveTaskIdField,
1976
2110
  cwd: cwdHintField,
1977
2111
  },
1978
2112
  }, async ({ decision, capture_token, task_id }) => {
@@ -2196,18 +2330,18 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
2196
2330
  // -- hunch_record_correction (write-back: "Never Twice") ------------------
2197
2331
  server.registerTool("hunch_record_correction", {
2198
2332
  title: "Capture a correction as an enforced constraint (Never Twice)",
2199
- description: "When a human corrects the agent ('no, do it this way' / 'never call X here'), persist that correction as a first-class, SCOPED Constraint with provenance — so the pre-edit hook and the CI Constraint Guard hold EVERY assistant to it from now on, instead of it being forgotten next session. Writes to the shared .hunch/ graph (client-agnostic). Set severity:'blocking' only when the human said never/must; set applies_to_all:true only when the rule is genuinely repo-wide (otherwise it is scoped to scope_hint_file). Returns the constraint id, scope, and what it now enforces. Not for a design choice with alternatives (hunch_record_decision) or an observed gap with no rule yet (hunch_record_finding).",
2333
+ description: "When a human corrects the agent ('never call X here'), persist it as a SCOPED Constraint with provenance, so the pre-edit hook and CI Constraint Guard hold every assistant to it. severity:'blocking' only when the human said never/must; applies_to_all:true only for a genuinely repo-wide rule (else scoped to scope_hint_file). Returns the constraint id and scope. Not for a design choice (hunch_record_decision) or an observed gap with no rule yet (hunch_record_finding).",
2200
2334
  inputSchema: {
2201
2335
  rule: z.string().describe("The invariant in the human's words, e.g. \"never call the pay-per-token API here\"."),
2202
- scope_hint_file: z.string().optional().describe("A file the correction was about; scopes the constraint to it (the conservative default). Prefer a REPO-RELATIVE path (src/foo.ts); an absolute path is relativized against the repo root, and one outside the repo is discarded rather than scoped to a path that could never match."),
2336
+ scope_hint_file: z.string().optional().describe("File the correction was about; scopes the constraint to it (conservative default). Prefer a repo-relative path — an absolute one is relativized, and one outside the repo is discarded rather than scoped to a path that could never match."),
2203
2337
  severity: z.enum(["advisory", "warning", "blocking"]).optional().describe("Default 'warning'. Use 'blocking' only for a hard never/must rule."),
2204
2338
  applies_to_all: z.boolean().optional().describe("True ONLY if the rule is genuinely repo-wide (scopes to **); required to make a repo-wide rule blocking."),
2205
2339
  type: z.enum(["security", "performance", "correctness", "architecture", "compliance"]).optional(),
2206
2340
  rationale: z.string().optional().describe("Why it must hold."),
2207
2341
  source_decision: z.string().optional().describe("id of a decision this correction derives from."),
2208
- private: z.boolean().optional().describe("write into the PRIVATE overlay store (HUNCH_PRIVATE_DIR) instead of the committed repo — a sensitive rule enforced locally (pre-edit hook + local check) but never exposed in a public PR comment. Errors if no private store is configured."),
2209
- capture_token: z.string().optional().describe("token from hunch_capture_decision. The rule is recorded and enforced either way, as agent testimony capped at severity 'warning'. A token never lets it DENY: blocking authority comes only from a human running the printed `hunch review --confirm <id> --severity <s>` command."),
2210
- task_id: TaskIdSchema.optional().describe("Exact task ID for observing this successful save; reporting never changes capture authority."),
2342
+ private: z.boolean().optional().describe("write into the PRIVATE overlay store (HUNCH_PRIVATE_DIR), not the committed repo — enforced locally (pre-edit hook + check), never in a public PR comment. Errors if no private store is configured."),
2343
+ capture_token: z.string().optional().describe("token from hunch_capture_decision. Recorded and enforced either way, as agent testimony capped at severity 'warning'. A token never lets it DENY: blocking authority needs a human running the printed `hunch review --confirm <id> --severity <s>`."),
2344
+ task_id: saveTaskIdField,
2211
2345
  cwd: cwdHintField,
2212
2346
  },
2213
2347
  }, async (input) => {
@@ -2295,12 +2429,12 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
2295
2429
  // -- hunch_record_finding (write-back: observations, no diff) ---------------
2296
2430
  server.registerTool("hunch_record_finding", {
2297
2431
  title: "Record a finding (an observation with no code change)",
2298
- description: "Persist an OBSERVATION into Hunch — audited knowledge with no diff: an audit that surfaced a gap (e.g. queries missing tenant scoping), a measured number, a vendor/platform fact, an incident with no code fix. The anchor is a date + evidence, not a commit. Advisory: it grounds future edits to the affected files/symbols (pre-edit hook + hunch_context) and is listed by hunch_findings; it never blocks. Re-record the SAME title to update triage (e.g. triage:'resolved' + resolved_commit once fixed). If the finding is a violation of a rule that ISN'T recorded yet, record the rule first (hunch_record_correction) and link it via violates_constraint. Returns the finding id, triage, and the files it now grounds. Not for a rule to enforce (hunch_record_correction) or a choice between alternatives (hunch_record_decision).",
2432
+ description: "Persist an OBSERVATION with no code change — an audit gap, a measured number, a platform fact, an incident — anchored to a date + evidence, not a commit. Advisory: grounds future edits to the affected files (pre-edit hook, hunch_context) and is listed by hunch_findings; never blocks. Re-record the SAME title to update triage (e.g. triage:'resolved' + resolved_commit). If it violates an unrecorded rule, record the rule first (hunch_record_correction) and link it via violates_constraint. Not for a rule to enforce (hunch_record_correction) or a choice between alternatives (hunch_record_decision).",
2299
2433
  inputSchema: {
2300
2434
  finding: z.object({
2301
2435
  title: z.string().describe("stable one-line name — re-recording the same title updates the finding"),
2302
2436
  observation: z.string().describe("what was observed, in plain words"),
2303
- evidence: z.array(z.string()).optional().describe("the query/command run + representative output — a finding without evidence is an opinion"),
2437
+ evidence: z.array(z.string()).optional().describe("query/command run + representative output — a finding without evidence is an opinion"),
2304
2438
  method: z.string().optional().describe("rb_* runbook that re-runs the audit (makes it re-verifiable)"),
2305
2439
  severity: z.enum(["low", "medium", "high", "critical"]).optional(),
2306
2440
  triage: z.enum(["open", "accepted-risk", "scheduled", "resolved", "stale"]).optional().describe("default 'open'. 'resolved' should carry resolved_commit."),
@@ -2309,9 +2443,9 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
2309
2443
  violates_constraint: z.string().optional().describe("con_* this finding is a known violation of"),
2310
2444
  spawned_decision: z.string().optional().describe("dec_* recorded in response"),
2311
2445
  resolved_commit: z.string().optional().describe("the commit that fixed it (with triage:'resolved')"),
2312
- private: z.boolean().optional().describe("write into the PRIVATE overlay store instead of the committed repo. Errors if no private store is configured."),
2446
+ private: z.boolean().optional().describe("write into the PRIVATE overlay store, not the committed repo. Errors if no private store is configured."),
2313
2447
  }),
2314
- task_id: TaskIdSchema.optional().describe("Exact task ID for observing this successful save; reporting never changes capture authority."),
2448
+ task_id: saveTaskIdField,
2315
2449
  cwd: cwdHintField,
2316
2450
  },
2317
2451
  }, async ({ finding, task_id }) => {
@@ -2557,32 +2691,44 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
2557
2691
  // -- hunch_findings (read: the open-observations ledger) --------------------
2558
2692
  server.registerTool("hunch_findings", {
2559
2693
  title: "Open findings for a scope",
2560
- description: "List LIVE findings (observed gaps/debt with no fix yet — triage open/accepted-risk/scheduled) concerning a file, glob, or symbol; omit scope for the whole ledger. Call before planning work in an area to inherit past audits instead of re-discovering them. Advisory; resolved/stale findings are excluded unless all:true. Not for invariants (hunch_check_constraints) or bug history (hunch_bug_lineage): findings are observations, never rules.",
2694
+ description: "List LIVE findings (observed gaps/debt with no fix yet — triage open/accepted-risk/scheduled) for a file, glob, or symbol; omit scope for the whole ledger. Call before planning work in an area to inherit past audits instead of re-discovering them. Lists each finding's first sentence; pass id for one finding's full observation. Advisory; resolved/stale findings are excluded unless all:true. Not for invariants (hunch_check_constraints) or bug history (hunch_bug_lineage): findings are observations, never rules.",
2561
2695
  inputSchema: {
2562
2696
  scope: z.string().optional().describe("a path, glob, or symbol (e.g. src/procs/** or dbo.GetOrders); omit for all"),
2563
2697
  all: z.boolean().optional().describe("include resolved/stale findings (the full history)"),
2698
+ id: z.string().optional().describe("one finding id (fnd_*) — returns its full observation, whatever its triage; scope/all/limit are ignored"),
2699
+ limit: z.number().int().min(1).max(FINDINGS_MAX).optional().describe(`how many to list (default ${FINDINGS_CAP}, max ${FINDINGS_MAX})`),
2564
2700
  },
2565
- }, async ({ scope, all }) => {
2701
+ }, async ({ scope, all, id, limit }) => {
2702
+ const concerns = (f) => [...f.affected_files, ...f.affected_symbols].join(", ") || "(unscoped)";
2703
+ 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("; ");
2704
+ if (id) {
2705
+ const f = store.recs("findings").find((r) => r.id === id);
2706
+ if (!f)
2707
+ return ok(`No finding "${id}".`);
2708
+ 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)}` : ""}`);
2709
+ }
2566
2710
  const live = (f) => f.triage === "open" || f.triage === "accepted-risk" || f.triage === "scheduled";
2567
2711
  const list = (scope ? store.liveFindingsFor(scope) : store.recs("findings").filter(all ? () => true : live))
2568
2712
  .filter(all ? () => true : live)
2569
2713
  .sort((a, b) => (SEV_BUG[b.severity] ?? 0) - (SEV_BUG[a.severity] ?? 0) || a.id.localeCompare(b.id));
2570
2714
  if (!list.length)
2571
2715
  return ok(`No ${all ? "" : "live "}findings${scope ? ` for "${scope}"` : ""}. (Record one after an audit with hunch_record_finding.)`);
2572
- const L = list.slice(0, FINDINGS_CAP).map((f) => {
2573
- const links = [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("; ");
2574
- return `• [${f.triage}/${f.severity}] ${f.title} (${f.id}, observed ${f.observed_at.slice(0, 10)})\n ${f.observation}\n concerns: ${[...f.affected_files, ...f.affected_symbols].join(", ") || "(unscoped)"}${links ? `\n ${links}` : ""}`;
2716
+ const cap = limit ?? FINDINGS_CAP;
2717
+ const L = list.slice(0, cap).map((f) => {
2718
+ const where = [...f.affected_files, ...f.affected_symbols];
2719
+ const shown = where.length > 3 ? `${where.slice(0, 3).join(", ")} (+${where.length - 3})` : concerns(f);
2720
+ return `• [${f.triage}/${f.severity}] ${f.title} (${f.id}, observed ${f.observed_at.slice(0, 10)})\n ${firstSentence(f.observation)}\n concerns: ${shown}`;
2575
2721
  });
2576
- return ok(`${list.length} finding(s)${scope ? ` for "${scope}"` : ""}:\n${L.join("\n")}${more(list.length, FINDINGS_CAP)}`);
2722
+ return ok(`${list.length} finding(s)${scope ? ` for "${scope}"` : ""} — hunch_findings(id) for one in full:\n${L.join("\n")}${more(list.length, cap, "raise limit or narrow scope")}`);
2577
2723
  });
2578
2724
  server.registerTool("hunch_policy_upgrade_correction", {
2579
2725
  title: "Build a proved review proposal from one exact correction",
2580
- description: "Upgrade the exact supported static ESM import-declaration package projection of one captured correction into a deterministic review packet when the baseline is clean. Writes proposal, plan, proof, and evidence artifacts only; never activates, warns, blocks, or grants authority. Unsupported corrections keep their immediate legacy guard and create no policy.",
2726
+ description: "Upgrade the exact supported static ESM import-declaration package projection of one captured correction into a deterministic review packet, when the baseline is clean. Writes proposal, plan, proof, and evidence artifacts only; never activates, warns, blocks, or grants authority. Unsupported corrections keep their immediate legacy guard and create no policy.",
2581
2727
  inputSchema: {
2582
2728
  constraint_id: z.string().describe("Captured correction constraint id (con_*)."),
2583
2729
  public_only: z.boolean().optional().describe("Read and write only the public correction home."),
2584
- private_only: z.boolean().optional().describe("Keep correction-derived evidence/policy/proof artifacts in the configured private overlay; the public source-code graph is refreshed before proof."),
2585
- include_artifacts: z.boolean().optional().describe("Include the complete Policy IR, proof plan, proof receipts, and evidence object. Default output is a concise review envelope."),
2730
+ private_only: z.boolean().optional().describe("Keep correction-derived artifacts in the private overlay; the public source-code graph is refreshed before proof."),
2731
+ include_artifacts: z.boolean().optional().describe("Include the full Policy IR, proof plan, receipts, and evidence. Default is a concise review envelope."),
2586
2732
  cwd: cwdHintField,
2587
2733
  },
2588
2734
  }, async ({ constraint_id, public_only, private_only, include_artifacts }) => {
@@ -2647,7 +2793,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
2647
2793
  // -- hunch_merge_verdict (Causal Merge Verdict — read-only, client-agnostic) --
2648
2794
  server.registerTool("hunch_merge_verdict", {
2649
2795
  title: "Causal merge verdict: is this change safe against the recorded WHY?",
2650
- description: "Before opening or merging a PR, replay a diff against engineering memory and return ONE verdict — BLOCK / WARN / PASS. For each invariant DIRECTLY in scope it cites WHY the guard exists (the decision that motivated it + the bug whose root cause spawned it); it also lists invariants reached via blast radius (near, advisory), any deliberately-retired code the diff re-introduces, and symbols the diff adds that are already defined elsewhere in the graph (possible re-implementation/sprawl, advisory). Deterministic, no LLM. Omit base, commit, and working to check STAGED changes; pass working:true for all local changes, base (e.g. origin/main) for a PR range, or commit for a single commit. Call this before merging a widely-scoped change. Not for an advisory impact map (hunch_pr_impact), intent erosion with no diff (hunch_conformance), or a sealed proof of one committed transition (hunch_change_proof).",
2796
+ description: "Before opening or merging a PR, replay a diff against memory and return ONE verdict — BLOCK / WARN / PASS — citing the decision and bug behind each invariant in scope, plus advisory blast-radius invariants, re-introduced retired code, and symbols already defined elsewhere. Deterministic, no LLM. Default: staged changes; working:true for all local changes, base (e.g. origin/main) for a PR range, commit for one commit. Not for an advisory impact map (hunch_pr_impact), or intent erosion with no diff (hunch_conformance).",
2651
2797
  inputSchema: {
2652
2798
  base: z.string().optional().describe("Diff against this base ref (e.g. origin/main) — for a PR/branch."),
2653
2799
  commit: z.string().optional().describe("Diff a single commit (sha/ref). Omit base AND commit to check staged changes."),
@@ -2690,7 +2836,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
2690
2836
  // -- hunch_pr_impact (read-only impact surface — advisory, never gates) ----
2691
2837
  server.registerTool("hunch_pr_impact", {
2692
2838
  title: "PR impact: the dependency + memory surface of a change",
2693
- description: "Given a change (staged, working tree, a branch vs base, or a single commit), return its IMPACT SURFACE: the files whose code transitively depends on the changed files, the invariants directly in scope and those reached via blast radius, and the recorded decisions concerning the touched files. Read-only and advisory — use hunch_merge_verdict for the gate. Call before review to know what a PR can break and which recorded intent it touches. Omit base, commit, and working for staged changes. Not for a verdict (hunch_merge_verdict) or a sealed proof (hunch_change_proof).",
2839
+ description: "IMPACT SURFACE of a change (default staged; or working:true, base, or commit): files that transitively depend on it, invariants in scope and via blast radius, and decisions on the touched files. Read-only, advisory. Not for a verdict (hunch_merge_verdict).",
2694
2840
  inputSchema: {
2695
2841
  base: z.string().optional().describe("Diff against this base ref (e.g. origin/main) — for a PR/branch."),
2696
2842
  commit: z.string().optional().describe("Impact of a single commit (sha/ref). Omit base AND commit for staged changes."),