@davesheffer/hunch 1.46.0 → 1.47.1

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 (78) hide show
  1. package/dist/cli/index.js +280 -358
  2. package/dist/cli/invocation.d.ts +8 -13
  3. package/dist/cli/invocation.js +26 -29
  4. package/dist/cli/taskReport.d.ts +6 -0
  5. package/dist/cli/taskReport.js +31 -8
  6. package/dist/constitution/delta.js +2 -2
  7. package/dist/constitution/sourceMutation.js +28 -18
  8. package/dist/core/commitrepair.d.ts +43 -138
  9. package/dist/core/commitrepair.js +76 -167
  10. package/dist/core/correction.d.ts +2 -0
  11. package/dist/core/correction.js +23 -10
  12. package/dist/core/docanchors.js +9 -9
  13. package/dist/core/drift.js +22 -27
  14. package/dist/core/escalations.d.ts +16 -58
  15. package/dist/core/escalations.js +39 -121
  16. package/dist/core/glob.d.ts +7 -7
  17. package/dist/core/glob.js +12 -11
  18. package/dist/core/groundingLag.d.ts +2 -9
  19. package/dist/core/groundingLag.js +5 -11
  20. package/dist/core/groundingMerge.d.ts +2 -7
  21. package/dist/core/groundingMerge.js +35 -54
  22. package/dist/core/paths.d.ts +17 -24
  23. package/dist/core/paths.js +37 -43
  24. package/dist/core/refrepair.d.ts +2 -1
  25. package/dist/core/refrepair.js +4 -4
  26. package/dist/core/repairqueue.d.ts +17 -34
  27. package/dist/core/repairqueue.js +48 -98
  28. package/dist/core/taskRecord.d.ts +1 -1
  29. package/dist/core/taskRecord.js +2 -2
  30. package/dist/core/taskReport.d.ts +8 -3
  31. package/dist/core/taskReport.js +7 -2
  32. package/dist/core/taskReportEvidence.d.ts +7 -0
  33. package/dist/core/taskReportEvidence.js +16 -4
  34. package/dist/core/taskReportHook.js +2 -2
  35. package/dist/core/taskReportPublic.js +2 -2
  36. package/dist/core/taskReportRender.js +2 -2
  37. package/dist/core/types.d.ts +3 -3
  38. package/dist/core/types.js +1 -1
  39. package/dist/extractors/diff.d.ts +4 -4
  40. package/dist/extractors/diff.js +25 -29
  41. package/dist/extractors/git.d.ts +18 -23
  42. package/dist/extractors/git.js +72 -64
  43. package/dist/extractors/helm.d.ts +10 -0
  44. package/dist/extractors/helm.js +43 -59
  45. package/dist/extractors/indexer.d.ts +6 -0
  46. package/dist/extractors/indexer.js +126 -178
  47. package/dist/extractors/k8sManifest.d.ts +23 -80
  48. package/dist/extractors/k8sManifest.js +319 -690
  49. package/dist/extractors/languages.d.ts +28 -37
  50. package/dist/extractors/languages.js +55 -54
  51. package/dist/extractors/nativeTreeSitter.d.ts +4 -4
  52. package/dist/extractors/nativeTreeSitter.js +17 -14
  53. package/dist/extractors/parse.d.ts +7 -13
  54. package/dist/extractors/parse.js +39 -51
  55. package/dist/integrations/claudemd.js +1 -0
  56. package/dist/integrations/gitignore.js +3 -5
  57. package/dist/integrations/health.js +1 -1
  58. package/dist/integrations/hooks.d.ts +9 -14
  59. package/dist/integrations/hooks.js +19 -28
  60. package/dist/integrations/mergeDriver.js +13 -14
  61. package/dist/integrations/probe.d.ts +11 -0
  62. package/dist/integrations/probe.js +29 -4
  63. package/dist/integrations/sync.d.ts +16 -4
  64. package/dist/integrations/sync.js +54 -3
  65. package/dist/integrations/workspaceLedger.d.ts +1 -1
  66. package/dist/mcp/server.d.ts +4 -5
  67. package/dist/mcp/server.js +71 -83
  68. package/dist/mcp/taskReportTools.js +4 -4
  69. package/dist/serve/app.js +2 -2
  70. package/dist/store/hunchStore.js +12 -18
  71. package/dist/synthesis/provider.d.ts +19 -21
  72. package/dist/synthesis/provider.js +67 -76
  73. package/dist/synthesis/synthesize.d.ts +8 -8
  74. package/dist/synthesis/synthesize.js +32 -49
  75. package/dist/taskReports.d.ts +1 -0
  76. package/dist/taskReports.js +2 -2
  77. package/package.json +2 -2
  78. package/server.json +2 -2
@@ -34,7 +34,7 @@ import { refreshExistingGrounding } from "../integrations/providers.js";
34
34
  import { workspaceLedgerView, renderWorktreeTable, renderBranchTable, workspaceSummaryLine, snapshotHasHome, recordWorkspaceSnapshot, branchRows, worktreeRows } from "../integrations/workspaceLedger.js";
35
35
  import { workspacesConfig } from "../core/config.js";
