@davesheffer/hunch 1.25.0 → 1.26.0

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 CHANGED
@@ -195,6 +195,14 @@ are homed in an overlay, never in a repository. The MCP server binds it as `nury
195
195
  `nuryel_read`, `nuryel_write` and `nuryel_subscribe`; every other transport will call the same
196
196
  store binding. Contract and evidence: [docs/nuryel-state-contract.md](docs/nuryel-state-contract.md).
197
197
 
198
+ As of 1.26.0 the state layer is also **served**: `hunch serve --config <file>` hosts organization,
199
+ team, user and repository partitions over HTTP on loopback with the same three verbs. A served
200
+ partition is a directory whose `.hunch/partition.json` names the scope it is; the bearer token
201
+ resolves the principal and grants come from the config only. `hunch serve init --partition
202
+ user:david --root <dir> --principal sofia@david` declares a partition and mints a token. The typed
203
+ client is `import { createStateClient } from "@davesheffer/hunch/state"`. This folds the separate
204
+ Hunch Memory service into Hunch.
205
+
198
206
  Read [Deterministic organizational state](docs/deterministic-state.md) and the [roadmap](ROADMAP.md).
199
207
 
200
208
  ### Naming
package/dist/cli/index.js CHANGED
@@ -25,6 +25,7 @@ import { writeFileAtomic } from "../core/io.js";
25
25
  import { looksLikeCorrection, CORRECTION_NUDGE } from "../core/correction.js";
26
26
  import { HUNCH_VERSION } from "../core/version.js";
27
27
  import { registerIntegrationCommands } from "./integrations.js";
28
+ import { registerServeCommands } from "./serve.js";
28
29
  import { registerUpdateCommand } from "./update.js";
29
30
  import { inspectIntegrations, formatIntegrationHealth, integrationHealthFails, integrationSessionWarning } from "../integrations/health.js";
30
31
  import { HunchStore } from "../store/hunchStore.js";
@@ -113,6 +114,7 @@ import { resolveInvocation, dim, synthesisStatusLines, maybeWarnOllamaContext }
113
114
  const program = new Command();
114
115
  program.name("hunch").description("Hunch — engineering memory and a deterministic Change Gate for AI-assisted codebases.").version(HUNCH_VERSION);
115
116
  registerIntegrationCommands(program);
117
+ registerServeCommands(program);
116
118
  registerUpdateCommand(program);
117
119
  let openStore = null;
118
120
  function openTeamStore(root, opts = {}) {
@@ -0,0 +1,64 @@
1
+ import { resolve } from "node:path";
2
+ import { createServeApp } from "../serve/app.js";
3
+ import { initServeConfig, readServeConfig } from "../serve/config.js";
4
+ import { ScopeSchema, scopePath } from "../core/stateContract.js";
5
+ import { HUNCH_VERSION } from "../core/version.js";
6
+ function parseScopeArg(value) {
7
+ const m = /^([a-z]+):(.+)$/.exec(value.trim());
8
+ const parsed = m ? ScopeSchema.safeParse({ kind: m[1], id: m[2] }) : null;
9
+ if (!parsed?.success)
10
+ throw new Error(`partition must be kind:id (organization|team|user|repository), got "${value}"`);
11
+ return parsed.data;
12
+ }
13
+ export function registerServeCommands(program) {
14
+ const serve = program.command("serve")
15
+ .description("Serve nuryel.state/1 over HTTP for organization / team / user / repository partitions (binds 127.0.0.1; put it behind SSH or a reverse proxy)")
16
+ .option("--config <file>", "serve config (nuryel.serve-config/1)", "hunch-serve.json")
17
+ .option("--port <n>", "override the configured port")
18
+ .action((opts) => {
19
+ const config = readServeConfig(resolve(opts.config));
20
+ const port = opts.port ? Number(opts.port) : config.port;
21
+ if (!Number.isInteger(port) || port < 1 || port > 65535)
22
+ throw new Error(`invalid port ${opts.port}`);
23
+ const app = createServeApp(config, { version: HUNCH_VERSION });
24
+ // Bind loopback, never expose a port: the orchestrator reaches it over an SSH hop or a
25
+ // reverse proxy that terminates TLS and auth of its own. Folded-in decision from Hunch Memory.
26
+ app.listen(port, "127.0.0.1", () => {
27
+ console.log(`hunch ${HUNCH_VERSION} serving nuryel.state/1 on http://127.0.0.1:${port} — ${config.partitions.map((p) => scopePath(p.scope)).join(", ")} (${config.principals.length} principal(s))`);
28
+ });
29
+ const stop = () => { app.close(() => { app.closeStores(); process.exit(0); }); };
30
+ process.on("SIGINT", stop);
31
+ process.on("SIGTERM", stop);
32
+ });
33
+ serve.command("init")
34
+ .description("Declare a partition directory and mint a principal token (printed once; only its hash is stored)")
35
+ .requiredOption("--partition <kind:id>", "the scope this directory IS, e.g. user:david or organization:ylm")
36
+ .requiredOption("--root <dir>", "directory whose .hunch/ holds the partition (created if missing)")
37
+ .option("--config <file>", "serve config to create or extend", "hunch-serve.json")
38
+ .option("--principal <id>", "principal to add or rotate, granted this partition")
39
+ .option("--kind <kind>", "principal kind: human | agent | service", "agent")
40
+ .option("--grant <kind:id...>", "additional partitions to grant the principal (must be served by this config)")
41
+ .option("--port <n>", "port to record in a new config")
42
+ .option("--json", "machine-readable output")
43
+ .action((opts) => {
44
+ if (!["human", "agent", "service"].includes(opts.kind))
45
+ throw new Error("--kind must be human, agent or service");
46
+ const scope = parseScopeArg(opts.partition);
47
+ const grants = [scope, ...(opts.grant ?? []).map(parseScopeArg)];
48
+ const result = initServeConfig({
49
+ file: resolve(opts.config), scope, root: resolve(opts.root),
50
+ ...(opts.principal ? { principal: { id: opts.principal, kind: opts.kind, grants } } : {}),
51
+ ...(opts.port ? { port: Number(opts.port) } : {}),
52
+ });
53
+ if (opts.json) {
54
+ console.log(JSON.stringify({ config: resolve(opts.config), partition: result.partition, token: result.token }));
55
+ return;
56
+ }
57
+ console.log(`partition ${scopePath(scope)} → ${result.partition.root}`);
58
+ console.log(`config: ${resolve(opts.config)} (${result.config.partitions.length} partition(s), ${result.config.principals.length} principal(s))`);
59
+ if (result.token)
60
+ console.log(`token for ${opts.principal} (shown once — only its sha256 is stored): ${result.token}`);
61
+ console.log(`start: hunch serve --config ${opts.config}`);
62
+ });
63
+ }
64
+ //# sourceMappingURL=serve.js.map
@@ -0,0 +1,48 @@
1
+ /** A typed refusal from the server: the problem+json body, with `code` = its title. */
2
+ export class StateClientError extends Error {
3
+ status;
4
+ code;
5
+ problem;
6
+ constructor(status, code, problem) {
7
+ super(`${code}: ${problem.detail}`);
8
+ this.status = status;
9
+ this.code = code;
10
+ this.problem = problem;
11
+ this.name = "StateClientError";
12
+ }
13
+ }
14
+ export function createStateClient(opts) {
15
+ const base = opts.baseUrl.replace(/\/+$/, "");
16
+ const doFetch = opts.fetch ?? fetch;
17
+ const timeoutMs = opts.timeoutMs ?? 15_000;
18
+ async function call(method, path, body) {
19
+ const controller = new AbortController();
20
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
21
+ try {
22
+ const response = await doFetch(`${base}${path}`, {
23
+ method,
24
+ headers: { authorization: `Bearer ${opts.token}`, ...(body !== undefined ? { "content-type": "application/json" } : {}) },
25
+ body: body !== undefined ? JSON.stringify(body) : undefined,
26
+ signal: controller.signal,
27
+ });
28
+ const text = await response.text();
29
+ const parsed = text ? JSON.parse(text) : {};
30
+ if (!response.ok) {
31
+ const p = parsed;
32
+ throw new StateClientError(response.status, p.title ?? String(response.status), p);
33
+ }
34
+ return parsed;
35
+ }
36
+ finally {
37
+ clearTimeout(timer);
38
+ }
39
+ }
40
+ return {
41
+ capabilities: (scope) => call("GET", `/nuryel/v1/capabilities${scope ? `?scope=${encodeURIComponent(`${scope.kind}:${scope.id}`)}` : ""}`),
42
+ read: (request) => call("POST", "/nuryel/v1/read", request),
43
+ write: (request) => call("POST", "/nuryel/v1/write", request),
44
+ subscribe: (request) => call("POST", "/nuryel/v1/subscribe", request),
45
+ health: () => call("GET", "/nuryel/v1/health"),
46
+ };
47
+ }
48
+ //# sourceMappingURL=state.js.map
@@ -0,0 +1,186 @@
1
+ /**
2
+ * `hunch serve` — the HTTP binding of nuryel.state/1, and the served product's partition host.
3
+ *
4
+ * Folded in from Hunch Memory: bind 127.0.0.1 (the caller's job; never expose a port), a
5
+ * bearer token that resolves the PRINCIPAL (the body never names one — grants come from the
6
+ * config, never from the caller), problem+json errors, a body limit, and a cross-process
7
+ * write lock per partition so a stdio MCP process on the same store cannot race a write.
8
+ *
9
+ * Every rule lives in src/store/stateBinding.ts; this file only maps HTTP to it:
10
+ * GET /nuryel/v1/capabilities → capabilities of the partition named by ?scope=kind:id (default: first granted)
11
+ * POST /nuryel/v1/read → readState
12
+ * POST /nuryel/v1/write → writeState (under the partition's write lock)
13
+ * POST /nuryel/v1/subscribe → subscribeState
14
+ * Request bodies are the contract's request schemas minus `schema` and `principal`.
15
+ */
16
+ import { createServer } from "node:http";
17
+ import { HunchStore } from "../store/hunchStore.js";
18
+ import { hunchPaths } from "../core/paths.js";
19
+ import { flushCapture } from "../integrations/sync.js";
20
+ import { StateRefusal, capabilities, readState, subscribeState, writeState } from "../store/stateBinding.js";
21
+ import { STATE_READ_VERSION, STATE_SUBSCRIBE_VERSION, STATE_WRITE_VERSION, ScopeSchema, scopePath } from "../core/stateContract.js";
22
+ import { partitionFor, resolvePrincipal } from "./config.js";
23
+ import { WriteLockTimeout, withWriteLock } from "./writelock.js";
24
+ import { HUNCH_VERSION } from "../core/version.js";
25
+ export const BODY_LIMIT_BYTES = 1024 * 1024;
26
+ export const PROBLEM_TYPE = "https://www.hunchmemory.com/problems/nuryel.state/1/";
27
+ export class HttpProblem extends Error {
28
+ status;
29
+ code;
30
+ extra;
31
+ constructor(status, code, message, extra = {}) {
32
+ super(message);
33
+ this.status = status;
34
+ this.code = code;
35
+ this.extra = extra;
36
+ this.name = "HttpProblem";
37
+ }
38
+ }
39
+ const problem = (status, code, message, extra = {}) => new HttpProblem(status, code, message, extra);
40
+ /** Refusal code → HTTP status. Outside-grants is 403 (the partition list is not secret to a
41
+ * principal that holds a token), conflicts are 409, identity/derivation errors 422. */
42
+ const REFUSAL_STATUS = {
43
+ "outside-grants": 403, unsupported: 400, malformed: 400, identity: 422, conflict: 409, idempotency: 409, "no-partition-home": 404,
44
+ };
45
+ function bearerToken(req) {
46
+ const header = req.headers.authorization;
47
+ if (!header)
48
+ return undefined;
49
+ const trimmed = header.trim();
50
+ if (!/^bearer /i.test(trimmed))
51
+ return undefined;
52
+ const token = trimmed.slice("bearer ".length).trim();
53
+ return token || undefined;
54
+ }
55
+ async function readBody(req) {
56
+ const declared = req.headers["content-length"];
57
+ if (declared !== undefined) {
58
+ if (Array.isArray(declared) || !/^[0-9]+$/.test(declared))
59
+ throw problem(400, "invalid-body", "content-length is not valid");
60
+ if (Number(declared) > BODY_LIMIT_BYTES)
61
+ throw problem(413, "body-too-large", `body exceeds ${BODY_LIMIT_BYTES} bytes`);
62
+ }
63
+ const chunks = [];
64
+ let size = 0;
65
+ for await (const chunk of req) {
66
+ size += chunk.length;
67
+ if (size > BODY_LIMIT_BYTES)
68
+ throw problem(413, "body-too-large", `body exceeds ${BODY_LIMIT_BYTES} bytes`);
69
+ chunks.push(chunk);
70
+ }
71
+ if (size === 0)
72
+ return {};
73
+ try {
74
+ const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8"));
75
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
76
+ throw new Error("not an object");
77
+ return parsed;
78
+ }
79
+ catch {
80
+ throw problem(400, "invalid-body", "body is not a JSON object");
81
+ }
82
+ }
83
+ function parseScopeParam(value) {
84
+ if (!value)
85
+ return undefined;
86
+ const m = /^([a-z]+):(.+)$/.exec(value);
87
+ const parsed = m ? ScopeSchema.safeParse({ kind: m[1], id: m[2] }) : null;
88
+ if (!parsed?.success)
89
+ throw problem(400, "invalid-scope", "scope must be kind:id");
90
+ return parsed.data;
91
+ }
92
+ export function createServeApp(config, opts = {}) {
93
+ const version = opts.version ?? HUNCH_VERSION;
94
+ const stores = new Map();
95
+ const storeFor = (scope) => {
96
+ const partition = partitionFor(config, scope);
97
+ if (!partition)
98
+ throw problem(404, "no-partition", `this server does not serve ${scopePath(scope)}`);
99
+ let store = stores.get(partition.root);
100
+ if (!store) {
101
+ store = opts.openStore ? opts.openStore(partition.root) : new HunchStore(hunchPaths(partition.root));
102
+ store.json.ensureDirs();
103
+ stores.set(partition.root, store);
104
+ }
105
+ return { store, root: partition.root };
106
+ };
107
+ /** Grants are checked here AND inside the binding — the binding's check is the contract's, this
108
+ * one refuses before a store is even opened. */
109
+ const requireScope = (principal, body) => {
110
+ const parsed = ScopeSchema.safeParse(body.scope);
111
+ if (!parsed.success)
112
+ throw problem(400, "invalid-scope", "scope is required: { kind, id }");
113
+ if (!principal.grants.some((g) => scopePath(g) === scopePath(parsed.data)))
114
+ throw problem(403, "outside-grants", `scope ${scopePath(parsed.data)} is outside the principal's grants`);
115
+ return parsed.data;
116
+ };
117
+ const send = (res, status, payload, type = "application/json") => {
118
+ const text = JSON.stringify(payload);
119
+ res.writeHead(status, { "content-type": `${type}; charset=utf-8`, "content-length": Buffer.byteLength(text), "cache-control": "no-store", "x-hunch-version": version });
120
+ res.end(text);
121
+ };
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");
124
+ };
125
+ const server = createServer(async (req, res) => {
126
+ try {
127
+ const url = new URL(req.url ?? "/", "http://127.0.0.1");
128
+ if (url.pathname === "/nuryel/v1/health" && req.method === "GET") {
129
+ return send(res, 200, { ok: true, version, protocol: "nuryel.state/1", partitions: config.partitions.map((p) => scopePath(p.scope)) });
130
+ }
131
+ const principal = resolvePrincipal(config, bearerToken(req));
132
+ if (!principal)
133
+ throw problem(401, "unauthorized", "a valid bearer token is required");
134
+ if (url.pathname === "/nuryel/v1/capabilities" && req.method === "GET") {
135
+ const scope = parseScopeParam(url.searchParams.get("scope")) ?? principal.grants[0];
136
+ if (!principal.grants.some((g) => scopePath(g) === scopePath(scope)))
137
+ throw problem(403, "outside-grants", `scope ${scopePath(scope)} is outside the principal's grants`);
138
+ const { store } = storeFor(scope);
139
+ return send(res, 200, { ...capabilities(store), principal: { id: principal.id, kind: principal.kind, grants: principal.grants } });
140
+ }
141
+ if (req.method !== "POST")
142
+ throw problem(405, "method-not-allowed", `${req.method} is not allowed on ${url.pathname}`);
143
+ const body = await readBody(req);
144
+ // The body never names the principal: the token did.
145
+ delete body.principal;
146
+ delete body.schema;
147
+ if (url.pathname === "/nuryel/v1/read") {
148
+ const scope = requireScope(principal, body);
149
+ const { store } = storeFor(scope);
150
+ const { response, envelope } = readState(store, { schema: STATE_READ_VERSION, principal, ...body });
151
+ return send(res, 200, { ...response, envelope });
152
+ }
153
+ if (url.pathname === "/nuryel/v1/write") {
154
+ const scope = requireScope(principal, body);
155
+ const { store, root } = storeFor(scope);
156
+ const result = await withWriteLock(hunchPaths(root).hunch, () => writeState(store, { schema: STATE_WRITE_VERSION, principal, ...body }, {
157
+ flush: (isPrivate, message) => flushCapture(store, hunchPaths(root).hunch, isPrivate, message),
158
+ }));
159
+ return send(res, result.outcome === "created" ? 201 : 200, result);
160
+ }
161
+ if (url.pathname === "/nuryel/v1/subscribe") {
162
+ const scope = requireScope(principal, body);
163
+ const { store } = storeFor(scope);
164
+ return send(res, 200, subscribeState(store, { schema: STATE_SUBSCRIBE_VERSION, principal, ...body }));
165
+ }
166
+ throw problem(404, "not-found", `${url.pathname} is not a nuryel.state/1 route`);
167
+ }
168
+ catch (error) {
169
+ if (error instanceof HttpProblem)
170
+ return sendProblem(res, error);
171
+ if (error instanceof StateRefusal)
172
+ return sendProblem(res, problem(REFUSAL_STATUS[error.code], error.code, error.message, error.conflict ? { conflict: error.conflict } : {}));
173
+ if (error instanceof WriteLockTimeout)
174
+ return sendProblem(res, problem(503, "write-lock-timeout", error.message, { "retry-after": 1 }));
175
+ if (error && typeof error === "object" && error.name === "ZodError") {
176
+ const issues = (error.issues ?? []).map((i) => `${i.path.join(".") || "request"}: ${i.message}`);
177
+ return sendProblem(res, problem(400, "malformed", `request is malformed: ${issues.join("; ")}`, { issues }));
178
+ }
179
+ return sendProblem(res, problem(500, "internal", error.message));
180
+ }
181
+ });
182
+ const closeStores = () => { for (const store of stores.values())
183
+ store.close(); stores.clear(); };
184
+ return Object.assign(server, { closeStores });
185
+ }
186
+ //# sourceMappingURL=app.js.map
@@ -0,0 +1,121 @@
1
+ /**
2
+ * `hunch serve` configuration — the served partitions and the principals allowed in.
3
+ *
4
+ * A partition is a directory holding a `.hunch/` store whose `.hunch/partition.json`
5
+ * names the scope it IS (organization / team / user / repository). A principal is a
6
+ * bearer token (stored as a sha256 hash — plaintext is printed once by `serve init`)
7
+ * bound to a principal id, kind and grants. The request body never carries a principal:
8
+ * the token resolves it, and grants are decided from this file, never from the caller.
9
+ */
10
+ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
11
+ import { existsSync, mkdirSync, readFileSync } from "node:fs";
12
+ import { dirname, resolve } from "node:path";
13
+ import { z } from "zod";
14
+ import { writeFileAtomic } from "../core/io.js";
15
+ import { ScopeSchema, scopePath } from "../core/stateContract.js";
16
+ export const SERVE_CONFIG_VERSION = "nuryel.serve-config/1";
17
+ const TOKEN = /^[A-Za-z0-9][A-Za-z0-9._:@+-]{0,199}$/;
18
+ export const PartitionConfigSchema = z.object({
19
+ scope: ScopeSchema,
20
+ /** Directory whose `.hunch/` holds the partition. Relative paths resolve from the config file. */
21
+ root: z.string().min(1),
22
+ }).strict();
23
+ export const PrincipalConfigSchema = z.object({
24
+ id: z.string().regex(TOKEN),
25
+ kind: z.enum(["human", "agent", "service"]),
26
+ display: z.string().max(256).optional(),
27
+ /** sha256 hex of the bearer token. */
28
+ token_sha256: z.string().regex(/^[0-9a-f]{64}$/),
29
+ grants: z.array(ScopeSchema).min(1).max(64),
30
+ }).strict();
31
+ export const ServeConfigSchema = z.object({
32
+ schema: z.literal(SERVE_CONFIG_VERSION),
33
+ port: z.number().int().min(1).max(65535).default(7474),
34
+ partitions: z.array(PartitionConfigSchema).min(1).max(256),
35
+ principals: z.array(PrincipalConfigSchema).max(1024).default([]),
36
+ }).strict();
37
+ export function hashToken(token) {
38
+ return createHash("sha256").update(token, "utf8").digest("hex");
39
+ }
40
+ export function mintToken() {
41
+ return `nyt_${randomBytes(24).toString("base64url")}`;
42
+ }
43
+ export function readServeConfig(file) {
44
+ const raw = JSON.parse(readFileSync(file, "utf8"));
45
+ const config = ServeConfigSchema.parse(raw);
46
+ const base = dirname(resolve(file));
47
+ const partitions = config.partitions.map((p) => ({ ...p, root: resolve(base, p.root) }));
48
+ const seen = new Set();
49
+ for (const p of partitions) {
50
+ const key = scopePath(p.scope);
51
+ if (seen.has(key))
52
+ throw new Error(`serve config lists partition ${key} twice`);
53
+ seen.add(key);
54
+ }
55
+ const ids = new Set();
56
+ for (const p of config.principals) {
57
+ if (ids.has(p.id))
58
+ throw new Error(`serve config lists principal ${p.id} twice`);
59
+ ids.add(p.id);
60
+ for (const g of p.grants)
61
+ if (!seen.has(scopePath(g)))
62
+ throw new Error(`principal ${p.id} is granted ${scopePath(g)}, which this server does not serve`);
63
+ }
64
+ return { ...config, partitions, file: resolve(file) };
65
+ }
66
+ export function writeServeConfig(file, config) {
67
+ mkdirSync(dirname(resolve(file)), { recursive: true });
68
+ writeFileAtomic(resolve(file), JSON.stringify(ServeConfigSchema.parse(config), null, 2) + "\n");
69
+ }
70
+ /** Constant-time token → principal. Undefined for a missing or unknown token. */
71
+ export function resolvePrincipal(config, token) {
72
+ if (!token)
73
+ return undefined;
74
+ const hash = Buffer.from(hashToken(token), "hex");
75
+ for (const p of config.principals) {
76
+ const candidate = Buffer.from(p.token_sha256, "hex");
77
+ if (candidate.length === hash.length && timingSafeEqual(candidate, hash)) {
78
+ return { id: p.id, kind: p.kind, ...(p.display ? { display: p.display } : {}), grants: p.grants };
79
+ }
80
+ }
81
+ return undefined;
82
+ }
83
+ export function partitionFor(config, scope) {
84
+ return config.partitions.find((p) => scopePath(p.scope) === scopePath(scope));
85
+ }
86
+ /** `serve init`: ensure a partition directory declares its scope, and add a principal
87
+ * with a freshly minted token. Idempotent for the partition; a principal id that
88
+ * already exists gets a NEW token (rotation), the old one stops working. */
89
+ export function initServeConfig(opts) {
90
+ const file = resolve(opts.file);
91
+ const existing = existsSync(file) ? readServeConfig(file) : null;
92
+ const root = resolve(opts.root);
93
+ const hunchDir = resolve(root, ".hunch");
94
+ mkdirSync(hunchDir, { recursive: true });
95
+ const partitionFile = resolve(hunchDir, "partition.json");
96
+ if (existsSync(partitionFile)) {
97
+ const declared = ScopeSchema.parse(JSON.parse(readFileSync(partitionFile, "utf8")));
98
+ if (scopePath(declared) !== scopePath(opts.scope))
99
+ throw new Error(`${root} already declares partition ${scopePath(declared)}, not ${scopePath(opts.scope)}`);
100
+ }
101
+ else {
102
+ writeFileAtomic(partitionFile, JSON.stringify(opts.scope, null, 2) + "\n");
103
+ }
104
+ const manifest = resolve(hunchDir, "manifest.json");
105
+ if (!existsSync(manifest))
106
+ writeFileAtomic(manifest, JSON.stringify({ schema_version: 3 }, null, 2) + "\n");
107
+ const partitions = existing ? existing.partitions.filter((p) => scopePath(p.scope) !== scopePath(opts.scope)) : [];
108
+ const partition = { scope: opts.scope, root };
109
+ partitions.push(partition);
110
+ let principals = existing?.principals ?? [];
111
+ let token = null;
112
+ if (opts.principal) {
113
+ token = mintToken();
114
+ const grants = opts.principal.grants?.length ? opts.principal.grants : [opts.scope];
115
+ principals = [...principals.filter((p) => p.id !== opts.principal.id), { id: opts.principal.id, kind: opts.principal.kind, token_sha256: hashToken(token), grants }];
116
+ }
117
+ const config = { schema: SERVE_CONFIG_VERSION, port: opts.port ?? existing?.port ?? 7474, partitions, principals };
118
+ writeServeConfig(file, config);
119
+ return { config, token, partition };
120
+ }
121
+ //# sourceMappingURL=config.js.map
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Cross-process write lock, one per partition root — folded in from Hunch Memory
3
+ * (src/service/writelock.ts there), where it held the one-live-decision-per-topic
4
+ * guarantee across the HTTP process and a stdio MCP process sharing a store.
5
+ *
6
+ * The state binding reads the ledger and the incumbent records, then writes; two
7
+ * processes can both complete the read before either writes. An in-process mutex
8
+ * does not span processes, so the lock is a file created with `open(path, "wx")` —
9
+ * atomic on POSIX and Windows. A holder that dies leaves the file behind; a lock is
10
+ * stealable once its owner is provably gone (pid dead on the same host) or older
11
+ * than any plausible write.
12
+ */
13
+ import { hostname } from "node:os";
14
+ import { randomBytes } from "node:crypto";
15
+ import { closeSync, openSync, readFileSync, rmSync, statSync, writeSync } from "node:fs";
16
+ import { join } from "node:path";
17
+ /** Longest a write may hold the lock before another process may steal it: a put plus a
18
+ * git commit and push — seconds, not minutes. */
19
+ export const STALE_AFTER_MS = 60_000;
20
+ /** Longest a writer waits before giving up and telling the caller. */
21
+ export const ACQUIRE_TIMEOUT_MS = 10_000;
22
+ const POLL_MS = 25;
23
+ export class WriteLockTimeout extends Error {
24
+ path;
25
+ heldBy;
26
+ waitedMs;
27
+ constructor(path, heldBy, waitedMs) {
28
+ super(heldBy
29
+ ? `write lock on ${path} held by pid ${heldBy.pid} on ${heldBy.host} since ${heldBy.at}; waited ${waitedMs}ms`
30
+ : `write lock on ${path} not acquired within ${waitedMs}ms`);
31
+ this.path = path;
32
+ this.heldBy = heldBy;
33
+ this.waitedMs = waitedMs;
34
+ this.name = "WriteLockTimeout";
35
+ }
36
+ }
37
+ export function writeLockPath(hunchDir) {
38
+ return join(hunchDir, "write.lock");
39
+ }
40
+ function readOwner(path) {
41
+ try {
42
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
43
+ if (typeof parsed.pid !== "number" || typeof parsed.nonce !== "string")
44
+ return undefined;
45
+ return { pid: parsed.pid, host: typeof parsed.host === "string" ? parsed.host : "", nonce: parsed.nonce, at: typeof parsed.at === "string" ? parsed.at : "" };
46
+ }
47
+ catch {
48
+ return undefined; // released between our open and this read, or truncated by a crash — age decides
49
+ }
50
+ }
51
+ function pidAlive(pid) {
52
+ try {
53
+ process.kill(pid, 0);
54
+ return true;
55
+ }
56
+ catch (error) {
57
+ return error.code === "EPERM"; // exists, another user — alive for our purposes
58
+ }
59
+ }
60
+ function stealable(path, owner, now) {
61
+ let ageMs;
62
+ try {
63
+ ageMs = now - statSync(path).mtimeMs;
64
+ }
65
+ catch {
66
+ return false;
67
+ }
68
+ if (owner && owner.host === hostname() && !pidAlive(owner.pid))
69
+ return true;
70
+ return ageMs > STALE_AFTER_MS;
71
+ }
72
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
73
+ /** Hold the partition's lock across `fn` (sync or async); released even if it throws. */
74
+ export async function withWriteLock(hunchDir, fn, opts = {}) {
75
+ const path = writeLockPath(hunchDir);
76
+ const timeoutMs = opts.timeoutMs ?? ACQUIRE_TIMEOUT_MS;
77
+ const nonce = randomBytes(8).toString("hex");
78
+ const started = Date.now();
79
+ let fd;
80
+ let lastOwner;
81
+ for (;;) {
82
+ try {
83
+ fd = openSync(path, "wx");
84
+ break;
85
+ }
86
+ catch (error) {
87
+ if (error.code !== "EEXIST")
88
+ throw error;
89
+ lastOwner = readOwner(path);
90
+ if (stealable(path, lastOwner, Date.now())) {
91
+ rmSync(path, { force: true });
92
+ continue;
93
+ }
94
+ if (Date.now() - started >= timeoutMs)
95
+ throw new WriteLockTimeout(path, lastOwner, Date.now() - started);
96
+ await sleep(POLL_MS);
97
+ }
98
+ }
99
+ const owner = { pid: process.pid, host: hostname(), nonce, at: new Date().toISOString() };
100
+ try {
101
+ writeSync(fd, JSON.stringify(owner));
102
+ }
103
+ catch { /* held by existence; identity is a courtesy */ }
104
+ closeSync(fd);
105
+ try {
106
+ return await fn();
107
+ }
108
+ finally {
109
+ // Only remove a lock that is still ours: if we overran STALE_AFTER_MS and were stolen
110
+ // from, the file now protects another writer.
111
+ const current = readOwner(path);
112
+ if (!current || current.nonce === nonce)
113
+ rmSync(path, { force: true });
114
+ }
115
+ }
116
+ //# sourceMappingURL=writelock.js.map
@@ -17,7 +17,8 @@
17
17
  * dependencies, external-truth-stays-external (schema refinements), never-in-request-path
