@davesheffer/hunch 1.8.2 → 1.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +96 -1
  2. package/dist/cli/index.js +1238 -396
  3. package/dist/constitution/adapters.js +31 -14
  4. package/dist/constitution/behaviorEvaluator.js +20 -7
  5. package/dist/constitution/behaviorProof.js +3 -2
  6. package/dist/constitution/canonical.js +7 -1
  7. package/dist/constitution/card.js +7 -2
  8. package/dist/constitution/compiler.js +71 -1
  9. package/dist/constitution/correctionPolicyMaterializer.js +496 -0
  10. package/dist/constitution/delta.js +3 -2
  11. package/dist/constitution/evaluator.js +29 -3
  12. package/dist/constitution/experiment.js +96 -5
  13. package/dist/constitution/experimentRunner.js +43 -14
  14. package/dist/constitution/g2BehaviorCandidates.js +49 -26
  15. package/dist/constitution/g2BehaviorDependencies.js +203 -14
  16. package/dist/constitution/g2Candidates.js +1 -1
  17. package/dist/constitution/lifecycle.js +17 -0
  18. package/dist/constitution/plan.js +26 -9
  19. package/dist/constitution/replacementFreeGit.js +67 -0
  20. package/dist/constitution/replay.js +6 -0
  21. package/dist/constitution/replayCache.js +1 -1
  22. package/dist/constitution/replayWorker.js +1 -1
  23. package/dist/constitution/repository.js +141 -5
  24. package/dist/constitution/safeCheckout.js +75 -0
  25. package/dist/constitution/schema.js +30 -5
  26. package/dist/constitution/service.js +74 -14
  27. package/dist/constitution/sourceMutation.js +65 -12
  28. package/dist/constitution/staticGraphBaseline.js +44 -0
  29. package/dist/constitution/structural.js +60 -4
  30. package/dist/core/autoreview.js +1 -1
  31. package/dist/core/canonicalOrder.js +6 -0
  32. package/dist/core/conformance.js +68 -27
  33. package/dist/core/docscan.js +2 -1
  34. package/dist/core/escalations.js +11 -0
  35. package/dist/core/io.js +44 -9
  36. package/dist/core/overlaySafety.js +178 -0
  37. package/dist/core/paths.js +13 -2
  38. package/dist/core/safeRepoFile.js +74 -0
  39. package/dist/extractors/comments.js +6 -8
  40. package/dist/extractors/git.js +1631 -82
  41. package/dist/extractors/indexer.js +86 -47
  42. package/dist/extractors/repoSource.js +390 -0
  43. package/dist/integrations/ciAction.js +10 -2
  44. package/dist/integrations/gitignore.js +44 -5
  45. package/dist/integrations/mergeDriver.js +23 -5
  46. package/dist/integrations/sync.js +61 -5
  47. package/dist/integrations/team.js +666 -23
  48. package/dist/mcp/server.js +261 -34
  49. package/dist/store/db.js +57 -7
  50. package/dist/store/hunchStore.js +92 -11
  51. package/dist/store/jsonStore.js +350 -63
  52. package/dist/store/schema.js +27 -11
  53. package/dist/synthesis/provider.js +13 -4
  54. package/dist/synthesis/synthesize.js +56 -19
  55. package/dist/wiki/graph.js +5 -4
  56. package/dist/wiki/wiki.js +16 -10
  57. package/package.json +15 -3
  58. package/tooling/competitive-watch.mjs +108 -0
  59. package/tooling/md1-benchmark.mjs +628 -0
@@ -16,22 +16,25 @@ import { decisionId } from "../core/ids.js";
16
16
  import { buildCorrectionConstraint } from "../core/correction.js";
17
17
  import { knownRepoDeps } from "../synthesis/tripwires.js";
18
18
  import { refreshExistingGrounding } from "../integrations/providers.js";
19
- import { revParse, asOfDate, revExists, lastChangeDate, rangeFiles, rangeDiff, commitFiles, commitDiff, stagedFiles, stagedDiff, workingFiles, workingDiff, pullHunch } from "../extractors/git.js";
20
- import { flushCapture } from "../integrations/sync.js";
21
- import { ensureTeamOverlay } from "../integrations/team.js";
19
+ import { revParse, asOfDate, revExists, lastChangeDate, rangeFiles, rangeDiff, commitFiles, commitDiff, stagedFiles, stagedDiff, workingFiles, workingDiff, pullHunchStatus, sameRemoteUrl } from "../extractors/git.js";
20
+ import { flushCapture, flushMemoryHome, pinSharedRemote } from "../integrations/sync.js";
21
+ import { advertisedTeamRemoteContract, ensureTeamOverlay, overlayMatchesTeamRemote, readTeamConfig, teamRemoteContract, teamSharedRef } from "../integrations/team.js";
22
22
  import { formatContext, formatStructure } from "../core/format.js";