36
36
  import { revParse, asOfDate, revExists, lastChangeDate, checkFreshnessRevision, teamPullIsFresh, rangeFiles, rangeGateDiff, commitFiles, commitGateDiff, stagedFiles, stagedGateDiff, workingFiles, workingGateDiff, pullHunchStatusDetailed, describePullFailure, sameRemoteUrl, currentBranch, worktreePaths, pathKnownToHistory } from "../extractors/git.js";
37
- import { flushCapture, flushMemoryHome, pinSharedRemote } from "../integrations/sync.js";
37
+ import { flushCapture, flushStateWrite, flushMemoryHome, pinSharedRemote, HELD_ON_DEFAULT_BRANCH } from "../integrations/sync.js";
38
38
  import { withWriteLock } from "../serve/writelock.js";
39
39
  import { advertisedTeamRemoteContract, ensureTeamOverlay, isTeamStoreTrusted, overlayMatchesTeamRemote, teamWiringConsented, untrustedTeamStoreMessage, readTeamConfig, teamRemoteContract, teamSharedRef } from "../integrations/team.js";
40
40
  import { formatSearchHit, formatStructure } from "../core/format.js";
@@ -90,34 +90,36 @@ 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
114
115
  * next flush sweeps it up; claiming "auto-committed" there would be a lie. */
115
- const flushNote = (flush, home, mode) => flush === "pushed" ? ` (committed + pushed to the ${mode === "shared" ? "shared team store" : "private repo"})`
116
- : flush === "committed"
117
- ? home === "private"
118
- ? " (committed to the overlay repo — push deferred: offline, no upstream, or merge conflict; the next capture or `hunch private --sync` retries)"
119
- : " (auto-committed to .hunch/ — rides your next push)"
120
- : "";
116
+ const flushNote = (flush, home, mode) => flush === "held" ? ` (${HELD_ON_DEFAULT_BRANCH})`
117
+ : flush === "pushed" ? ` (committed + pushed to the ${mode === "shared" ? "shared team store" : "private repo"})`
118
+ : flush === "committed"
119
+ ? home === "private"
120
+ ? " (committed to the overlay repo — push deferred: offline, no upstream, or merge conflict; the next capture or `hunch private --sync` retries)"
121
+ : " (auto-committed to .hunch/ — rides your next push)"
122
+ : "";
121
123
  /** When an overlay exists, a PUBLIC write deserves one visible line: a record that
122
124
  * lands in the committed store publishes on the next push, and an agent writing
123
125
  * strategy/competitive content there is a leak nobody notices until it ships
@@ -150,11 +152,9 @@ const publicHomeNote = (home, hasPrivate, record, hunchDir) => {
150
152
  return risk;
151
153
  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
154
  };
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. */
155
+ /** Self-diagnosing destination suffix for every auto-committing write tool. A capture
156
+ * silently landing in the wrong repo or branch is only discoverable by auditing git log
157
+ * by hand, so the destination is ALWAYS shown, whether or not a cwd hint chose it. */
158
158
  const destinationNote = (destRoot) => {
159
159
  const branch = currentBranch(destRoot);
160
160
  return ` [captured${branch ? ` on branch ${branch}` : ""} in ${destRoot}]`;
@@ -586,12 +586,10 @@ function nuryelWriteSubject(facet, record) {
586
586
  const kind = facet.endsWith("s") ? facet.slice(0, -1) : facet;
587
587
  return `${kind}${typeof label === "string" ? ` "${label.slice(0, 60)}"` : ""} (nuryel_write)`;
588
588
  }
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;
589
+ /** Where a capture keyed to a storage home actually lands. Centralized so every
590
+ * destination-reporting call site agrees: hunch_policy_upgrade_correction once computed
591
+ * its own home but reported the public root regardless, misreporting a private-homed proof. */
592
+ const destinationRoot = (home, store, root) => home === "private" && store.privateDir ? store.privateDir : root;
595
593
  // Read-side token budgets: every tool result is injected into a Claude Code
596
594
  // session, so an uncapped list pollutes the context window. Cap each list to its
597
595
  // highest-signal head (records are pre-sorted by severity/confidence) and tell the
@@ -993,13 +991,12 @@ function qualityNudge(rec) {
993
991
  }
994
992
  return "";
995
993
  }
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. */
994
+ /** Resolve a free-form target (symbol id, name, or file path) to symbol records using
995
+ * tiered matching — exact id, then exact name, then exact file, then a path-segment-anchored
996
+ * suffix. Shared with HunchStore.why() through matchSymbolsTiered so the two never disagree. */
1000
997
  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).
998
+ // An absolute path (an agent's verbatim edit-payload path) must match the repo-relative
999
+ // stored file paths (issue #296).
1003
1000
  target = repoRelativeTarget(target, store.publicRoot);
1004
1001
  const syms = store.json.loadAll("symbols");
1005
1002
  const components = store.json.loadAll("components");
@@ -1372,23 +1369,20 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
1372
1369
  };
1373
1370
  const registerTool = server.registerTool.bind(server);
