@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.
- package/dist/cli/index.js +355 -55
- package/dist/cli/integrations.js +10 -0
- package/dist/cli/serve.js +1 -0
- package/dist/client/readOrCompute.d.ts +77 -0
- package/dist/client/readOrCompute.js +85 -0
- package/dist/client/state.d.ts +1 -0
- package/dist/client/state.js +1 -0
- package/dist/constitution/g2.d.ts +1 -0
- package/dist/constitution/service.js +8 -0
- package/dist/constitution/sourceMutation.js +23 -18
- package/dist/core/agenthook.d.ts +14 -0
- package/dist/core/agenthook.js +48 -5
- package/dist/core/changeProof.js +5 -1
- package/dist/core/checkreport.d.ts +7 -0
- package/dist/core/checkreport.js +20 -3
- package/dist/core/compare.js +3 -2
- package/dist/core/config.d.ts +16 -0
- package/dist/core/config.js +13 -0
- package/dist/core/machine.d.ts +20 -0
- package/dist/core/machine.js +101 -0
- package/dist/core/taskReportEvidence.js +6 -6
- package/dist/core/types.d.ts +67 -1
- package/dist/core/types.js +3 -0
- package/dist/core/workspace.d.ts +256 -0
- package/dist/core/workspace.js +359 -0
- package/dist/extractors/diff.d.ts +34 -0
- package/dist/extractors/diff.js +147 -5
- package/dist/extractors/git.d.ts +40 -11
- package/dist/extractors/git.js +147 -43
- package/dist/extractors/helm.d.ts +17 -28
- package/dist/extractors/helm.js +12 -12
- package/dist/extractors/indexer.js +171 -7
- package/dist/extractors/k8sManifest.d.ts +59 -0
- package/dist/extractors/k8sManifest.js +507 -0
- package/dist/extractors/workspaces.d.ts +28 -0
- package/dist/extractors/workspaces.js +427 -0
- package/dist/integrations/claudemd.js +1 -0
- package/dist/integrations/gitignore.d.ts +27 -2
- package/dist/integrations/gitignore.js +103 -17
- package/dist/integrations/hooks.d.ts +63 -7
- package/dist/integrations/hooks.js +350 -38
- package/dist/integrations/scaffold.js +11 -0
- package/dist/integrations/workspaceLedger.d.ts +93 -0
- package/dist/integrations/workspaceLedger.js +307 -0
- package/dist/mcp/server.js +59 -5
- package/dist/serve/app.d.ts +2 -0
- package/dist/serve/app.js +107 -92
- package/dist/serve/mcpHttp.d.ts +27 -0
- package/dist/serve/mcpHttp.js +95 -0
- package/dist/store/hunchStore.d.ts +4 -2
- package/dist/store/hunchStore.js +23 -6
- package/dist/store/stateBinding.js +83 -35
- package/package.json +1 -1
- 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
|
|
123
|
-
|
|
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"))
|
|
175
|
-
|
|
176
|
-
|
|
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 (
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
199
|
-
|
|
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 (
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
return
|
|
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,
|
|
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
|
package/dist/store/hunchStore.js
CHANGED
|
@@ -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).
|
|
1650
|
-
//
|
|
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 ?? "",
|