@davesheffer/hunch 1.38.1 → 1.39.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 (54) hide show
  1. package/dist/cli/index.js +355 -55
  2. package/dist/cli/integrations.js +10 -0
  3. package/dist/cli/serve.js +1 -0
  4. package/dist/client/readOrCompute.d.ts +77 -0
  5. package/dist/client/readOrCompute.js +85 -0
  6. package/dist/client/state.d.ts +1 -0
  7. package/dist/client/state.js +1 -0
  8. package/dist/constitution/g2.d.ts +1 -0
  9. package/dist/constitution/service.js +8 -0
  10. package/dist/constitution/sourceMutation.js +23 -18
  11. package/dist/core/agenthook.d.ts +14 -0
  12. package/dist/core/agenthook.js +48 -5
  13. package/dist/core/changeProof.js +5 -1
  14. package/dist/core/checkreport.d.ts +7 -0
  15. package/dist/core/checkreport.js +20 -3
  16. package/dist/core/compare.js +3 -2
  17. package/dist/core/config.d.ts +16 -0
  18. package/dist/core/config.js +13 -0
  19. package/dist/core/machine.d.ts +20 -0
  20. package/dist/core/machine.js +101 -0
  21. package/dist/core/taskReportEvidence.js +6 -6
  22. package/dist/core/types.d.ts +67 -1
  23. package/dist/core/types.js +3 -0
  24. package/dist/core/workspace.d.ts +256 -0
  25. package/dist/core/workspace.js +359 -0
  26. package/dist/extractors/diff.d.ts +34 -0
  27. package/dist/extractors/diff.js +147 -5
  28. package/dist/extractors/git.d.ts +40 -11
  29. package/dist/extractors/git.js +147 -43
  30. package/dist/extractors/helm.d.ts +17 -28
  31. package/dist/extractors/helm.js +12 -12
  32. package/dist/extractors/indexer.js +171 -7
  33. package/dist/extractors/k8sManifest.d.ts +59 -0
  34. package/dist/extractors/k8sManifest.js +507 -0
  35. package/dist/extractors/workspaces.d.ts +28 -0
  36. package/dist/extractors/workspaces.js +427 -0
  37. package/dist/integrations/claudemd.js +1 -0
  38. package/dist/integrations/gitignore.d.ts +27 -2
  39. package/dist/integrations/gitignore.js +103 -17
  40. package/dist/integrations/hooks.d.ts +63 -7
  41. package/dist/integrations/hooks.js +350 -38
  42. package/dist/integrations/scaffold.js +11 -0
  43. package/dist/integrations/workspaceLedger.d.ts +93 -0
  44. package/dist/integrations/workspaceLedger.js +307 -0
  45. package/dist/mcp/server.js +59 -5
  46. package/dist/serve/app.d.ts +2 -0
  47. package/dist/serve/app.js +107 -92
  48. package/dist/serve/mcpHttp.d.ts +27 -0
  49. package/dist/serve/mcpHttp.js +95 -0
  50. package/dist/store/hunchStore.d.ts +4 -2
  51. package/dist/store/hunchStore.js +23 -6
  52. package/dist/store/stateBinding.js +83 -35
  53. package/package.json +1 -1
  54. package/server.json +2 -2