1374
1371
  server.registerTool = ((name, config, callback) => hiddenTools.has(name) ? undefined : registerTool(name, config, async (...args) => {
1375
- // Claude Code CLI never advertises `roots`/`roots/list_changed` for an agent-driven
1376
- // `cd` or EnterWorktree (issue #20) — the cached `root` above just never moves, so a
1377
- // write silently lands wherever the process was spawned. Write tools accept an
1378
- // optional `cwd` argument (see cwdHintField) as a client-agnostic fallback: resolved
1379
- // fresh on EVERY call instead of trusted from a cache, and re-homing this stdio
1380
- // process the same way a `roots` notification would. Only safe when this is the
1381
- // sole in-flight request — re-homing under a concurrent request would tear its
1382
- // root/store out from under it, so that case is refused rather than risked.
1383
- const cwdHint = extractCwdHint(args[0]);
1372
+ // The Claude Code CLI never advertises roots/list_changed for an agent `cd` or worktree
1373
+ // entry, so the cached root never moves and writes land where the process was spawned.
1374
+ // Write tools therefore accept an optional `cwd` (see cwdHintField) as a client-agnostic
1375
+ // fallback, resolved fresh on every call rather than cached, re-homing this stdio
1376
+ // process the way a roots notification would. That is only safe when no other request
1377
+ // is in flight, so that case is refused instead of risked.
1378
+ const cwdHint = cwdHintOf(args[0]);
1384
1379
  // A pinned root is the whole point of `hunch mcp --root`: a served partition must not
1385
1380
  // follow the caller's working directory into some other checkout.
1386
1381
  if (cwdHint !== undefined && !pinned) {
1387
1382
  const target = canonicalRootPath(findRoot(cwdHint));
1388
1383
  if (target !== canonicalRootPath(root)) {
1389
- if (activeRequests) {
1390
- return err(`Hunch is mid-request against ${root} and cannot safely switch to the working directory you passed ` +
1391
- `(resolves to ${target}) while another call is in flight. Retry this call once the other one completes.`);
1384
+ if (activeRequests !== 0) {
1385
+ 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.`);
1392
1386
  }
1393
1387
  try {
1394
1388
  setRoot(cwdHint);
@@ -1923,13 +1917,13 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
1923
1917
  // -- hunch_now (the hot view: recent activity + roadmap) --------------------
1924
1918
  // PUBLIC store only, per dec_29eff08c69's jurisdiction rule: an assistant may
1925
1919
  // paste this anywhere, so it must be publishable by construction. Union view
1926
- // stays behind `hunch now --private` on the local terminal. EXCEPTION: a
1927
- // queued commit-repair's liveness is checked against the full store (see
1928
- // below), so a private-overlay decision's id and commit shas — never its
1929
- // title — can surface in the escalation line.
1920
+ // is available only through the local terminal command `hunch now --private`.
1921
+ // Exception: a queued commit-repair's liveness is checked against the full store, so a
1922
+ // private-overlay decision's id and commit shas (never its title) may appear in the
1923
+ // escalation line.
1930
1924
  server.registerTool("hunch_now", {
1931
1925
  title: "Recent activity + the roadmap (the hot view)",
1932
- 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.",
1926
+ 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.",
1933
1927
  inputSchema: {
1934
1928
  recent_limit: z.number().optional().describe("How many recent decisions to include (default 5)."),
1935
1929
  roadmap_limit: z.number().optional().describe("How many roadmap entries to include (default 2); the rest are counted."),
@@ -1962,18 +1956,15 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
1962
1956
  L.push("", ws);
1963
1957
  const escalations = pendingEscalations(store.advisoryRecs("decisions"));
1964
1958
  escalations.push(...premiseEscalations(store.advisoryRecs("decisions"), { now: new Date().toISOString(), exists: (p) => existsSync(join(root, p)) }));
1965
- // liveness checked against the full store (repair-provenance reads the
1966
- // full store too) even though the title stays scoped to advisoryRecs —
1967
- // an overlay decision's repair is fully answerable, so it must not go
1968
- // silent here just because its title is private.
1969
- const hunchNowQueue = readActivePendingRepairs(root);
1970
- escalations.push(...commitRepairEscalations(hunchNowQueue, store.advisoryRecs("decisions"), store.recs("decisions"), withheldRewrites(root, hunchNowQueue)));
1971
- // Only ACTIONABLE entries are a question the assistant can put to the human
1972
- // directly — a duplicate-id commit-repair follower whose own resolution
1973
- // says "act on a different entry first" isn't one (#61).
1974
- const actionableNow = actionableEscalations(escalations);
1975
- if (actionableNow.length) {
1976
- L.push("", `⚖ ${actionableNow.length} decision(s) need the human's call — ASK inline (never queue): ${actionableNow.map((e) => e.question).join(" · ")}`);
1959
+ // Liveness is checked against the FULL store (the title stays scoped to advisory
1960
+ // records), so an overlay decision's repair does not go silent.
1961
+ const repairQueue = readActivePendingRepairs(root);
1962
+ escalations.push(...commitRepairEscalations(repairQueue, store.advisoryRecs("decisions"), store.recs("decisions"), withheldRewrites(root, repairQueue)));
1963
+ // A duplicate-id commit-repair follower whose own answer is "act on a different
1964
+ // entry first" is not a question to raise (#61).
1965
+ const actionable = actionableEscalations(escalations);
1966
+ if (actionable.length) {
1967
+ L.push("", `⚖ ${actionable.length} decision(s) need the human's call — ASK inline (never queue): ${actionable.map((e) => e.question).join(" · ")}`);
1977
1968
  }
1978
1969
  return ok(L.join("\n"));
1979
1970
  });
@@ -2039,24 +2030,20 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
2039
2030
  // Premise decay: a live decision whose recorded reason died. Question-framed
2040
2031
  // like every entry here — authority never changes until the human answers.
2041
2032
  items.push(...premiseEscalations(store.advisoryRecs("decisions"), { now: new Date().toISOString(), exists: (p) => existsSync(join(root, p)) }));
2042
- // See the sibling hunch_now handler above for why `live` is the full store.
2043
- const hunchEscalationsQueue = readActivePendingRepairs(root);
2044
- items.push(...commitRepairEscalations(hunchEscalationsQueue, store.advisoryRecs("decisions"), store.recs("decisions"), withheldRewrites(root, hunchEscalationsQueue)));
2033
+ // Live set is the full store, for the reason given in the sibling hunch_now handler.
2034
+ const repairQueue = readActivePendingRepairs(root);
2035
+ items.push(...commitRepairEscalations(repairQueue, store.advisoryRecs("decisions"), store.recs("decisions"), withheldRewrites(root, repairQueue)));
2045
2036
  try {
2046
2037
  items.push(...policyEscalations(new ConstitutionService(store, root).list({ publicOnly: true }).map((p) => ({ ...p, last_action: p.audit.at(-1)?.action ?? null }))));
2047
2038
  }
2048
2039
  catch { /* constitution unavailable — memory escalations still surface */ }
2049
2040
  if (!items.length)
2050
2041
  return ok("✓ Nothing needs a human decision — memory is auto-trusted and self-consistent.");
2051
- // Only ACTIONABLE entries are a question to ask the human directly — a
2052
- // duplicate-id commit-repair follower whose own resolution says "act on
2053
- // a different entry first" still surfaces below for transparency, but
2054
- // isn't itself something to raise as a decision (#61).
2042
+ // Only actionable entries are questions to raise; non-actionable followers still
2043
+ // appear below for transparency (#61).
2055
2044
  const L = [escalationHeadline(items, "mcp"), ""];
2056
2045
  for (const e of items) {
2057
- // Marked distinctly (never the "⚖ question" glyph) when it isn't
2058
- // itself something to raise — an entry whose own answer is "act on a
2059
- // different entry first" reads as informational, not as a question.
2046
+ // Non-actionable entries are marked distinctly so they read as informational, not as a question.
2060
2047
  L.push(`${e.actionable === false ? "·" : "⚖"} ${e.question}`);
2061
2048
  L.push(` ${e.detail}`);
2062
2049
  L.push(` → ${e.resolution}`, "");
@@ -2474,7 +2461,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
2474
2461
  const where = decision.private
2475
2462
  ? ` [PRIVATE overlay — not committed to this repo]${flushed}`
2476
2463
  : home === "private" ? ` [SHARED store — one source of truth for the whole team]${flushed}` : flushed;
2477
- const dest = destinationNote(resolveDestRoot(home, store, root));
2464
+ const dest = destinationNote(destinationRoot(home, store, root));
2478
2465
  // The chain (nuryel.state/1): a `shipped` receipt in an organization drawer rests on
2479
2466
  // this decision by id + the hash ON FILE + this repository's partition. Hand the ref
2480
2467
  // over now so the agent never rests on a pre-store hash or re-derives the scope.
@@ -2585,7 +2572,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
2585
2572
  const tierNote = `
2586
2573
 
2587
2574
  ⚠ ${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 })}`;
2588
- const dest = destinationNote(resolveDestRoot(home, store, root));
2575
+ const dest = destinationNote(destinationRoot(home, store, root));
2589
2576
  return ok(`${existing ? "Updated" : "Recorded"} ${rec.severity} constraint ${rec.id}: "${rec.statement}" (scope: ${rec.scope.join(", ")}).${where}${dest} It now ${enforce}.${reviewNote}${tierNote}`);
2590
2577
  }
2591
2578
  catch (e) {
@@ -2666,8 +2653,9 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
2666
2653
  ? `\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.`
2667
2654
  : "";
2668
2655
  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.";
2669
- const dest = destinationNote(resolveDestRoot(home, store, root));
2670
- 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}`);
2656
+ const dest = destinationNote(destinationRoot(home, store, root));
2657
+ const grounds = [...rec.affected_files, ...rec.affected_symbols].join(", ") || "(nothing — add affected_files/symbols so it surfaces at edit time)";
2658
+ 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}`);
2671
2659
  }
2672
2660
  catch (e) {
2673
2661
  return err(`Failed to record finding: ${e.message}`);
@@ -2780,7 +2768,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
2780
2768
  // not race the HTTP server between the ledger read and the record write.
2781
2769
  const { hunchDir } = stateHomeFor(store, input.scope);
2782
2770
  const result = await withWriteLock(hunchDir, () => writeState(store, { schema: STATE_WRITE_VERSION, ...input }, {
2783
- flush: (isPrivate, message) => flushCapture(store, hunchPaths(root).hunch, isPrivate, message, startupTeamRoute ?? undefined),
2771
+ flush: (isPrivate, message) => flushStateWrite(store, hunchPaths(root).hunch, isPrivate, message, startupTeamRoute ?? undefined),
2784
2772
  }));
2785
2773
  return stateResult(`${result.outcome} ${result.record_id} (${result.durability}) ${result.record_hash}`, result);
2786
2774
  }
@@ -2797,7 +2785,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
2797
2785
  try {
2798
2786
  const { hunchDir } = stateHomeFor(store, input.scope);
2799
2787
  const result = await withWriteLock(hunchDir, () => captureState(store, { schema: STATE_CAPTURE_VERSION, ...input }, {
2800
- flush: (isPrivate, message) => flushCapture(store, hunchPaths(root).hunch, isPrivate, message, startupTeamRoute ?? undefined),
2788
+ flush: (isPrivate, message) => flushStateWrite(store, hunchPaths(root).hunch, isPrivate, message, startupTeamRoute ?? undefined),
2801
2789
  }));
2802
2790
  return stateResult(`${result.outcome} observation ${result.record_id} (${result.durability}); this does not assert currentness. ${result.record_hash}`, result);
2803
2791
  }
@@ -2814,7 +2802,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
2814
2802
  try {
2815
2803
  const { hunchDir } = stateHomeFor(store, input.scope);
2816
2804
  const result = await withWriteLock(hunchDir, () => captureBatchState(store, { schema: STATE_CAPTURE_BATCH_VERSION, ...input }, {
2817
- flush: (isPrivate, message) => flushCapture(store, hunchPaths(root).hunch, isPrivate, message, startupTeamRoute ?? undefined),
2805
+ flush: (isPrivate, message) => flushStateWrite(store, hunchPaths(root).hunch, isPrivate, message, startupTeamRoute ?? undefined),
2818
2806
  }));
2819
2807
  return stateResult(`Capture batch: ${result.results.filter(r => r.status === "saved").length} saved/replayed, ${result.results.filter(r => r.status === "refused").length} refused.${result.reviews ? ` Reviews: ${result.reviews.filter(r => r.status === "saved").length} withdrawn/replayed, ${result.reviews.filter(r => r.status === "refused").length} refused.` : ''} Inspect each indexed result.`, result);
2820
2808
  }
@@ -2950,7 +2938,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
2950
2938
  if (artifactHome === "private") {
2951
2939
  flushMemoryHome(store, hunchPaths(root).hunch, "private", `hunch: prove correction ${constraint_id}`, startupTeamRoute ?? undefined);
2952
2940
  }
2953
- const destRoot = resolveDestRoot(artifactHome, store, root);
2941
+ const destRoot = destinationRoot(artifactHome, store, root);
2954
2942
  const destination = { root: destRoot, branch: currentBranch(destRoot) };
2955
2943
  if (include_artifacts)
2956
2944
  return ok(JSON.stringify({ ...upgrade, destination }, null, 2));
@@ -3139,7 +3127,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
3139
3127
  if (!home)
3140
3128
  throw new Error(`policy ${policy_id} has no exact storage home`);
3141
3129
  flushMemoryHome(store, hunchPaths(root).hunch, home, `hunch: plan policy ${policy_id}`, startupTeamRoute ?? undefined);
3142
- const destRoot = resolveDestRoot(home, store, root);
3130
+ const destRoot = destinationRoot(home, store, root);
3143
3131
  return ok(JSON.stringify({ ...plan, destination: { root: destRoot, branch: currentBranch(destRoot) } }, null, 2));
3144
3132
  }
3145
3133
  catch (e) {
@@ -3437,7 +3425,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
3437
3425
  dependencyTimeoutMs: dependency_timeout_ms ?? 300_000,
3438
3426
  });
3439
3427
  flushMemoryHome(store, hunchPaths(root).hunch, "private", "hunch: materialize G2 behavior policies", startupTeamRoute ?? undefined);
3440
- const destRoot = resolveDestRoot("private", store, root);
3428
+ const destRoot = destinationRoot("private", store, root);
3441
3429
  return ok(JSON.stringify({ ...materialized, destination: { root: destRoot, branch: currentBranch(destRoot) } }, null, 2));
3442
3430
  }