23
23
  import { compareCandidates } from "../core/compare.js";
24
24
  import { checkConformance } from "../core/conformance.js";
25
- import { ConstitutionService } from "../constitution/service.js";
25
+ import { ConstitutionService, policyEvaluationEnvelope } from "../constitution/service.js";
26
+ import { sourceGraphSnapshot } from "../constitution/evaluator.js";
26
27
  import { G2_RUNBOOK_CATEGORIES } from "../constitution/g2.js";
27
28
  import { renderMarkdown, renderImpact, verdict } from "../core/checkreport.js";
28
29
  import { nowData, wikiStatus, publicHome, readWikiManifestAt } from "../wiki/wiki.js";
29
30
  import { HUNCH_VERSION } from "../core/version.js";
30
- import { indexRepo } from "../extractors/indexer.js";
31
+ import { assertCompleteRepoScan, indexRepo, scanRepo } from "../extractors/indexer.js";
31
32
  import { liveForTopic, historyForTopic, rejectedForTopic, captureConflicts } from "../core/topics.js";
32
33
  import { pendingEscalations, policyEscalations } from "../core/escalations.js";
33
34
  import { issueCaptureToken as issueToken, consumeCaptureToken as consumeToken } from "../core/capturetoken.js";
34
35
  import { randomUUID } from "node:crypto";
36
+ import { existsSync } from "node:fs";
37
+ import { join } from "node:path";
35
38
  const ok = (text) => ({ content: [{ type: "text", text }] });
36
39
  const err = (text) => ({ content: [{ type: "text", text }], isError: true });
37
40
  /** Honest auto-commit suffix: reports only what flushCapture ACTUALLY did. A skipped
@@ -122,24 +125,101 @@ export function buildServer(root) {
122
125
  // Team auto-discovery: a committed .hunch/team.json advertises the shared store — a
123
126
  // fresh clone (a new teammate, a headless agent, a CI workflow) wires itself BEFORE the
124
127
  // store is constructed, so every consumer resolves the same single source of truth.
125
- // Best-effort: offline / no team.json proceed exactly as before.
126
- try {
127
- ensureTeamOverlay(root);
128
+ // Once that declaration is present it is fail-closed: starting against the public
129
+ // graph after an invalid config, failed first clone, or dead pointer would let both
130
+ // reads and writes silently escape the team's memory spine.
131
+ const explicitOverlay = !!process.env.HUNCH_PRIVATE_DIR?.trim();
132
+ const teamFile = join(hunchPaths(root).hunch, "team.json");
133
+ const teamAdvertised = !explicitOverlay && existsSync(teamFile);
134
+ const startupTeamConfig = teamAdvertised ? readTeamConfig(root) : null;
135
+ if (teamAdvertised && !startupTeamConfig) {
136
+ throw new Error(".hunch/team.json is invalid or unsafe; refusing to start MCP on public memory");
128
137
  }
129
- catch { /* never block server start */ }
138
+ ensureTeamOverlay(root);
130
139
  const store = new HunchStore(hunchPaths(root));
140
+ if (teamAdvertised && (store.mode !== "shared"
141
+ || !store.privateDir
142
+ || !existsSync(store.privateDir)
143
+ || !overlayMatchesTeamRemote(root, join(store.privateDir, "..")))) {
144
+ store.close();
145
+ throw new Error("the advertised team memory store is unavailable or tracks a different remote; refusing to start MCP on another graph");
146
+ }
147
+ const startupTeamRoute = teamAdvertised && store.privateDir
148
+ ? teamRemoteContract(root, join(store.privateDir, ".."))
149
+ : null;
150
+ if (startupTeamRoute)
151
+ pinSharedRemote(store, startupTeamRoute);
152
+ const matchesStartupTeamRoute = () => {
153
+ if (!teamAdvertised || !store.privateDir || !startupTeamConfig || !startupTeamRoute)
154
+ return !teamAdvertised;
155
+ const currentTeamConfig = readTeamConfig(root);
156
+ const currentTeamRoute = teamRemoteContract(root, join(store.privateDir, ".."));
157
+ return !!currentTeamConfig && !!currentTeamRoute
158
+ && sameRemoteUrl(startupTeamConfig.shared_repo, root, currentTeamConfig.shared_repo, root)
159
+ && teamSharedRef(startupTeamConfig) === teamSharedRef(currentTeamConfig)
160
+ && startupTeamRoute.ref === currentTeamRoute.ref
161
+ && sameRemoteUrl(startupTeamRoute.fetchUrl, startupTeamRoute.urlCwd, currentTeamRoute.fetchUrl, currentTeamRoute.urlCwd)
162
+ && sameRemoteUrl(startupTeamRoute.pushUrl, startupTeamRoute.urlCwd, currentTeamRoute.pushUrl, currentTeamRoute.urlCwd);
163
+ };
131
164
  // Two-way sync (read side): pull the private overlay's remote on startup, so THIS machine's