package/dist/serve/app.js CHANGED
@@ -16,6 +16,8 @@ import { STATE_PROOF_CAPABILITY } from '../core/stateProof.js';
16
16
  * POST /nuryel/v1/write → writeState (under the partition's write lock)
17
17
  * POST /nuryel/v1/subscribe → subscribeState
18
18
  * POST /nuryel/v1/records → recordsState (by id, grants first)
19
+ * POST /nuryel/v1/mcp → the same verbs as nuryel_* tools over MCP streamable HTTP
20
+ * (src/serve/mcpHttp.ts), through the same dispatcher
19
21
  * Request bodies are the contract's request schemas minus `schema` and `principal`.
20
22
  */
21
23
  import { createServer } from "node:http";
@@ -30,7 +32,9 @@ import { HUNCH_VERSION } from "../core/version.js";
30
32
  import { captureState, captureBatchState } from "../store/stateCapture.js";
31
33
  import { STATE_CAPTURE_VERSION, STATE_CAPTURE_BATCH_VERSION } from "../core/stateContract.js";
32
34
  import { operatorHtml, operatorCss, operatorJs } from "./operator.js";
35
+ import { MCP_PATH, handleMcpRequest } from "./mcpHttp.js";
33
36
  export const BODY_LIMIT_BYTES = 1024 * 1024;
37
+ const POST_ROUTES = new Map([["/nuryel/v1/read", "read"], ["/nuryel/v1/write", "write"], ["/nuryel/v1/capture", "capture"], ["/nuryel/v1/capture-batch", "capture-batch"], ["/nuryel/v1/subscribe", "subscribe"], ["/nuryel/v1/records", "records"]]);
34
38
  export const PROBLEM_TYPE = "https://www.hunchmemory.com/problems/nuryel.state/1/";
35
39
  export class HttpProblem extends Error {
36
40
  status;
@@ -119,8 +123,87 @@ export function createServeApp(config, opts = {}) {
119
123
  res.writeHead(status, { "content-type": `${type}; charset=utf-8`, "content-length": Buffer.byteLength(text), "cache-control": "no-store", "x-hunch-version": version });
120
124
  res.end(text);
121
125
  };
122
- const sendProblem = (res, p) => {
123
- send(res, p.status, { type: `${PROBLEM_TYPE}${p.code}`, title: p.code, status: p.status, detail: p.message, ...p.extra }, "application/problem+json");
126
+ const problemBody = (p) => ({ type: `${PROBLEM_TYPE}${p.code}`, title: p.code, status: p.status, detail: p.message, ...p.extra });
127
+ const sendProblem = (res, p) => { send(res, p.status, problemBody(p), "application/problem+json"); };
128
+ /** Anything thrown → problem. Shared by REST (problem+json) and MCP (tool error results). */
129
+ const problemOf = (error) => {
130
+ if (error instanceof HttpProblem)
131
+ return error;
132
+ if (error instanceof StateRefusal)
133
+ return problem(REFUSAL_STATUS[error.code], error.code, error.message, error.conflict ? { conflict: error.conflict } : {});
134
+ if (error instanceof WriteLockTimeout)
135
+ return problem(503, "write-lock-timeout", error.message, { "retry-after": 1 });
136
+ if (error && typeof error === "object" && error.name === "ZodError") {
137
+ const issues = (error.issues ?? []).map((i) => `${i.path.join(".") || "request"}: ${i.message}`);
138
+ return problem(400, "malformed", `request is malformed: ${issues.join("; ")}`, { issues });
139
+ }
140
+ return problem(500, "internal", error.message);
141
+ };
142
+ /** One authenticated state verb. The REST routes and the MCP tools both call this, so every
143
+ * rule — grants, the write lock, flushes, refusals — is the same whichever transport carried it. */
144
+ const dispatch = async (route, principal, body, activeConfig) => {
145
+ const requestStore = (scope) => storeFor(scope, activeConfig);
146
+ const isGranted = (s) => principal.grants.some((g) => scopePath(g) === scopePath(s));
147
+ if (route === "capabilities") {
148
+ let scope = principal.grants[0];
149
+ if (body.scope !== undefined) {
150
+ const parsed = ScopeSchema.safeParse(body.scope);
151
+ if (!parsed.success)
152
+ throw problem(400, "invalid-scope", "scope must be { kind, id }");
153
+ scope = parsed.data;
154
+ }
155
+ if (!isGranted(scope))
156
+ throw problem(403, "outside-grants", `scope ${scopePath(scope)} is outside the principal's grants`);
157
+ const offered = capabilities(requestStore(scope).store);
158
+ return { status: 200, payload: { ...offered, capabilities: [...offered.capabilities, STATE_PROOF_CAPABILITY], principal: { id: principal.id, kind: principal.kind, grants: principal.grants } } };
159
+ }
160
+ // The body never names the principal: the credential did.
161
+ delete body.principal;
162
+ delete body.schema;
163
+ const scope = requireScope(principal, body);
164
+ // Only authenticated grants select stores for cross-partition source visibility.
165
+ const accessOptions = { additionalStores: principal.grants.map(grant => requestStore(grant).store) };
166
+ const { store, root } = requestStore(scope);
167
+ const flush = (isPrivate, message) => flushCapture(store, hunchPaths(root).hunch, isPrivate, message);
168
+ if (route === "read") {
169
+ if (body.observed_page !== undefined && body.scopes !== undefined)
170
+ throw problem(400, 'malformed', 'observation pages require a single partition without scopes');
171
+ if (body.scopes === undefined) {
172
+ const { response, envelope } = readState(store, { schema: STATE_READ_VERSION, principal, ...body }, accessOptions);
173
+ return { status: 200, payload: { ...response, envelope } };
174
+ }
175
+ // Union read. The primary `scope` was gated above as always; every extra scope is
176
+ // either granted (read from ITS partition — 404 no-partition if this server lacks it)
177
+ // or named in denied_scopes. One ungranted extra never refuses the whole call.
178
+ const requested = ReadScopesSchema.safeParse(body.scopes);
179
+ if (!requested.success)
180
+ throw problem(400, "invalid-scope", "scopes must be 1..64 entries of { kind, id }");
181
+ const { scopes: _scopes, ...rest } = body;
182
+ const ungranted = requested.data.filter((s) => !isGranted(s));
183
+ const others = new Map();
184
+ for (const s of requested.data)
185
+ if (isGranted(s) && scopePath(s) !== scopePath(scope) && !others.has(scopePath(s)))
186
+ others.set(scopePath(s), s);
187
+ const primary = readState(store, { schema: STATE_READ_VERSION, principal, ...rest, scope }, accessOptions);
188
+ const merged = mergeReadResponses(primary.response, [...others.values()].map((other) => readState(requestStore(other).store, { schema: STATE_READ_VERSION, principal, ...rest, scope: other }, accessOptions).response), ungranted);
189
+ return { status: 200, payload: { ...merged, envelope: primary.envelope } };
190
+ }
191
+ if (route === "write" || route === "capture" || route === "capture-batch") {
192
+ const { hunchDir } = stateHomeFor(store, scope);
193
+ const opts = { ...accessOptions, flush };
194
+ if (route === "write") {
195
+ const result = await withWriteLock(hunchDir, () => writeState(store, { schema: STATE_WRITE_VERSION, principal, ...body }, opts));
196
+ return { status: result.outcome === "created" ? 201 : 200, payload: result };
197
+ }
198
+ if (route === "capture") {
199
+ const result = await withWriteLock(hunchDir, () => captureState(store, { schema: STATE_CAPTURE_VERSION, principal, ...body }, opts));
200
+ return { status: result.outcome === "created" ? 201 : 200, payload: result };
201
+ }
202
+ return { status: 200, payload: await withWriteLock(hunchDir, () => captureBatchState(store, { schema: STATE_CAPTURE_BATCH_VERSION, principal, ...body }, opts)) };
203
+ }
204
+ if (route === "subscribe")
205
+ return { status: 200, payload: subscribeState(store, { schema: STATE_SUBSCRIBE_VERSION, principal, ...body }, accessOptions) };
206
+ return { status: 200, payload: recordsState(store, { schema: STATE_RECORDS_VERSION, principal, ...body }, accessOptions) };
124
207
  };
125
208
  const server = createServer(async (req, res) => {
126
209
  try {
@@ -131,7 +214,6 @@ export function createServeApp(config, opts = {}) {
131
214
  catch {
132
215
  throw problem(503, 'configuration-unavailable', 'server configuration is unavailable; authentication is refused');
133
216
  }
134
- const requestStore = (scope) => storeFor(scope, activeConfig);
135
217
  const url = new URL(req.url ?? "/", "http://127.0.0.1");
136
218
  // Public shell only: all workspace data still uses the authenticated state routes below.
137
219
  const asset = new Map([["/operator", [operatorHtml, "text/html"]], ["/operator/", [operatorHtml, "text/html"]], ["/operator.css", [operatorCss, "text/css"]], ["/operator.js", [operatorJs, "text/javascript"]]]).get(url.pathname);
@@ -171,101 +253,34 @@ export function createServeApp(config, opts = {}) {
171
253
  throw problem(401, 'invalid_dpop_proof', 'credential is not bound to a proof key');
172
254
  const principal = { id: credential.id, kind: credential.kind, grants: credential.grants, ...(credential.display ? { display: credential.display } : {}) };
173
255
  if (url.pathname === "/nuryel/v1/capabilities" && req.method === "GET") {
174
- const scope = parseScopeParam(url.searchParams.get("scope")) ?? principal.grants[0];
175
- if (!principal.grants.some((g) => scopePath(g) === scopePath(scope)))
176
- throw problem(403, "outside-grants", `scope ${scopePath(scope)} is outside the principal's grants`);
177
- const { store } = requestStore(scope);
178
- const offered = capabilities(store);
179
- return send(res, 200, { ...offered, capabilities: [...offered.capabilities, STATE_PROOF_CAPABILITY], principal: { id: principal.id, kind: principal.kind, grants: principal.grants } });
256
+ const scope = parseScopeParam(url.searchParams.get("scope"));
257
+ const { status, payload } = await dispatch("capabilities", principal, scope ? { scope } : {}, activeConfig);
258
+ return send(res, status, payload);
180
259
  }
181
- if (req.method !== "POST")
182
- throw problem(405, "method-not-allowed", `${req.method} is not allowed on ${url.pathname}`);
183
- const body = await readBody(req);
184
- // The body never names the principal: the token did.
185
- delete body.principal;
186
- delete body.schema;
187
- // Only authenticated grants select stores for cross-partition source visibility.
188
- const accessOptions = { additionalStores: principal.grants.map(grant => requestStore(grant).store) };
189
- if (url.pathname === "/nuryel/v1/read") {
190
- const scope = requireScope(principal, body);
191
- if (body.observed_page !== undefined && body.scopes !== undefined)
192
- throw problem(400, 'malformed', 'observation pages require a single partition without scopes');
193
- const { store } = requestStore(scope);
194
- if (body.scopes === undefined) {
195
- const { response, envelope } = readState(store, { schema: STATE_READ_VERSION, principal, ...body }, accessOptions);
196
- return send(res, 200, { ...response, envelope });
260
+ if (url.pathname === MCP_PATH) {
261
+ // Stateless MCP: no server-initiated stream to open (GET) and no session to end (DELETE).
262
+ if (req.method !== "POST") {
263
+ res.setHeader("allow", "POST");
264
+ throw problem(405, "method-not-allowed", `${req.method} is not allowed on ${url.pathname}; MCP here is stateless POST`);
197
265
  }
198
- // Union read. The primary `scope` was gated above as always; every extra scope is
199
- // either granted (read from ITS partition — 404 no-partition if this server lacks it)
200
- // or named in denied_scopes. One ungranted extra never refuses the whole call.
201
- const requested = ReadScopesSchema.safeParse(body.scopes);
202
- if (!requested.success)
203
- throw problem(400, "invalid-scope", "scopes must be 1..64 entries of { kind, id }");
204
- const { scopes: _scopes, ...rest } = body;
205
- const isGranted = (s) => principal.grants.some((g) => scopePath(g) === scopePath(s));
206
- const ungranted = requested.data.filter((s) => !isGranted(s));
207
- const others = new Map();
208
- for (const s of requested.data)
209
- if (isGranted(s) && scopePath(s) !== scopePath(scope) && !others.has(scopePath(s)))
210
- others.set(scopePath(s), s);
211
- const primary = readState(store, { schema: STATE_READ_VERSION, principal, ...rest, scope }, accessOptions);
212
- const merged = mergeReadResponses(primary.response, [...others.values()].map((other) => readState(requestStore(other).store, { schema: STATE_READ_VERSION, principal, ...rest, scope: other }, accessOptions).response), ungranted);
213
- return send(res, 200, { ...merged, envelope: primary.envelope });
214
- }
215
- if (url.pathname === "/nuryel/v1/write") {
216
- const scope = requireScope(principal, body);
217
- const { store, root } = requestStore(scope);
218
- const { hunchDir } = stateHomeFor(store, scope);
219
- const result = await withWriteLock(hunchDir, () => writeState(store, { schema: STATE_WRITE_VERSION, principal, ...body }, {
220
- ...accessOptions,
221
- flush: (isPrivate, message) => flushCapture(store, hunchPaths(root).hunch, isPrivate, message),
222
- }));
223
- return send(res, result.outcome === "created" ? 201 : 200, result);
224
- }
225
- if (url.pathname === "/nuryel/v1/capture") {
226
- const scope = requireScope(principal, body);
227
- const { store, root } = requestStore(scope);
228
- const { hunchDir } = stateHomeFor(store, scope);
229
- const result = await withWriteLock(hunchDir, () => captureState(store, { schema: STATE_CAPTURE_VERSION, principal, ...body }, {
230
- ...accessOptions,
231
- flush: (isPrivate, message) => flushCapture(store, hunchPaths(root).hunch, isPrivate, message),
232
- }));
233
- return send(res, result.outcome === "created" ? 201 : 200, result);
234
- }
235
- if (url.pathname === "/nuryel/v1/capture-batch") {
236
- const scope = requireScope(principal, body);
237
- const { store, root } = requestStore(scope);
238
- const { hunchDir } = stateHomeFor(store, scope);
239
- const result = await withWriteLock(hunchDir, () => captureBatchState(store, { schema: STATE_CAPTURE_BATCH_VERSION, principal, ...body }, {
240
- ...accessOptions,
241
- flush: (isPrivate, message) => flushCapture(store, hunchPaths(root).hunch, isPrivate, message),
242
- }));
243
- return send(res, 200, result);
266
+ const message = await readBody(req);
267
+ return await handleMcpRequest(req, res, message, (route, body) => dispatch(route, principal, body, activeConfig), (error) => problemBody(problemOf(error)), version);
244
268
  }
245
- if (url.pathname === "/nuryel/v1/subscribe") {
246
- const scope = requireScope(principal, body);
247
- const { store } = requestStore(scope);
248
- return send(res, 200, subscribeState(store, { schema: STATE_SUBSCRIBE_VERSION, principal, ...body }, accessOptions));
249
- }
250
- if (url.pathname === "/nuryel/v1/records") {
251
- const scope = requireScope(principal, body);
252
- const { store } = requestStore(scope);
253
- return send(res, 200, recordsState(store, { schema: STATE_RECORDS_VERSION, principal, ...body }, accessOptions));
254
- }
255
- throw problem(404, "not-found", `${url.pathname} is not a nuryel.state/1 route`);
269
+ if (req.method !== "POST")
270
+ throw problem(405, "method-not-allowed", `${req.method} is not allowed on ${url.pathname}`);
271
+ const route = POST_ROUTES.get(url.pathname);
272
+ if (!route)
273
+ throw problem(404, "not-found", `${url.pathname} is not a nuryel.state/1 route`);
274
+ const { status, payload } = await dispatch(route, principal, await readBody(req), activeConfig);
275
+ return send(res, status, payload);
256
276
  }
257
277
  catch (error) {
258
- if (error instanceof HttpProblem)
259
- return sendProblem(res, error);
260
- if (error instanceof StateRefusal)
261
- return sendProblem(res, problem(REFUSAL_STATUS[error.code], error.code, error.message, error.conflict ? { conflict: error.conflict } : {}));
262
- if (error instanceof WriteLockTimeout)
263
- return sendProblem(res, problem(503, "write-lock-timeout", error.message, { "retry-after": 1 }));
264
- if (error && typeof error === "object" && error.name === "ZodError") {
265
- const issues = (error.issues ?? []).map((i) => `${i.path.join(".") || "request"}: ${i.message}`);
266
- return sendProblem(res, problem(400, "malformed", `request is malformed: ${issues.join("; ")}`, { issues }));
278
+ // An MCP response may already be on the wire; a second status line would corrupt it.
279
+ if (res.headersSent) {
280
+ res.end();
281
+ return;
267
282
  }
268
- return sendProblem(res, problem(500, "internal", error.message));
283
+ return sendProblem(res, problemOf(error));
269
284
  }
270
285
  });
271
286
  const closeStores = () => { for (const store of stores.values())
@@ -0,0 +1,27 @@
1
+ /**
2
+ * MCP over streamable HTTP for `hunch serve` — the nuryel.state/1 tools behind the same
3
+ * authenticated server, so an HTTP MCP client (an agent gateway, a remote orchestrator) can
4
+ * add the state layer as a tool target without a stdio process or a wrapper.
5
+ *
6
+ * A binding of the served routes, never a second implementation: every tool call goes through
7
+ * the dispatcher `createServeApp` uses for REST, so grants, the write lock, flushes and refusals
8
+ * are identical. The credential already resolved the principal before this runs; no tool
9
+ * accepts one. Stateless (a fresh server and transport per request, no session id) with JSON
10
+ * responses, because every verb is a single request/response.
11
+ */
12
+ import type { IncomingMessage, ServerResponse } from "node:http";
13
+ export declare const MCP_PATH = "/nuryel/v1/mcp";
14
+ export type StateRoute = "capabilities" | "read" | "write" | "capture" | "capture-batch" | "subscribe" | "records";
15
+ export type StateDispatch = (route: StateRoute, body: Record<string, unknown>) => Promise<{
16
+ status: number;
17
+ payload: unknown;
18
+ }>;
19
+ export interface ProblemShape {
20
+ type: string;
21
+ title: string;
22
+ status: number;
23
+ detail: string;
24
+ [extra: string]: unknown;
25
+ }
26
+ /** Serve one authenticated MCP POST. The caller has already authenticated and parsed the body. */
27
+ export declare function handleMcpRequest(req: IncomingMessage, res: ServerResponse, body: unknown, dispatch: StateDispatch, problemOf: (error: unknown) => ProblemShape, version: string): Promise<void>;
@@ -0,0 +1,95 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ // The web-standard transport, not the Node one: the Node wrapper imports @hono/node-server,
3
+ // which the production dependency audit keeps unreachable (tooling/production-dependency-audit.mjs).
4
+ // Bridging IncomingMessage → Request → ServerResponse here is a few lines and keeps that boundary.
5
+ import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
6
+ import { CaptureBatchRequestSchema, CaptureRequestSchema, ReadRequestSchema, RecordsRequestSchema, ScopeSchema, SubscribeRequestSchema, WriteRequestSchema } from "../core/stateContract.js";
7
+ export const MCP_PATH = "/nuryel/v1/mcp";
8
+ const TOOLS = [
9
+ {
10
+ name: "nuryel_capabilities", route: "capabilities",
11
+ title: "nuryel.state/1 — what this state server supports",
12
+ description: "Negotiate before depending on anything: the contract version, capability list (verbs + record schemas) and your principal's grants. Optional scope {kind, id} selects a granted partition; default is your first grant.",
13
+ input: { scope: ScopeSchema.optional() },
14
+ },
15
+ {
16
+ name: "nuryel_read", route: "read",
17
+ title: "nuryel.state/1 read — the system-of-record answer for a subject",
18
+ description: "Read current state for a scope under a delivery receipt. Pass a subject (an entity id, a decision topic, or an external object_type:object_key such as transaction:TX-1) to get state_of_record — what is current, in force, done, what it depends on and what invalidates it — with the referenced records. Read before redoing work: if a current record already answers the subject and nothing it depends on changed, reuse it. Your identity comes from your credential; scopes outside your grants are named in denied_scopes.",
19
+ input: ReadRequestSchema.omit({ schema: true, principal: true }).shape,
20
+ },
21
+ {
22
+ name: "nuryel_write", route: "write",
23
+ title: "nuryel.state/1 write — provenance + idempotency in, durability out",
24
+ description: "Write one record into a facet (receipts, commitments, derived, entities, relationships, decisions, constraints, bugs, findings). The record must carry provenance and the request an idempotency_key: replaying the same request returns the original; the same key with a different payload is refused and the refusal names what differs. Ids are derived from the record's facts. A second live decision on a topic is refused with the incumbent named; pass supersedes to replace it explicitly. The result reports durability (local, committed or pushed).",
25
+ input: WriteRequestSchema.omit({ schema: true, principal: true }).shape,
26
+ },
27
+ {
28
+ name: "nuryel_capture", route: "capture",
29
+ title: "nuryel.state/1 capture — one relevant assertion with exact source excerpts",
30
+ description: "Save ONE relevant atomic assertion learned during the task, with a concrete future-use reason and exact excerpts from the source text. Whole source text is never stored. Saved observations are not verified current summaries or receipts.",
31
+ input: CaptureRequestSchema.omit({ schema: true, principal: true }).shape,
32
+ },
33
+ {
34
+ name: "nuryel_capture_batch", route: "capture-batch",
35
+ title: "nuryel.state/1 capture batch — save relevant atomic observations",
36
+ description: "Save up to 32 independent assertions from up to 8 sources in one call, each with exact supporting excerpts and a future-use reason. Results preserve input indexes; inspect every refusal.",
37
+ input: CaptureBatchRequestSchema.omit({ schema: true, principal: true }).shape,
38
+ },
39
+ {
40
+ name: "nuryel_subscribe", route: "subscribe",
41
+ title: "nuryel.state/1 subscribe — the scope's ordered change stream after a cursor",
42
+ description: "Return change events for a scope with seq > after_seq, strictly ordered. Keep head_seq as your next cursor; if resync is true, rebuild held state with reads.",
43
+ input: SubscribeRequestSchema.omit({ schema: true, principal: true }).shape,
44
+ },
45
+ {
46
+ name: "nuryel_records", route: "records",
47
+ title: "nuryel.state/1 records — fetch records by id, grants first",
48
+ description: "Fetch state records by id (from a subscribe event, a read ref or a write result). Every id is accounted for: found, denied (outside your grants) or missing.",
49
+ input: RecordsRequestSchema.omit({ schema: true, principal: true }).shape,
50
+ },
51
+ ];
52
+ function buildServer(dispatch, problemOf, version) {
53
+ const server = new McpServer({ name: "hunch-serve", version });
54
+ for (const tool of TOOLS) {
55
+ server.registerTool(tool.name, { title: tool.title, description: tool.description, inputSchema: tool.input }, async (input) => {
56
+ try {
57
+ const { payload } = await dispatch(tool.route, { ...input });
58
+ return { content: [{ type: "text", text: JSON.stringify(payload) }], structuredContent: payload };
59
+ }
60
+ catch (error) {
61
+ const p = problemOf(error);
62
+ return { isError: true, content: [{ type: "text", text: `nuryel.state/1 refused [${p.title}] (${p.status}): ${p.detail}` }], structuredContent: p };
63
+ }
64
+ });
65
+ }
66
+ return server;
67
+ }
68
+ /** Serve one authenticated MCP POST. The caller has already authenticated and parsed the body. */
69
+ export async function handleMcpRequest(req, res, body, dispatch, problemOf, version) {
70
+ const server = buildServer(dispatch, problemOf, version);
71
+ const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true });
72
+ try {
73
+ await server.connect(transport);
74
+ // Only the headers the transport negotiates on travel; the body was already read and bounded.
75
+ const headers = new Headers();
76
+ for (const name of ["accept", "content-type", "mcp-protocol-version", "mcp-session-id", "last-event-id"]) {
77
+ const value = req.headers[name];
78
+ if (typeof value === "string")
79
+ headers.set(name, value);
80
+ }
81
+ const request = new Request(new URL(req.url ?? MCP_PATH, "http://127.0.0.1"), { method: "POST", headers, body: JSON.stringify(body) });
82
+ const response = await transport.handleRequest(request, { parsedBody: body });
83
+ const text = await response.text();
84
+ const out = { "cache-control": "no-store", "x-hunch-version": version, "content-length": String(Buffer.byteLength(text)) };
85
+ response.headers.forEach((value, name) => { if (name !== "content-length")
86
+ out[name] = value; });
87
+ res.writeHead(response.status, out);
88
+ res.end(text);
89
+ }
90
+ finally {
91
+ await transport.close();
92
+ await server.close();
93
+ }
94
+ }
95
+ //# sourceMappingURL=mcpHttp.js.map
@@ -5,7 +5,7 @@ import { type Embedder } from "./embedder.js";
5
5
  import { JsonStore } from "./jsonStore.js";
6
6
  import { type RankingContext, type RankingQuery, type RankingWeights, type SlotOptions, type TaskSelection } from "../core/taskRanking.js";
7
7
  import { type VetoTier } from "../core/strictgate.js";
8
- import { type DiffAnalysis } from "../extractors/diff.js";
8
+ import { type DiffAnalysis, type DiffStatus } from "../extractors/diff.js";
9
9
  import type { CheckReport, CausalWhy, ImpactReport } from "../core/checkreport.js";
10
10
  import { type ReviewedLandscapeSelection } from "../core/landscapeDelivery.js";
11
11
  import { type StateSlice } from "../core/stateDelivery.js";
@@ -384,7 +384,7 @@ export declare class HunchStore {
384
384
  /** PR impact (read-only, ADVISORY — never gates): the dependency + memory surface
385
385
  * of a change. Composes the SAME primitives as buildCheckReport (blast radius,
386
386
  * scope-matched constraints, why) so impact and gating can never disagree. */
387
- prImpact(files: string[], diff: string): ImpactReport;
387
+ prImpact(files: string[], diff: string, diffStatus?: DiffStatus): ImpactReport;
388
388
  /** Structure view (hunch_structure / hunch structure): serve the indexed shape of
389
389
  * the repo so an agent ORIENTS from the graph instead of running grep/glob rounds.
390
390
  * Resolution: no target -> repo map; a directory -> its files+symbols; a file ->
@@ -446,6 +446,8 @@ export declare class HunchStore {
446
446
  strict: boolean;
447
447
  lastChange?: (f: string) => string;
448
448
  publicOnly?: boolean;
449
+ /** Completeness of `diff` as produced for a gate (a GateDiff fits). */
450
+ diffStatus?: DiffStatus;
449
451
  }): CheckReport;
450
452
  /** Sprawl/"this already exists" guard (ADVISORY, never blocks). A symbol the diff
451
453
  * ADDS whose name already exists in the indexed graph in a file NOT touched by the
@@ -28,7 +28,7 @@ import { currentForTopic, isInForce } from "../core/topics.js";
28
28
  import { edgeId } from "../core/ids.js";
29
29
  import { isStrictBlocker, isVetoBlocker } from "../core/strictgate.js";
30
30
  import { effectiveForbids, matchForbids } from "../core/constraintmatch.js";
31
- import { analyzeDiff } from "../extractors/diff.js";
31
+ import { analyzeDiff, diffContentGaps } from "../extractors/diff.js";
32
32
  import { selectReviewedLandscape, } from "../core/landscapeDelivery.js";
33
33
  import { STATE_KINDS, STATE_SLICE_CAPS, compareStateHits, isStateKind, stateLiveness, stateObservedAt, stateSearchDoc, stateSubject, } from "../core/stateDelivery.js";
34
34
  /** Git cannot resolve repository identity from a cwd that does not exist yet.
@@ -1332,7 +1332,7 @@ export class HunchStore {
1332
1332
  /** PR impact (read-only, ADVISORY — never gates): the dependency + memory surface
1333
1333
  * of a change. Composes the SAME primitives as buildCheckReport (blast radius,
1334
1334
  * scope-matched constraints, why) so impact and gating can never disagree. */
1335
- prImpact(files, diff) {
1335
+ prImpact(files, diff, diffStatus) {
1336
1336
  const changed = new Set(files.map(toPosixTarget));
1337
1337
  const blast = new Map();
1338
1338
  for (const f of changed) {
@@ -1344,7 +1344,7 @@ export class HunchStore {
1344
1344
  blast.set(b.file, b);
1345
1345
  }
1346
1346
  }
1347
- const report = this.buildCheckReport([...changed], diff, { strict: false });
1347
+ const report = this.buildCheckReport([...changed], diff, { strict: false, diffStatus });
1348
1348
  const decisions = new Map();
1349
1349
  for (const f of changed) {
1350
1350
  for (const d of this.why(f).decisions)
@@ -1639,6 +1639,7 @@ export class HunchStore {
1639
1639
  });
1640
1640
  const directReport = [];
1641
1641
  const addedDepSet = new Set(an.addedDeps);
1642
+ const missingContent = diffContentGaps(diff, opts.diffStatus);
1642
1643
  for (const { c, files: fs } of direct.values()) {
1643
1644
  const forbids = effectiveForbids(c);
1644
1645
  if (forbids) {
@@ -1646,11 +1647,27 @@ export class HunchStore {
1646
1647
  // dep imported / symbol added / pattern matched in scoped code) — not by bare
1647
1648
  // scope-touch. A commit that touches the scope but doesn't trip it COMPLIES → drop it
1648
1649
  // (no noise). A real hit blocks WITHOUT the staleness gate: content is verified per
1649
- // commit, so file churn can't retract the teeth (dec_e0a36efbf5). Empty diff ⇒ can't
1650
- // prove a violation ⇒ treat as clean.
1650
+ // commit, so file churn can't retract the teeth (dec_e0a36efbf5). A COMPLETE diff with
1651
+ // no added lines for a file proves nothing was added there ⇒ clean.
1651
1652
  const scopedAdded = fs.flatMap((f) => an.addedLinesByFile.get(f) ?? []);
1652
- if (!matchForbids(forbids, addedDepSet, scopedAdded))
1653
+ if (!matchForbids(forbids, addedDepSet, scopedAdded)) {
1654
+ // …but when the diff is truncated, git could not produce it, or a file's content
1655
+ // could not be read, "no added lines" is not evidence of compliance. A blocking
1656
+ // rule over such a file is UNEVALUABLE and fails closed under the same strict gate
1657
+ // a proven hit would face (dec_20db57c576). Advisory/warning rules stay quiet.
1658
+ const unseen = missingContent ? fs.filter((f) => missingContent.missing(f)) : [];
1659
+ if (c.severity !== "blocking" || !unseen.length)
1660
+ continue;
1661
+ const strictBlocks = isStrictBlocker(c, false);
1662
+ directReport.push({
1663
+ id: c.id, severity: c.severity, statement: c.statement, rationale: c.rationale ?? "",
1664
+ files: fs, strictBlocks,
1665
+ downgrade: strictBlocks ? undefined : "low-confidence",
1666
+ unevaluable: { reason: missingContent.reason(unseen), files: unseen },
1667
+ why: this.causalChain(c.id),
1668
+ });
1653
1669
  continue;
1670
+ }
1654
1671
  const strictBlocks = isStrictBlocker(c, false);
1655
1672
  directReport.push({
1656
1673
  id: c.id, severity: c.severity ?? "advisory", statement: c.statement, rationale: c.rationale ?? "",