18
18
  * (there is no proxy verb — this module never fetches anything).
19
19
  */
20
- import { basename } from "node:path";
20
+ import { basename, join } from "node:path";
21
+ import { existsSync, readFileSync } from "node:fs";
21
22
  import { z } from "zod";
22
23
  import { appendChanges, latestSeqFor, readLedger } from "./changeLedger.js";
23
24
  import { hunchPaths } from "../core/paths.js";
@@ -40,14 +41,24 @@ export class StateRefusal extends Error {
40
41
  const LEGACY_FACETS = new Set(["decisions", "constraints", "bugs", "findings"]);
41
42
  // Explicit classes, no `i` flag: the pattern must survive zod → JSON schema for MCP output validation.
42
43
  const TOKEN = /^[A-Za-z0-9][A-Za-z0-9._:@+-]{0,199}$/;
43
- /** The repository partition this store serves. The id is the checkout's directory name,
44
- * sanitized to the contract's token grammar stable per clone, discoverable through
45
- * `capabilities`, and the scope every legacy record defaults to. */
46
- export function repositoryScope(store) {
44
+ /** The partition this store IS. A served partition declares itself in `.hunch/partition.json`
45
+ * (`{ kind, id }`, committed with the store); a plain checkout is the repository partition
46
+ * named after its directory, sanitized to the contract's token grammar stable per clone,
47
+ * discoverable through `capabilities`, and the scope every legacy record defaults to. */
48
+ export function partitionOf(store) {
49
+ const declared = join(hunchPaths(store.publicRoot).hunch, "partition.json");
50
+ if (existsSync(declared)) {
51
+ const parsed = ScopeSchema.safeParse(JSON.parse(readFileSync(declared, "utf8")));
52
+ if (!parsed.success)
53
+ throw new StateRefusal("unsupported", `${declared} does not declare a valid partition scope`);
54
+ return parsed.data;
55
+ }
47
56
  const raw = basename(store.publicRoot).replace(/[^A-Za-z0-9._:@+-]/g, "-").replace(/^[^A-Za-z0-9]+/, "");
48
57
  const id = TOKEN.test(raw) ? raw : "repository";
49
58
  return { kind: "repository", id };
50
59
  }
60
+ /** @deprecated name kept for callers written before served partitions; same value as partitionOf. */
61
+ export const repositoryScope = partitionOf;
51
62
  export const SubscribeResponseSchema = z.object({
52
63
  schema: z.literal(STATE_SUBSCRIBE_VERSION),
53
64
  scope: ScopeSchema,
@@ -59,19 +70,21 @@ export const SubscribeResponseSchema = z.object({
59
70
  filtered: z.boolean(),
60
71
  }).strict();
61
72
  export function capabilities(store) {
62
- const partitions = store.hasPrivate ? ["organization", "team", "user", "repository"] : ["repository"];
63
- return { protocol: STATE_CONTRACT_VERSION, capabilities: [...STATE_CAPABILITIES], repository: repositoryScope(store), partitions };
73
+ const own = partitionOf(store);
74
+ const partitions = store.hasPrivate ? ["organization", "team", "user", "repository"] : [own.kind];
75
+ return { protocol: STATE_CONTRACT_VERSION, capabilities: [...STATE_CAPABILITIES], repository: own, partitions };
64
76
  }
65
77
  // ---- homing --------------------------------------------------------------------------------
66
78
  const granted = (principal, scope) => principal.grants.some((g) => scopePath(g) === scopePath(scope));
67
79
  function homeFor(store, scope) {
68
- const repo = repositoryScope(store);
69
- if (scope.kind === "repository") {
70
- if (scope.id !== repo.id)
71
- throw new StateRefusal("unsupported", `this store serves repository ${repo.id}, not ${scope.id}`);
80
+ const own = partitionOf(store);
81
+ if (scopePath(scope) === scopePath(own)) {
82
+ // The store IS this partition: its capture home (public `.hunch/`, or the overlay in shared mode).
72
83
  const home = store.captureHome(false);
73
84
  return { home, hunchDir: home === "private" ? store.privateDir : hunchPaths(store.publicRoot).hunch, isPrivate: false };
74
85
  }
86
+ if (scope.kind === "repository")
87
+ throw new StateRefusal("unsupported", `this store serves ${scopePath(own)}, not ${scopePath(scope)}`);
75
88
  if (!store.hasPrivate || !store.privateDir) {
76
89
  throw new StateRefusal("no-partition-home", `${scope.kind} partitions never ride a repository; configure an overlay (hunch private / hunch shared) to hold ${scopePath(scope)}`);
77
90
  }
@@ -93,7 +106,7 @@ export function readState(store, input) {
93
106
  const request = ReadRequestSchema.parse(input);
94
107
  if (!granted(request.principal, request.scope))
95
108
  throw new StateRefusal("outside-grants", `scope ${scopePath(request.scope)} is outside the principal's grants`);
96
- const repo = repositoryScope(store);
109
+ const repo = partitionOf(store);
97
110
  const facets = new Set(request.facets ?? STATE_FACETS);
98
111
  const target = request.task ?? request.subject ?? scopePath(request.scope);
99
112
  const ctx = store.assembleContext(target, request.budget_tokens ?? 1500);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davesheffer/hunch",
3
- "version": "1.25.0",
3
+ "version": "1.26.0",
4
4
  "mcpName": "io.github.davesheffer/hunch",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Dave Sheffer <dave.sheffer1@gmail.com>",
@@ -26,6 +26,7 @@
26
26
  "types": "./dist/projectDna.d.ts",
27
27
  "default": "./dist/projectDna.js"
28
28
  },
29
+ "./state": "./dist/client/state.js",
29
30
  "./dist/*": "./dist/*",
30
31
  "./package.json": "./package.json"
31
32
  },
package/server.json CHANGED
@@ -7,13 +7,13 @@
7
7
  "source": "github"
8
8
  },
9
9
  "websiteUrl": "https://www.hunchmemory.com",
10
- "version": "1.25.0",
10
+ "version": "1.26.0",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "npm",
14
14
  "registryBaseUrl": "https://registry.npmjs.org",
15
15
  "identifier": "@davesheffer/hunch",
16
- "version": "1.25.0",
16
+ "version": "1.26.0",
17
17
  "runtimeHint": "npx",
18
18
  "packageArguments": [
19
19
  {