132
165
  // session sees memory captured on other machines/worktrees before we index — making the
133
- // overlay genuinely one source of truth. Best-effort, leaves a clean tree, never blocks start.
166
+ // overlay genuinely one source of truth. Remote calls are bounded; request-time failures
167
+ // back off exponentially instead of freezing every tool on the same unavailable remote.
168
+ let nextRemotePullAt = 0;
169
+ let consecutivePullFailures = 0;
170
+ const notePull = (status, finishedAt) => {
171
+ if (status === "updated" || status === "current") {
172
+ consecutivePullFailures = 0;
173
+ nextRemotePullAt = finishedAt + 1_000;
174
+ }
175
+ else if (status === "busy") {
176
+ nextRemotePullAt = finishedAt + 100;
177
+ }
178
+ else if (status === "unconfigured") {
179
+ consecutivePullFailures = 0;
180
+ nextRemotePullAt = finishedAt + 30_000;
181
+ }
182
+ else {
183
+ consecutivePullFailures = Math.min(consecutivePullFailures + 1, 6);
184
+ nextRemotePullAt = finishedAt + Math.min(30_000, 1_000 * (2 ** (consecutivePullFailures - 1)));
185
+ }
186
+ };
187
+ const pullTeamMemory = (force = false) => {
188
+ if (!store.privateDir)
189
+ return;
190
+ const now = Date.now();
191
+ if (!force && now < nextRemotePullAt)
192
+ return;
193
+ notePull(pullHunchStatus(store.privateDir, {
194
+ timeoutMs: 5_000,
195
+ remote: startupTeamRoute ?? advertisedTeamRemoteContract(root, join(store.privateDir, "..")),
196
+ }), Date.now());
197
+ };
134
198
  if (store.privateDir) {
135
199
  try {
136
- pullHunch(store.privateDir);
200
+ pullTeamMemory(true);
137
201
  }
138
202
  catch { /* offline / no remote — proceed with local */ }
139
203
  }
204
+ // A source stamp is acknowledged ONLY after a stable, successful rebuild. If
205
+ // another process changes the atomic JSON tree during the rebuild, retry once;
206
+ // continued churn leaves the marker unset so the next request tries again.
207
+ let indexedSourceStamp;
208
+ const refreshIndex = () => {
209
+ for (let attempt = 0; attempt < 2; attempt++) {
210
+ const before = store.sourceStamp();
211
+ store.reindexFresh();
212
+ const after = store.sourceStamp();
213
+ if (before === after) {
214
+ indexedSourceStamp = after;
215
+ return;
216
+ }
217
+ }
218
+ indexedSourceStamp = undefined;
219
+ };
140
220
  // Ensure the SQLite index reflects the JSON source of truth on startup.
141
221
  try {
142
- store.reindex();
222
+ refreshIndex();
143
223
  }
