@davesheffer/hunch 1.26.1 → 1.26.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli/index.js CHANGED
@@ -20,7 +20,7 @@ import { join, relative, dirname, basename, resolve, isAbsolute } from "node:pat
20
20
  import { tmpdir } from "node:os";
21
21
  import { fileURLToPath } from "node:url";
22
22
  import { Command } from "commander";
23
- import { hunchPaths, hunchPathsForDir, findRoot, toPosixTarget } from "../core/paths.js";
23
+ import { hunchPaths, hunchPathsForDir, findRoot, toPosixTarget, isDir } from "../core/paths.js";
24
24
  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";
@@ -5160,8 +5160,16 @@ function printAutoReviewPlan(plan) {
5160
5160
  program
5161
5161
  .command("mcp")
5162
5162
  .description("Start the MCP server over stdio (Claude Code connects here).")
5163
- .action(async () => {
5163
+ .option("--root <dir>", "serve exactly this store or served partition and ignore the client's workspace roots and cwd hints")
5164
+ .action(async (opts) => {
5164
5165
  const { startServer } = await import("../mcp/server.js");
5166
+ if (opts.root) {
5167
+ const pinned = resolve(opts.root);
5168
+ if (!isDir(join(pinned, ".hunch")))
5169
+ throw new Error(`--root ${pinned} has no .hunch/ store`);
5170
+ await startServer(pinned, { pinned: true });
5171
+ return;
5172
+ }
5165
5173
  await startServer(process.cwd());
5166
5174
  });
5167
5175
  // ---- migrate (schema versioning) ------------------------------------------
@@ -88,6 +88,9 @@ export const ReadResponseSchema = z.object({
88
88
  state_of_record: StateOfRecordSchema.nullable(),
89
89
  /** Scopes the principal asked about but is not granted — named, never silently dropped. */
90
90
  denied_scopes: z.array(ScopeSchema).max(64).default([]),
91
+ /** The records behind every ref in `state_of_record`, by id, so a consumer can answer from
92
+ * the drawer without a second lookup. Additive; absent when there is no subject. */
93
+ records: z.record(z.string(), z.record(z.string(), z.unknown())).optional(),
91
94
  }).strict();
92
95
  export const WriteRequestSchema = z.object({
93
96
  schema: z.literal(STATE_WRITE_VERSION),
@@ -818,7 +818,11 @@ function stagedMemoryPaths(hunchDir, env, allowMemoryDeletions = false) {
818
818
  function isDerivedStoreArtifact(relativeName) {
819
819
  return /^[^/]+\.sqlite[^/]*$/i.test(relativeName)
820
820
  || relativeName.split("/").some((segment) => segment.includes(".tmp"))
821
- || relativeName === "events.log";
821
+ || relativeName === "events.log"
822
+ // `hunch serve` flushes INSIDE its cross-process write lock, so the lock file is always
823
+ // staged alongside the record; treating it as a violation made every served write skip
824
+ // the commit quietly and report durability "local" forever (1.26.0/1.26.1).
825
+ || relativeName === "write.lock";
822
826
  }
823
827
  /** Enumerate ordinary JSON files already contained under an overlay. Push-capable
824
828
  * stores force-add this exact allowlist so remote .gitignore, info/exclude, or an
@@ -22,6 +22,7 @@ import { knownRepoDeps } from "../synthesis/tripwires.js";
22
22
  import { refreshExistingGrounding } from "../integrations/providers.js";
23
23
  import { revParse, asOfDate, revExists, lastChangeDate, rangeFiles, rangeDiff, commitFiles, commitDiff, stagedFiles, stagedDiff, workingFiles, workingDiff, pullHunchStatus, sameRemoteUrl, currentBranch } from "../extractors/git.js";
24
24
  import { flushCapture, flushMemoryHome, pinSharedRemote } from "../integrations/sync.js";
25
+ import { withWriteLock } from "../serve/writelock.js";
25
26
  import { advertisedTeamRemoteContract, ensureTeamOverlay, overlayMatchesTeamRemote, readTeamConfig, teamRemoteContract, teamSharedRef } from "../integrations/team.js";
26
27
  import { formatStructure } from "../core/format.js";
27
28
  import { diagnoseIssueCorrectionStage, formatCorrectionStageDiagnostic } from "../core/correctionStage.js";
@@ -517,7 +518,8 @@ function prepareRoot(root, explicitOverlay, requireIndex) {
517
518
  throw error;
518
519
  }
519
520
  }
520
- export function buildServerWithRootControl(initialRoot) {
521
+ export function buildServerWithRootControl(initialRoot, options = {}) {
522
+ const pinned = options.pinned === true;
521
523
  const explicitOverlay = !!process.env.HUNCH_PRIVATE_DIR?.trim();
522
524
  const initial = prepareRoot(initialRoot, explicitOverlay, false);
523
525
  let root = initial.root;
@@ -670,7 +672,9 @@ export function buildServerWithRootControl(initialRoot) {
670
672
  // sole in-flight request — re-homing under a concurrent request would tear its
671
673
  // root/store out from under it, so that case is refused rather than risked.
672
674
  const cwdHint = extractCwdHint(args[0]);
673
- if (cwdHint !== undefined) {
675
+ // A pinned root is the whole point of `hunch mcp --root`: a served partition must not
676
+ // follow the caller's working directory into some other checkout.
677
+ if (cwdHint !== undefined && !pinned) {
674
678
  const target = canonicalRootPath(findRoot(cwdHint));
675
679
  if (target !== canonicalRootPath(root)) {
676
680
  if (activeRequests) {
@@ -1710,7 +1714,31 @@ export function buildServerWithRootControl(initialRoot) {
1710
1714
  ? `subject ${sor.subject}: current ${sor.current.length} · in force ${sor.in_force.length} · done ${sor.done.length} · depends on ${sor.depends_on.length} · invalidated by ${sor.invalidated_by.length}`
1711
1715
  : "no subject — delivery envelope only";
1712
1716
  const deniedNote = response.denied_scopes.length ? `\ndenied scopes: ${response.denied_scopes.map((s) => `${s.kind}/${s.id}`).join(", ")}` : "";
1713
- return stateResult(`${response.receipt_id} · ${summary}${deniedNote}\n\n${envelope.text}`, response);
1717
+ // Render the state of record itself, not only its refs: a consumer answers from this text.
1718
+ const line = (label, ref) => {
1719
+ const r = (response.records ?? {})[ref.id] ?? {};
1720
+ const g = (k) => { const v = r[k]; return typeof v === "string" ? v : v == null ? "" : JSON.stringify(v); };
1721
+ if (ref.facet === "derived")
1722
+ return `- ${label} derived ${ref.id} · computed ${g("computed_at")} · ${r.dependencies?.length ?? 0} dependencies\n ${g("content").slice(0, 1200)}`;
1723
+ if (ref.facet === "commitments")
1724
+ return `- ${label} commitment ${ref.id} · ${g("status")} · due ${g("due")} · owner ${g("owner")}: ${g("title")}`;
1725
+ if (ref.facet === "receipts") {
1726
+ const t = (r.target ?? {});
1727
+ return `- ${label} receipt ${ref.id} · ${g("action_kind")} on ${String(t.system ?? "")} ${String(t.object_type ?? "")}:${String(t.object_key ?? "")} · ${g("state")} at ${g("occurred_at")} by ${g("actor")}`;
1728
+ }
1729
+ if (ref.facet === "decisions")
1730
+ return `- ${label} decision ${ref.id} · ${g("status")}: ${g("title")}`;
1731
+ if (ref.facet === "constraints")
1732
+ return `- ${label} constraint ${ref.id} · ${g("severity")}: ${g("statement")}`;
1733
+ if (ref.facet === "entities")
1734
+ return `- ${label} entity ${ref.id} · ${g("kind")} ${g("name")} · ${g("lifecycle")}`;
1735
+ return `- ${label} ${ref.facet} ${ref.id}`;
1736
+ };
1737
+ const stateText = sor
1738
+ ? [...sor.current.map((r) => line("current", r)), ...sor.in_force.map((r) => line("in force", r)), ...sor.done.map((r) => line("done", r)),
1739
+ ...(sor.invalidated_by.length ? [`- invalidated by: ${sor.invalidated_by.join(", ")}`] : [])].join("\n") || "(nothing on record for this subject)"
1740
+ : "";
1741
+ return stateResult(`${response.receipt_id} · ${summary}${deniedNote}${stateText ? `\n\nState of record:\n${stateText}` : ""}\n\n${envelope.text}`, response);
1714
1742
  }
1715
1743
  catch (e) {
1716
1744
  return stateRefusal(e);
@@ -1723,9 +1751,11 @@ export function buildServerWithRootControl(initialRoot) {
1723
1751
  outputSchema: WriteResultSchema.shape,
1724
1752
  }, async ({ cwd: _cwd, ...input }) => {
1725
1753
  try {
1726
- const result = writeState(store, { schema: STATE_WRITE_VERSION, ...input }, {
1754
+ // Same cross-process lock `hunch serve` takes: a second agent writing over stdio must
1755
+ // not race the HTTP server between the ledger read and the record write.
1756
+ const result = await withWriteLock(hunchPaths(root).hunch, () => writeState(store, { schema: STATE_WRITE_VERSION, ...input }, {
1727
1757
  flush: (isPrivate, message) => flushCapture(store, hunchPaths(root).hunch, isPrivate, message, startupTeamRoute ?? undefined),
1728
- });
1758
+ }));
1729
1759
  return stateResult(`${result.outcome} ${result.record_id} (${result.durability}) ${result.record_hash}`, result);
1730
1760
  }
1731
1761
  catch (e) {
@@ -2290,7 +2320,9 @@ export function buildServerWithRootControl(initialRoot) {
2290
2320
  return {
2291
2321
  server,
2292
2322
  getRoot: () => root,
2293
- setRoot,
2323
+ setRoot: (next) => { if (!pinned)
2324
+ setRoot(next); },
2325
+ pinned,
2294
2326
  cancelPendingRoot: () => { pendingRoot = null; },
2295
2327
  };
2296
2328
  }
@@ -2309,6 +2341,8 @@ function provLine(record) {
2309
2341
  /** Query client roots after initialization and follow later list changes.
2310
2342
  * Generation ordering prevents a slow stale roots/list response from winning. */
2311
2343
  export function wireClientRoots(control, fallback) {
2344
+ if (control.pinned)
2345
+ return; // `hunch mcp --root`: the client's workspace is not this server's store
2312
2346
  let generation = 0;
2313
2347
  const syncRoots = async () => {
2314
2348
  const mine = ++generation;
@@ -2346,12 +2380,14 @@ export function wireClientRoots(control, fallback) {
2346
2380
  control.server.server.setNotificationHandler(RootsListChangedNotificationSchema, async () => { await syncRoots(); });
2347
2381
  }
2348
2382
  /** Start the stdio server (called by `hunch mcp`). */
2349
- export async function startServer(cwd = process.cwd()) {
2350
- const fallback = findRoot(cwd);
2351
- const control = buildServerWithRootControl(fallback);
2383
+ export async function startServer(cwd = process.cwd(), options = {}) {
2384
+ const fallback = options.pinned ? cwd : findRoot(cwd);
2385
+ const control = buildServerWithRootControl(fallback, options);
2352
2386
  wireClientRoots(control, fallback);
2353
2387
  const transport = new StdioServerTransport();
2354
2388
  await control.server.connect(transport);
2355
- console.error(`[hunch-mcp] serving Hunch over stdio (spawn root ${control.getRoot()}; resolving client roots…)`);
2389
+ console.error(control.pinned
2390
+ ? `[hunch-mcp] serving Hunch over stdio (pinned root ${control.getRoot()}; client roots ignored)`
2391
+ : `[hunch-mcp] serving Hunch over stdio (spawn root ${control.getRoot()}; resolving client roots…)`);
2356
2392
  }
2357
2393
  //# sourceMappingURL=server.js.map
@@ -34,6 +34,11 @@ export const ServeConfigSchema = z.object({
34
34
  partitions: z.array(PartitionConfigSchema).min(1).max(256),
35
35
  principals: z.array(PrincipalConfigSchema).max(1024).default([]),
36
36
  }).strict();
37
+ export const PARTITION_GITIGNORE = [
38
+ "# hunch serve partition — derived runtime artifacts (regenerable from .hunch/*.json)",
39
+ ".hunch/*.sqlite", ".hunch/*.sqlite-shm", ".hunch/*.sqlite-wal", ".hunch/*.sqlite-journal",
40
+ ".hunch/**/*.tmp*", ".hunch/write.lock", ".hunch/.hunch-commit.lock", ".hunch/local.json", ".hunch/events.log", "",
41
+ ].join("\n");
37
42
  export function hashToken(token) {
38
43
  return createHash("sha256").update(token, "utf8").digest("hex");
39
44
  }
@@ -104,6 +109,11 @@ export function initServeConfig(opts) {
104
109
  const manifest = resolve(hunchDir, "manifest.json");
105
110
  if (!existsSync(manifest))
106
111
  writeFileAtomic(manifest, JSON.stringify({ schema_version: 3 }, null, 2) + "\n");
112
+ // A served partition is meant to be its own git repository: keep the derived index, temp
113
+ // files and locks out of it so every auto-commit is records + ledger only.
114
+ const ignore = resolve(root, ".gitignore");
115
+ if (!existsSync(ignore))
116
+ writeFileAtomic(ignore, PARTITION_GITIGNORE);
107
117
  const partitions = existing ? existing.partitions.filter((p) => scopePath(p.scope) !== scopePath(opts.scope)) : [];
108
118
  const partition = { scope: opts.scope, root };
109
119
  partitions.push(partition);
@@ -118,6 +118,7 @@ export function readState(store, input) {
118
118
  profile: request.profile ?? "builder",
119
119
  });
120
120
  let stateOfRecord = null;
121
+ const records = {};
121
122
  const denied = new Map();
122
123
  if (request.subject !== undefined) {
123
124
  const subject = request.subject;
@@ -135,6 +136,10 @@ export function readState(store, input) {
135
136
  }
136
137
  return scope;
137
138
  };
139
+ const keep = (facet, record, scope) => {
140
+ records[record.id] = record;
141
+ return refOf(facet, record, scope);
142
+ };
138
143
  if (facets.has("decisions"))
139
144
  for (const d of store.recs("decisions")) {
140
145
  if (d.topic !== subject && d.id !== subject)
@@ -143,7 +148,7 @@ export function readState(store, input) {
143
148
  if (!scope)
144
149
  continue;
145
150
  if (isLive(d))
146
- current.push(refOf("decisions", d, scope));
151
+ current.push(keep("decisions", d, scope));
147
152
  }
148
153
  if (facets.has("constraints"))
149
154
  for (const c of store.recs("constraints")) {
@@ -153,7 +158,7 @@ export function readState(store, input) {
153
158
  if (!scope)
154
159
  continue;
155
160
  if (c.status === "active" && c.valid_to == null)
156
- inForce.push(refOf("constraints", c, scope));
161
+ inForce.push(keep("constraints", c, scope));
157
162
  }
158
163
  if (facets.has("receipts"))
159
164
  for (const r of store.recs("receipts")) {
@@ -164,7 +169,7 @@ export function readState(store, input) {
164
169
  if (!scope)
165
170
  continue;
166
171
  if (r.state === "succeeded" || r.state === "verified")
167
- done.push(refOf("receipts", r, scope));
172
+ done.push(keep("receipts", r, scope));
168
173
  if (r.invalidates.includes(subject))
169
174
  invalidatedBy.add(r.id);
170
175
  }
@@ -176,7 +181,7 @@ export function readState(store, input) {
176
181
  if (!scope)
177
182
  continue;
178
183
  if ((c.status === "open" || c.status === "waiting") && c.valid_to == null)
179
- inForce.push(refOf("commitments", c, scope));
184
+ inForce.push(keep("commitments", c, scope));
180
185
  }
181
186
  if (facets.has("derived"))
182
187
  for (const d of store.recs("derived")) {
@@ -186,7 +191,7 @@ export function readState(store, input) {
186
191
  if (!scope)
187
192
  continue;
188
193
  if (d.state === "current" && d.valid_to == null) {
189
- current.push(refOf("derived", d, scope));
194
+ current.push(keep("derived", d, scope));
190
195
  dependsOn.push(...d.dependencies);
191
196
  }
192
197
  }
@@ -198,7 +203,7 @@ export function readState(store, input) {
198
203
  if (!scope)
199
204
  continue;
200
205
  if (e.lifecycle === "active")
201
- current.push(refOf("entities", e, scope));
206
+ current.push(keep("entities", e, scope));
202
207
  }
203
208
  if (facets.has("relationships"))
204
209
  for (const r of store.recs("relationships")) {
@@ -207,7 +212,7 @@ export function readState(store, input) {
207
212
  const scope = admit("relationships", r);
208
213
  if (!scope)
209
214
  continue;
210
- current.push(refOf("relationships", r, scope));
215
+ current.push(keep("relationships", r, scope));
211
216
  }
212
217
  stateOfRecord = { subject, current, in_force: inForce, done, depends_on: dependsOn, invalidated_by: [...invalidatedBy].sort() };
213
218
  }
@@ -217,6 +222,7 @@ export function readState(store, input) {
217
222
  scope: request.scope,
218
223
  state_of_record: stateOfRecord,
219
224
  denied_scopes: [...denied.values()],
225
+ ...(stateOfRecord ? { records } : {}),
220
226
  });
221
227
  assertReadWithinGrants(request.principal, response);
222
228
  return { response, envelope };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davesheffer/hunch",
3
- "version": "1.26.1",
3
+ "version": "1.26.2",
4
4
  "mcpName": "io.github.davesheffer/hunch",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Dave Sheffer <dave.sheffer1@gmail.com>",
package/server.json CHANGED
@@ -7,13 +7,13 @@
7
7
  "source": "github"
8
8
  },
9
9
  "websiteUrl": "https://www.hunchmemory.com",
10
- "version": "1.26.1",
10
+ "version": "1.26.2",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "npm",
14
14
  "registryBaseUrl": "https://registry.npmjs.org",
15
15
  "identifier": "@davesheffer/hunch",
16
- "version": "1.26.1",
16
+ "version": "1.26.2",
17
17
  "runtimeHint": "npx",
18
18
  "packageArguments": [
19
19
  {