@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.
- package/README.md +96 -1
- package/dist/cli/index.js +1238 -396
- package/dist/constitution/adapters.js +31 -14
- package/dist/constitution/behaviorEvaluator.js +20 -7
- package/dist/constitution/behaviorProof.js +3 -2
- package/dist/constitution/canonical.js +7 -1
- package/dist/constitution/card.js +7 -2
- package/dist/constitution/compiler.js +71 -1
- package/dist/constitution/correctionPolicyMaterializer.js +496 -0
- package/dist/constitution/delta.js +3 -2
- package/dist/constitution/evaluator.js +29 -3
- package/dist/constitution/experiment.js +96 -5
- package/dist/constitution/experimentRunner.js +43 -14
- package/dist/constitution/g2BehaviorCandidates.js +49 -26
- package/dist/constitution/g2BehaviorDependencies.js +203 -14
- package/dist/constitution/g2Candidates.js +1 -1
- package/dist/constitution/lifecycle.js +17 -0
- package/dist/constitution/plan.js +26 -9
- package/dist/constitution/replacementFreeGit.js +67 -0
- package/dist/constitution/replay.js +6 -0
- package/dist/constitution/replayCache.js +1 -1
- package/dist/constitution/replayWorker.js +1 -1
- package/dist/constitution/repository.js +141 -5
- package/dist/constitution/safeCheckout.js +75 -0
- package/dist/constitution/schema.js +30 -5
- package/dist/constitution/service.js +74 -14
- package/dist/constitution/sourceMutation.js +65 -12
- package/dist/constitution/staticGraphBaseline.js +44 -0
- package/dist/constitution/structural.js +60 -4
- package/dist/core/autoreview.js +1 -1
- package/dist/core/canonicalOrder.js +6 -0
- package/dist/core/conformance.js +68 -27
- package/dist/core/docscan.js +2 -1
- package/dist/core/escalations.js +11 -0
- package/dist/core/io.js +44 -9
- package/dist/core/overlaySafety.js +178 -0
- package/dist/core/paths.js +13 -2
- package/dist/core/safeRepoFile.js +74 -0
- package/dist/extractors/comments.js +6 -8
- package/dist/extractors/git.js +1631 -82
- package/dist/extractors/indexer.js +86 -47
- package/dist/extractors/repoSource.js +390 -0
- package/dist/integrations/ciAction.js +10 -2
- package/dist/integrations/gitignore.js +44 -5
- package/dist/integrations/mergeDriver.js +23 -5
- package/dist/integrations/sync.js +61 -5
- package/dist/integrations/team.js +666 -23
- package/dist/mcp/server.js +261 -34
- package/dist/store/db.js +57 -7
- package/dist/store/hunchStore.js +92 -11
- package/dist/store/jsonStore.js +350 -63
- package/dist/store/schema.js +27 -11
- package/dist/synthesis/provider.js +13 -4
- package/dist/synthesis/synthesize.js +56 -19
- package/dist/wiki/graph.js +5 -4
- package/dist/wiki/wiki.js +16 -10
- package/package.json +15 -3
- package/tooling/competitive-watch.mjs +108 -0
- package/tooling/md1-benchmark.mjs +628 -0
package/dist/mcp/server.js
CHANGED
|
@@ -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,
|
|
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
|
-
//
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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("
|
|
852
|
-
commit: z.string().optional().describe("
|
|
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
|
-
|
|
861
|
-
store.reindex();
|
|
1069
|
+
const exactCommit = commit ? revParse(`${commit}^{commit}`, root) : undefined;
|
|
862
1070
|
const behavior = workspace ? { workspace }
|
|
863
|
-
:
|
|
864
|
-
:
|
|
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(
|
|
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
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
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
|
-
|
|
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
|
-
})
|
|
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
|
-
|
|
26
|
-
|
|
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.
|
|
85
|
+
initializeSchema(db, options.forcePlainSearch);
|
|
36
86
|
return db;
|
|
37
87
|
}
|
|
38
88
|
/** Run `fn` inside one transaction: BEGIN → fn → COMMIT, ROLLBACK on throw.
|