144
224
  catch (e) {
145
225
  console.error("[hunch-mcp] reindex on startup failed:", e.message);
@@ -149,6 +229,53 @@ export function buildServer(root) {
149
229
  // hunch_query and stays warm — and hybridSearch degrades to FTS until then.
150
230
  const embedderReady = selectEmbedder();
151
231
  const server = new McpServer({ name: "hunch", version: HUNCH_VERSION });
232
+ const registerTool = server.registerTool.bind(server);
233
+ server.registerTool = ((name, config, callback) => registerTool(name, config, async (...args) => {
234
+ // Routing is live state, not a startup constant. A branch switch or
235
+ // `hunch shared` can add/remove team.json while this stdio process remains
236
+ // alive; serving the old store after that boundary would write the wrong
237
+ // graph. Refuse and require a reconnect instead of attempting an in-place
238
+ // HunchStore swap while requests may be active.
239
+ // The explicit process overlay intentionally outranks committed team
240
+ // discovery for this process, both at startup and at every later request.
241
+ const teamFileNow = !explicitOverlay && existsSync(teamFile);
242
+ if (teamFileNow !== teamAdvertised) {
243
+ return err("The committed team-memory routing changed after this MCP process started. Reconnect Hunch before reading or writing memory.");
244
+ }
245
+ const currentTeamConfig = teamFileNow ? readTeamConfig(root) : null;
246
+ if (teamAdvertised && !matchesStartupTeamRoute()) {
247
+ return err("The team-memory URL or branch changed after this MCP process started. Refusing the old graph; reconnect Hunch first.");
248
+ }
249
+ if (teamFileNow && (!currentTeamConfig
250
+ || store.mode !== "shared"
251
+ || !store.privateDir
252
+ || !overlayMatchesTeamRemote(root, join(store.privateDir, "..")))) {
253
+ return err("The committed team memory destination is invalid or no longer matches this process. Refusing the stale graph; reconnect Hunch first.");
254
+ }
255
+ if (store.mode === "shared" && store.privateDir) {
256
+ try {
257
+ pullTeamMemory();
258
+ }
259
+ catch { /* offline / lock held / invalid remote — use local */ }
260
+ // Recompute the full semantic + physical snapshot after the synchronous
261
+ // network seam. A paired team.json/origin change can occur while fetch is
262
+ // blocked; serving after that race would attach the old checkout to a new
263
+ // destination even though the pull itself correctly refused.
264
+ if (teamAdvertised && !matchesStartupTeamRoute()) {
265
+ return err("The team-memory route changed during refresh. Refusing to serve a stale or redirected graph; reconnect Hunch first.");
266
+ }
267
+ try {
268
+ if (store.sourceStamp() !== indexedSourceStamp)
269
+ refreshIndex();
270
+ }
271
+ catch { /* corrupt/churning local source — serve the last durable indexed view */ }
272
+ }
273
+ const result = await callback(...args);
274
+ if (teamAdvertised && !matchesStartupTeamRoute()) {
275
+ return err("The team-memory route changed while the tool was running. Its startup destination was not published; reconnect Hunch before retrying.");
276
+ }
277
+ return result;
278
+ }));
152
279
  // -- hunch_query ----------------------------------------------------------
153
280
  server.registerTool("hunch_query", {
154
281
  title: "Query Hunch",
@@ -557,7 +684,7 @@ export function buildServer(root) {
557
684
  // Auto-flush the store the record landed in (on by default in every mode): a private
558
685
  // record commits+pushes its overlay repo; a public one commits .hunch/ in THIS repo
559
686
  // (commit only — it rides the user's next push, never auto-pushing their code branch).
560
- const flush = flushCapture(store, hunchPaths(root).hunch, !!decision.private, `hunch: capture ${id}`);
687
+ const flush = flushCapture(store, hunchPaths(root).hunch, !!decision.private, `hunch: capture ${id}`, startupTeamRoute ?? undefined);
561
688
  const flushed = flushNote(flush, home, store.mode);
562
689
  // Capture-session gate (staged deprecation, §9.3): a token proves an interview
563
690
  // preceded the write. No token still writes (non-breaking), but returns a nudge
@@ -605,6 +732,10 @@ export function buildServer(root) {
605
732
  // Private corrections go to the overlay (enforced locally via the merged read,
606
733
  // never rendered into the public CI comment, which is public-only by construction).
607
734
  const home = store.captureHome(!!input.private);
735
+ if (home === "public" && rec.source_decision && !store.json.get("decisions", rec.source_decision)) {
736
+ const location = store.getPrivateRec("decisions", rec.source_decision) ? "exists only in the private overlay" : "does not exist in the public home";
737
+ return err(`Refusing to record public correction ${rec.id}: source decision ${rec.source_decision} ${location}.`);
738
+ }
608
739
  const existing = home === "private" ? store.getPrivateRec("constraints", rec.id) : store.json.get("constraints", rec.id);
609
740
  if (home === "private")
610
741
  store.putPrivate("constraints", rec);
@@ -615,9 +746,13 @@ export function buildServer(root) {
615
746
  // Windsurf/AGENTS.md/CLAUDE.md), so a correction captured in one assistant is held
616
747
  // by all of them. Public only — a private rule must never render into committed
617
748
  // grounding. Refresh-only: it never scaffolds a doc the project opted out of.
618
- if (home === "public")
749
+ // Auto-commit refreshes and stages git-clean grounding inside flushCapture.
750
+ // Pre-refreshing would make those paths dirty first, causing the clean-path
751
+ // selector to skip them and leave successful captures with stale HEAD plus
752
+ // dirty AGENTS/assistant docs. Manual mode still refreshes in place.
753
+ if (home === "public" && !store.autoCommit)
619
754
  refreshExistingGrounding(root, store); // overlay rules never render into committed grounding
620
- const flush = flushCapture(store, hunchPaths(root).hunch, !!input.private, `hunch: capture ${rec.id}`);
755
+ const flush = flushCapture(store, hunchPaths(root).hunch, !!input.private, `hunch: capture ${rec.id}`, startupTeamRoute ?? undefined);
621
756
  const flushed = flushNote(flush, home, store.mode);
622
757
  const enforce = rec.severity === "blocking"
623
758
  ? "blocks a DIRECT edit to its scope at strict firmness, and fails a PR whose diff touches that scope (CI guard); blast-radius hits and lower firmness stay advisory"
@@ -625,12 +760,80 @@ export function buildServer(root) {
625
760
  const where = input.private
626
761
  ? ` [PRIVATE overlay — not committed to this repo]${flushed}`
627
762
  : home === "private" ? ` [SHARED store — one source of truth for the whole team]${flushed}` : flushed;
628
- return ok(`${existing ? "Updated" : "Recorded"} ${rec.severity} constraint ${rec.id}: "${rec.statement}" (scope: ${rec.scope.join(", ")}).${where} It now ${enforce}.`);
763
+ // The Constraint itself is the durable retry queue. Normal `hunch index`
764
+ // and post-commit sync rescan it; no in-process timer can be lost on exit.
765
+ const reviewNote = "\n\nREVIEW PENDING: After the fix is committed, run hunch index; an installed post-commit hook retries this automatically on the fixing commit. Only the supported static ESM import-declaration package projection is eligible, and it remains activation-blocked; the immediate guard is already durable.";
766
+ return ok(`${existing ? "Updated" : "Recorded"} ${rec.severity} constraint ${rec.id}: "${rec.statement}" (scope: ${rec.scope.join(", ")}).${where} It now ${enforce}.${reviewNote}`);
629
767
  }
630
768
  catch (e) {
631
769
  return err(`Failed to record correction: ${e.message}`);
632
770
  }
633
771
  });
772
+ server.registerTool("hunch_policy_upgrade_correction", {
773
+ title: "Build a proved review proposal from one exact correction",
774
+ 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.",
775
+ inputSchema: {
776
+ constraint_id: z.string().describe("Captured correction constraint id (con_*)."),
777
+ public_only: z.boolean().optional().describe("Read and write only the public correction home."),
778
+ 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."),
779
+ 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."),
780
+ },
781
+ }, async ({ constraint_id, public_only, private_only, include_artifacts }) => {
782
+ try {
783
+ if (public_only && private_only)
784
+ return err("Choose only one of public_only or private_only.");
785
+ // Resolve the correction's exact home before any writes. Overlay-first is
786
+ // the same selection contract as ConstitutionService.upgradeCorrection;
787
+ // deriving this later from a policy id is unsafe when legacy public and
788
+ // private homes contain the same id, and `store.unified` routes captures,
789
+ // not pre-existing public records.
790
+ const artifactHome = public_only ? "public"
791
+ : private_only ? "private"
792
+ : store.getPrivateRec("constraints", constraint_id) ? "private" : "public";
793
+ // Keep parity with the CLI: publish only immutable HEAD-derived graph
794
+ // data, while allowing upgradeCorrection to classify its exact dirty
795
+ // correction scope as pending evidence.
796
+ indexRepo(store, root, { churn: false, source: { kind: "commit", ref: "HEAD" } });
797
+ store.reindex();
798
+ const service = new ConstitutionService(store, root);
799
+ const upgrade = service.upgradeCorrection(constraint_id, {
800
+ publicOnly: public_only,
801
+ privateOnly: private_only,
802
+ });
803
+ // Build commit-bound artifacts against the common source HEAD, then pump
804
+ // both actual homes before replying. Publishing the public index first
805
+ // would make a shared plan reference an architect-only memory commit that
806
+ // teammates cannot resolve. A public artifact naturally deduplicates to
807
+ // one completion commit containing both index and policy JSON.
808
+ // Public Git history is itself a public output surface. A private/team
809
+ // correction id must not leak through the message of the public derived-
810
+ // graph commit, even though its policy packet is correctly stored only in
811
+ // the overlay. Pump public first with a content-neutral message, then pump
812
+ // the exact private artifact home with its internal identifier.
813
+ flushMemoryHome(store, hunchPaths(root).hunch, "public", "hunch: refresh derived graph and correction reviews", startupTeamRoute ?? undefined);
814
+ if (artifactHome === "private") {
815
+ flushMemoryHome(store, hunchPaths(root).hunch, "private", `hunch: prove correction ${constraint_id}`, startupTeamRoute ?? undefined);
816
+ }
817
+ if (include_artifacts)
818
+ return ok(JSON.stringify(upgrade, null, 2));
819
+ return ok(JSON.stringify({
820
+ status: upgrade.status,
821
+ correction_id: upgrade.correction_id,
822
+ reason: upgrade.reason,
823
+ evidence_id: upgrade.evidence.id,
824
+ policy_id: upgrade.policy?.id ?? null,
825
+ plan_id: upgrade.plan?.id ?? null,
826
+ proof_id: upgrade.proof?.id ?? null,
827
+ review: upgrade.review,
828
+ authority: upgrade.authority,
829
+ effects: upgrade.effects,
830
+ activation: upgrade.activation,
831
+ }, null, 2));
832
+ }
833
+ catch (e) {
834
+ return err(`Failed to upgrade correction: ${e.message}`);
835
+ }
836
+ });
634
837
  // -- hunch_merge_verdict (Causal Merge Verdict — read-only, client-agnostic) --
635
838
  server.registerTool("hunch_merge_verdict", {
636
839
  title: "Causal merge verdict: is this change safe against the recorded WHY?",
@@ -786,7 +989,13 @@ export function buildServer(root) {
786
989
  },
787
990
  }, async ({ policy_id, public_only }) => {
788
991
  try {
789
- return ok(JSON.stringify(new ConstitutionService(store, root).plan(policy_id, { publicOnly: public_only }), null, 2));
992
+ const service = new ConstitutionService(store, root);
993
+ const plan = service.plan(policy_id, { publicOnly: public_only });
994
+ const home = public_only ? "public" : service.repository.homeOfPolicy(policy_id);
995
+ if (!home)
996
+ throw new Error(`policy ${policy_id} has no exact storage home`);
997
+ flushMemoryHome(store, hunchPaths(root).hunch, home, `hunch: plan policy ${policy_id}`, startupTeamRoute ?? undefined);
998
+ return ok(JSON.stringify(plan, null, 2));
790
999
  }
791
1000
  catch (e) {
792
1001
  return err(`Failed to generate policy proof plan: ${e.message}`);
@@ -848,8 +1057,8 @@ export function buildServer(root) {
848
1057
  policy_id: z.string().optional().describe("Optional policy id; omit for all policies."),
849
1058
  active_only: z.boolean().optional().describe("Evaluate only active advisory/blocking policies."),
850
1059
  public_only: z.boolean().optional().describe("Exclude private-overlay policies and graph records."),
851
- workspace: z.enum(["staged", "working"]).optional().describe("For executable-behavior policies, evaluate the staged index or complete working snapshot in a disposable checkout."),
852
- commit: z.string().optional().describe("For executable-behavior policies, evaluate an exact commit ref instead of the current committed HEAD."),
1060
+ workspace: z.enum(["staged", "working"]).optional().describe("Evaluate static and executable policies against the staged index or complete working source snapshot; omit for working."),
1061
+ commit: z.string().optional().describe("Evaluate static and executable policies at an exact commit ref."),
853
1062
  },
854
1063
  }, async ({ policy_id, active_only, public_only, workspace, commit }) => {
855
1064
  try {
@@ -857,14 +1066,23 @@ export function buildServer(root) {
857
1066
  throw new Error("choose either workspace or commit for executable-behavior evaluation");
858
1067
  if (commit && !revExists(commit, root))
859
1068
  throw new Error(`commit ref ${JSON.stringify(commit)} does not resolve`);
860
- indexRepo(store, root, { churn: false });
861
- store.reindex();
1069
+ const exactCommit = commit ? revParse(`${commit}^{commit}`, root) : undefined;
862
1070
  const behavior = workspace ? { workspace }
863
- : commit ? { commit: revParse(commit, root) }
864
- : undefined;
1071
+ : exactCommit ? { commit: exactCommit }
1072
+ : { workspace: "working" };
1073
+ // A neutral evaluation is read-only. Static and executable policy legs
1074
+ // select the same source surface, and the receipt binds raw bytes as
1075
+ // well as topology. Default to the complete working view so a long-lived
1076
+ // MCP sees new safe untracked code without persisting derived JSON.
1077
+ const semanticSource = exactCommit ? { kind: "commit", ref: exactCommit }
1078
+ : workspace === "staged" ? { kind: "staged" }
1079
+ : { kind: "working" };
1080
+ const graphScan = scanRepo(store, root, { churn: false, source: semanticSource });
1081
+ assertCompleteRepoScan(graphScan);
1082
+ const snapshot = sourceGraphSnapshot(root, graphScan.source, graphScan.symbols, graphScan.edges, graphScan.components);
865
1083
  const receipts = new ConstitutionService(store, root)
866
- .evaluate({ id: policy_id, activeOnly: active_only, publicOnly: public_only, behavior })
867
- .map((r) => r.evaluation);
1084
+ .evaluate({ id: policy_id, activeOnly: active_only, publicOnly: public_only, behavior, snapshot })
1085
+ .map(policyEvaluationEnvelope);
868
1086
  return ok(JSON.stringify(receipts, null, 2));
869
1087
  }
870
1088
  catch (e) {
@@ -949,13 +1167,20 @@ export function buildServer(root) {
949
1167
  description: "Intent-conformance (the inversion of a normal guard): for every in-force decision carrying a conformance predicate, deterministically verify the CODE still satisfies its intent over the dependency graph — e.g. 'pay still reaches verifySession'. Returns the violations: intent the code has silently drifted away from, with NO diff required. Run before a refactor or merge to catch intent erosion a diff-only check can't see.",
950
1168
  inputSchema: {},
951
1169
  }, async () => {
952
- const results = checkConformance(store);
953
- if (!results.length)
954
- return ok("No conformance predicates recorded. Add a `conformance` predicate to a decision (e.g. {assert:'calls', subject:'pay', object:'verifySession'}) to prove the code honors its intent.");
955
- const violations = results.filter((r) => !r.satisfied);
956
- const lines = results.map((r) => `${r.satisfied ? "✅" : "⛔"} ${r.decision} "${r.title}" — ${r.assert} ${r.subject}${r.object ? ` → ${r.object}` : ""}: ${r.detail}`);
957
- const head = violations.length ? `⛔ ${violations.length} intent(s) the code no longer satisfies` : "✅ the code satisfies every recorded intent";
958
- return ok(`Intent-conformance (${results.length - violations.length}/${results.length} satisfied):\n\n${lines.join("\n")}\n\n${head}`);
1170
+ try {
1171
+ const scan = scanRepo(store, root, { churn: false, source: { kind: "working" } });
1172
+ assertCompleteRepoScan(scan);
1173
+ const results = checkConformance(store, { graph: scan });
1174
+ if (!results.length)
1175
+ return ok("No conformance predicates recorded. Add a `conformance` predicate to a decision (e.g. {assert:'calls', subject:'pay', object:'verifySession'}) to prove the code honors its intent.");
1176
+ const violations = results.filter((r) => !r.satisfied);
1177
+ const lines = results.map((r) => `${r.satisfied ? "✅" : "⛔"} ${r.decision} "${r.title}" — ${r.assert} ${r.subject}${r.object ? ` → ${r.object}` : ""}: ${r.detail}`);
1178
+ const head = violations.length ? `⛔ ${violations.length} intent(s) the code no longer satisfies` : "✅ the code satisfies every recorded intent";
1179
+ return ok(`Intent-conformance (${results.length - violations.length}/${results.length} satisfied):\n\n${lines.join("\n")}\n\n${head}`);
1180
+ }
1181
+ catch (error) {
1182
+ return err(`Conformance refused an incomplete working graph: ${error.message}`);
1183
+ }
959
1184
  });
960
1185
  server.registerTool("hunch_constitution_g2_behavior_candidates", {
961
1186
  title: "Review executable G2 behavior candidates",
@@ -1040,14 +1265,16 @@ export function buildServer(root) {
1040
1265
  },
1041
1266
  }, async ({ decision_id, since, max_commits, limit, allow_install_scripts, dependency_timeout_ms }) => {
1042
1267
  try {
1043
- return ok(JSON.stringify(new ConstitutionService(store, root).g2BehaviorPolicyMaterialize({
1268
+ const materialized = new ConstitutionService(store, root).g2BehaviorPolicyMaterialize({
1044
1269
  since: since ?? "180d",
1045
1270
  maxCommits: max_commits ?? 100,
1046
1271
  limit: limit ?? 30,
1047
1272
  decisionId: decision_id,
1048
1273
  allowInstallScripts: allow_install_scripts ?? [],
1049
1274
  dependencyTimeoutMs: dependency_timeout_ms ?? 300_000,
1050
- }), null, 2));
1275
+ });
1276
+ flushMemoryHome(store, hunchPaths(root).hunch, "private", "hunch: materialize G2 behavior policies", startupTeamRoute ?? undefined);
1277
+ return ok(JSON.stringify(materialized, null, 2));
1051
1278
  }
1052
1279
  catch (e) {
1053
1280
  return err(`Failed to materialize G2 behavior policies: ${e.message}`);
package/dist/store/db.js CHANGED
@@ -1,8 +1,8 @@
1
1
  /** Thin wrapper around node:sqlite for the derived index. */
2
2
  import { createRequire } from "node:module";
3
- import { mkdirSync } from "node:fs";
3
+ import { mkdirSync, rmSync } from "node:fs";
4
4
  import { dirname } from "node:path";
5
- import { SCHEMA_SQL } from "./schema.js";
5
+ import { FTS_SEARCH_SCHEMA_SQL, PLAIN_SEARCH_SCHEMA_SQL, SCHEMA_SQL } from "./schema.js";
6
6
  /** Load node:sqlite while swallowing ONLY its ExperimentalWarning (Node 22–24 still
7
7
  * emits it on module load). Hunch's stderr reaches humans, hooks, and MCP clients on
8
8
  * every invocation, so the noise would land everywhere; all other warnings pass through. */
@@ -22,17 +22,67 @@ function loadSqlite() {
22
22
  }
23
23
  }
24
24
  const sqlite = loadSqlite();
25
- export function openDb(sqlitePath) {
26
- mkdirSync(dirname(sqlitePath), { recursive: true });
25
+ class RebuildDerivedIndex extends Error {
26
+ }
27
+ function hasFts5(db) {
28
+ try {
29
+ const row = db.prepare(`SELECT sqlite_compileoption_used('ENABLE_FTS5') AS enabled`).get();
30
+ return Number(row.enabled) === 1;
31
+ }
32
+ catch {
33
+ return false;
34
+ }
35
+ }
36
+ function searchTableKind(db) {
37
+ const row = db.prepare(`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'search'`).get();
38
+ if (!row)
39
+ return null;
40
+ return /\bVIRTUAL\s+TABLE\b/i.test(row.sql ?? "") ? "fts5" : "plain";
41
+ }
42
+ function initializeSchema(db, forcePlainSearch = false) {
43
+ db.exec(SCHEMA_SQL);
44
+ const wanted = !forcePlainSearch && hasFts5(db) ? "fts5" : "plain";
45
+ const current = searchTableKind(db);
46
+ // Moving from a portable cache back to an FTS5-capable runtime is cheap: the
47
+ // search table is derived and reindex() repopulates it immediately.
48
+ if (wanted === "fts5" && current === "plain")
49
+ db.exec("DROP TABLE search");
50
+ // SQLite cannot DROP an FTS virtual table if this runtime does not know the
51
+ // module. The entire database is derived from Git-native JSON, so openDb()
52
+ // safely rebuilds that cache instead of leaving Hunch unusable.
53
+ if (wanted === "plain" && current === "fts5")
54
+ throw new RebuildDerivedIndex();
55
+ db.exec(wanted === "fts5" ? FTS_SEARCH_SCHEMA_SQL : PLAIN_SEARCH_SCHEMA_SQL);
56
+ }
57
+ function createDb(sqlitePath) {
27
58
  const db = new sqlite.DatabaseSync(sqlitePath);
28
59
  db.exec("PRAGMA busy_timeout = 5000");
29
- db.exec(SCHEMA_SQL);
30
60
  return db;
31
61
  }
62
+ export function openDb(sqlitePath) {
63
+ mkdirSync(dirname(sqlitePath), { recursive: true });
64
+ let db = createDb(sqlitePath);
65
+ try {
66
+ initializeSchema(db);
67
+ return db;
68
+ }
69
+ catch (error) {
70
+ if (!(error instanceof RebuildDerivedIndex)) {
71
+ db.close();
72
+ throw error;
73
+ }
74
+ db.close();
75
+ for (const path of [sqlitePath, `${sqlitePath}-wal`, `${sqlitePath}-shm`])
76
+ rmSync(path, { force: true });
77
+ db = createDb(sqlitePath);
78
+ initializeSchema(db);
79
+ return db;
80
+ }
81
+ }
32
82
  /** In-memory db (tests / ephemeral queries). */
33
- export function openMemoryDb() {
83
+ export function openMemoryDb(options = {}) {
34
84
  const db = new sqlite.DatabaseSync(":memory:");
35
- db.exec(SCHEMA_SQL);
85
+ initializeSchema(db, options.forcePlainSearch);
36
86
  return db;
37
87
  }
38
88
  /** Run `fn` inside one transaction: BEGIN → fn → COMMIT, ROLLBACK on throw.