3443
3431
  catch (e) {
@@ -2,7 +2,7 @@ import { z } from "zod";
2
2
  import { verificationLauncherFor } from "../core/verifyLauncher.js";
3
3
  import { TaskIdSchema, ReportClaimSchema, LessonReferenceSchema, finishReportTask, listReportTasks, readTaskReport, readLessonHistory, recordReportClaim, reportPresentationEnabled, startReportTask } from "../core/taskReport.js";
4
4
  import { persistTaskRecord } from "../core/taskRecord.js";
5
- import { reportSourceSnapshot, runReportConformance } from "../core/taskReportEvidence.js";
5
+ import { currentSource, runReportConformance } from "../core/taskReportEvidence.js";
6
6
  import { renderTaskReport, writeTaskReportHtml } from "../core/taskReportRender.js";
7
7
  function applicationReferences(report) {
8
8
  return report.deliveries.flatMap(d => d.records.map(r => ({ occurrence_id: d.occurrence_id, record_id: r.record_id, content_hash: r.content_hash, title: r.title }))).slice(-100);
@@ -85,7 +85,7 @@ export function registerTaskReportTools(server, getRoot, getStore) {
85
85
  // from the model must not fork a second report or fail the start.
86
86
  if (!task_id || !/different title/.test(error.message))
87
87
  throw error;
88
- task = readTaskReport(root, task_id, reportSourceSnapshot(root).hash).task;
88
+ task = readTaskReport(root, task_id, currentSource(root)).task;
89
89
  }
90
90
  const launcher = verificationLauncher();
91
91
  // Compact by design (#370): the rules an agent needs to act, and exact
@@ -120,7 +120,7 @@ export function registerTaskReportTools(server, getRoot, getStore) {
120
120
  catch (error) {
121
121
  graphNote = `\nGraph not saved: ${error.message}`;
122
122
  }
123
- const report = readTaskReport(root, task_id, reportSourceSnapshot(root).hash);
123
+ const report = readTaskReport(root, task_id, currentSource(root));
124
124
  const show = reportPresentationEnabled(root);
125
125
  // The HTML evidence view is rendered on demand (hunch_report(html: true),
126
126
  // `hunch report <id> --html`, or the VS Code view); finish writes no file.
@@ -165,7 +165,7 @@ export function registerTaskReportTools(server, getRoot, getStore) {
165
165
  const tasks = listReportTasks(root);
166
166
  return { content: [{ type: "text", text: tasks.length ? tasks.map(t => `${t.task_id} · ${t.state} · ${t.title}`).join("\n") : "No task reports yet. Start with hunch_task." }], structuredContent: { tasks } };
167
167
  }
168
- const report = readTaskReport(root, task_id, reportSourceSnapshot(root).hash);
168
+ const report = readTaskReport(root, task_id, currentSource(root));
169
169
  const file = html ? writeTaskReportHtml(root, task_id) : null;
170
170
  return { content: [{ type: "text", text: `${renderTaskReport(report)}${file ? `\nLocal evidence view: ${file}` : ""}` }], structuredContent: { ...boundedTaskReportForHost(report), application_references: applicationReferences(report), contribution_card: renderTaskReport(report), report_path: file } };
171
171
  }
package/dist/serve/app.js CHANGED
@@ -23,7 +23,7 @@ import { STATE_PROOF_CAPABILITY } from '../core/stateProof.js';
23
23
  import { createServer } from "node:http";
24
24
  import { HunchStore } from "../store/hunchStore.js";
25
25
  import { hunchPaths } from "../core/paths.js";
26
- import { flushCapture } from "../integrations/sync.js";
26
+ import { flushStateWrite } from "../integrations/sync.js";
27
27
  import { StateRefusal, capabilities, mergeReadResponses, readState, recordsState, stateHomeFor, subscribeState, writeState } from "../store/stateBinding.js";
28
28
  import { STATE_READ_VERSION, STATE_RECORDS_VERSION, STATE_SUBSCRIBE_VERSION, STATE_WRITE_VERSION, ReadScopesSchema, ScopeSchema, scopePath } from "../core/stateContract.js";
29
29
  import { partitionFor, resolveCredential, readServeConfig } from "./config.js";
@@ -174,7 +174,7 @@ export function createServeApp(config, opts = {}) {
174
174
  // Only authenticated grants select stores for cross-partition source visibility.
175
175
  const accessOptions = { additionalStores: principal.grants.map(grant => requestStore(grant).store) };
176
176
  const { store, root } = requestStore(scope);
177
- const flush = (isPrivate, message) => flushCapture(store, hunchPaths(root).hunch, isPrivate, message);
177
+ const flush = (isPrivate, message) => flushStateWrite(store, hunchPaths(root).hunch, isPrivate, message);
178
178
  if (route === "read") {
179
179
  if (body.observed_page !== undefined && body.scopes !== undefined)
180
180
  throw problem(400, 'malformed', 'observation pages require a single partition without scopes');
@@ -1341,8 +1341,8 @@ export class HunchStore {
1341
1341
  * instant — "what did we believe as of commit X?". Omit `asOf` for the full,
1342
1342
  * history-inclusive view (backward-compatible default). */
1343
1343
  why(target, opts = {}) {
1344
- // An absolute target (an agent's edit-payload path, verbatim) never matched
1345
- // the repo-relative stored file paths below (issue #299).
1344
+ // An agent's verbatim edit-payload path is absolute; stored paths are
1345
+ // repo-relative, so rewrite before any matching (issue #299).
1346
1346
  target = repoRelativeTarget(target, this.paths.root);
1347
1347
  // A record id (every delivered brief line ends in `hunch_why("dec_…")`) resolves
1348
1348
  // to that record directly (issue #474). With `asOf`, a record not in force then
@@ -1364,11 +1364,11 @@ export class HunchStore {
1364
1364
  // so an unanchored suffix pulled unrelated files' records into why()/the
1365
1365
  // pre-edit grounding block (issue #32). Segment-anchored matching only.
1366
1366
  //
1367
- // A target that IS a path Hunch's index already knows about (README.md,
1368
- // vscode-extension/index.ts, ...) names exactly one file — suffix-matching
1369
- // it too pulls in every OTHER file that merely shares a basename (issue
1370
- // #299: root index.ts delivered vscode-extension/index.ts's rules and vice
1371
- // versa). "Known to the index" is answered from already-loaded graph data
1367
+ // A target that is a path the index already knows (README.md,
1368
+ // `vscode-extension/index.ts`) names exactly ONE file. Suffix-matching it too
1369
+ // would pull in every other file sharing its basename (issue #299: root
1370
+ // `index.ts` received `vscode-extension/index.ts`'s rules and vice versa). "Known
1371
+ // to the index" is answered from already-loaded graph data, looked up
1372
1372
  // first (a symbol's exact file, or an indexed component's path glob — so a
1373
1373
  // symbol-less indexed file still counts as real), and only then from the
1374
1374
  // working tree, for a real file the index cannot see at all (issue #334).
@@ -1696,9 +1696,8 @@ export class HunchStore {
1696
1696
  * longer enforced. Pass `{ asOf }` to instead return the invariants in force at
1697
1697
  * that instant (time-travel: "what must I not have broken as of commit X?"). */
1698
1698
  checkConstraints(scope, opts = {}) {
1699
- // Constraint scopes are always repo-relative; an absolute target (an
1700
- // agent's edit-payload path, verbatim) matched nothing even when a
1701
- // blocking rule plainly applied (issue #296).
1699
+ // Scopes are repo-relative; an absolute target matched nothing even when a
1700
+ // blocking rule applied (issue #296).
1702
1701
  scope = repoRelativeTarget(scope, this.paths.root);
1703
1702
  const all = this.recs("constraints");
1704
1703
  const asOf = opts.asOf;
@@ -1713,11 +1712,8 @@ export class HunchStore {
1713
1712
  * glob, and the queried scope may be either too. Advisory only — findings never
1714
1713
  * enter any block path. Sorted worst-first, then id for stable output. */
1715
1714
  liveFindingsFor(scope) {
1716
- // Same absolute-target and same-basename-leak gaps as why() (issue #299): an
1717
- // un-rewritten absolute scope never matched the repo-relative `affected_files`
1718
- // below, and an un-guarded suffix fallback could pull in an unrelated file
1719
- // that merely shares a basename. Normalize once and reuse the same "is this
1720
- // path known to the index" guard.
1715
+ // Same absolute-target and same-basename-leak gaps as why() (issue #299):
1716
+ // normalize once and reuse the known-path guard.
1721
1717
  const t = repoRelativeTarget(scope, this.paths.root);
1722
1718
  const indexed = this.isKnownPath(t);
1723
1719
  const live = (f) => f.triage === "open" || f.triage === "accepted-risk" || f.triage === "scheduled";
@@ -2340,9 +2336,7 @@ export class HunchStore {
2340
2336
  * a task on `target`, ordered by what matters most — invariants first, then the
2341
2337
  * why, then blast radius and bug history — trimmed to a rough token budget. */
2342
2338
  assembleContext(target, budget = 1500, opts = {}) {
2343
- // An absolute target (an agent's edit-payload path, verbatim) must be
2344
- // repo-relative BEFORE it reaches why()/liveFindingsFor() below, or an
2345
- // absolute path never matches the repo-relative stored data (issue #299).
2339
+ // Normalize before it reaches why()/liveFindingsFor() below (issue #299).
2346
2340
  target = repoRelativeTarget(target, this.paths.root);
2347
2341
  const w = this.why(target, opts);
2348
2342
  const symIds = w.symbols.map((s) => s.id);
@@ -111,8 +111,9 @@ export interface SynthProvider {
111
111
  }
112
112
  /** Every selectable synthesis mode. `auto` is a preference value rather than a
113
113
  * provider: it resolves the invocation's origin without choosing by availability.
114
- * "openai-compat" is the opt-in local/self-hosted HTTP provider (Ollama, vLLM,
115
- * LM Studio, ...) — not a subscription, but explicitly selectable like one. */
114
+ * "openai-compat" is the opt-in local or self-hosted HTTP provider (Ollama, vLLM,
115
+ * LM Studio and so on). It is not a subscription, but it can be selected
116
+ * explicitly like one. */
116
117
  export declare const SYNTH_PROVIDER_NAMES: readonly ["claude-cli", "codex-cli", "cursor-agent", "kimi-cli", "openai-compat", "deterministic"];
117
118
  export declare const SYNTH_PREFERENCES: readonly ["auto", "claude-cli", "codex-cli", "cursor-agent", "kimi-cli", "openai-compat", "deterministic"];
118
119
  export type SynthProviderName = (typeof SYNTH_PROVIDER_NAMES)[number];
@@ -172,8 +173,11 @@ export declare function safeModel(v: string | undefined, fallback: undefined): s
172
173
  * the explicit skip flag preserves that neutral-cwd isolation without loading a
173
174
  * target repo's agent rules or MCP configuration. */
174
175
  export declare function codexExecArgs(model?: string): string[];
176
+ /** Sanitize HUNCH_SYNTH_TIMEOUT_MS: a finite number > 0, else `fallback`. */
175
177
  export declare function safeTimeout(v: string | undefined, fallback: number): number;
178
+ /** Sanitize HUNCH_SYNTH_MAX_TOKENS: a finite number > 0, else `fallback`. */
176
179
  export declare function safeMaxTokens(v: string | undefined, fallback: number): number;
180
+ /** True only when the user explicitly opted in to public (possibly metered) hosts. */
177
181
  export declare function meteredHostsAllowed(env?: NodeJS.ProcessEnv): boolean;
178
182
  export declare class OpenAICompatProvider extends PromptSynthProvider {
179
183
  readonly name = "openai-compat";
@@ -182,9 +186,10 @@ export declare class OpenAICompatProvider extends PromptSynthProvider {
182
186
  }
183
187
  /** Best-effort: does the configured openai-compat endpoint look like Ollama with
184
188
  * an UNSET num_ctx? Returns
185
- * an advisory warning string when so, or null when the endpoint isn't reachable,
186
- * doesn't look like Ollama's /api/show shape, or already has num_ctx set — this
187
- * is diagnostics only, never thrown, never blocking. Deliberately does NOT try to
189
+ * a warning string when the model does not pin num_ctx, or null when the
190
+ * endpoint is unreachable, does not look like Ollama's /api/show response shape,
191
+ * or already has num_ctx set. Diagnostics only: it never throws and never blocks.
192
+ * Deliberately does NOT try to
188
193
  * report the model's effective context length: modern Ollama defaults may come
189
194
  * from server configuration or VRAM tiers, and model_info keys are not a stable
190
195
  * parse target. We therefore report only the observed fact — whether num_ctx is
@@ -203,19 +208,12 @@ export declare class DeterministicProvider implements SynthProvider {
203
208
  * tagged. If nothing parses, hand the raw output to the mapper (→ it finds the JSON
204
209
  * draft or throws → deterministic fallback). Tolerant by design: drift degrades, never crashes. */
205
210
  export declare function extractCodexText(out: string): string;
206
- /** Test-only: clears the availability memoization cache so a test that toggles
207
- * env vars mid-process (e.g. HUNCH_SYNTH_BASE_URL) isn't served a stale result
208
- * cached by an earlier call in the same process. Never call from production code. */
211
+ /** Test-only: empty the availability cache so a test that changes env vars
212
+ * mid-process is not served a stale cached result. */
209
213
  export declare function __resetAvailabilityCacheForTests(): void;
210
- /** "ollama" is accepted as an alias for "openai-compat" — the provider is not
211
- * Ollama-specific (it speaks the OpenAI chat-completions format any self-hosted
212
- * server can implement), but Ollama is the most common self-hosted target and
213
- * users reach for that name first. Applied to the HUNCH_SYNTH_PROVIDER env var in
214
- * resolveSynthesisProvider below, and exported so the `hunch provider <name>` CLI
215
- * command (index.ts) normalizes it the same way before validating/persisting a
216
- * local preference — the two paths must agree, or a user who sets one and reads
217
- * the other back gets a confusing "unknown provider" message for a name that
218
- * actually works. */
214
+ /** The provider is not Ollama-specific, but Ollama is the name users try first, so
215
+ * "ollama" is an alias for "openai-compat". The env var and `hunch provider <name>`
216
+ * both normalize through here so the two paths agree. */
219
217
  export declare function normalizeProviderName(v: string | undefined): string | undefined;
220
218
  /** Read a per-user, gitignored choice. Invalid/missing local state is treated as auto;
221
219
  * `writeSynthesisPreference` refuses to overwrite malformed data so this forgiveness
@@ -258,10 +256,10 @@ export declare class EnsembleProvider implements SynthProvider {
258
256
  export declare function selectEnsemble(opts?: ProviderSelectionOptions & {
259
257
  samples?: number;
260
258
  }): Promise<EnsembleProvider | null>;
261
- /** Pick a provider to run the Critic pass — the same resolved provider normal
262
- * synthesis would use (subscription CLI or the opt-in openai-compat endpoint),
263
- * honoring the same env/local-preference/auto policy. Returns null when that
264
- * resolves to the deterministic fallback — verification then no-ops and the
259
+ /** Pick the provider for the Critic pass: the same resolved provider normal
260
+ * synthesis would use (a subscription CLI or the opt-in openai-compat endpoint),
261
+ * under the same env / local-preference / auto policy. Returns null when that
262
+ * resolves to the deterministic fallback, in which case verification does nothing. The
265
263
  * un-audited draft stands (graceful degradation; dec_18a81c8291). */
266
264
  export declare function selectVerifier(opts?: ProviderSelectionOptions): Promise<SynthProvider | null>;
267
265
  /** Map model text → RelevanceVerdict, or null when nothing usable parses (→ the