@davesheffer/hunch 1.41.6 → 1.42.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.
Files changed (40) hide show
  1. package/README.md +1 -1
  2. package/dist/cli/index.js +402 -154
  3. package/dist/constitution/experiment.d.ts +3 -3
  4. package/dist/constitution/g3.d.ts +1 -1
  5. package/dist/core/footprint.d.ts +15 -0
  6. package/dist/core/footprint.js +167 -0
  7. package/dist/core/groundingLag.d.ts +15 -0
  8. package/dist/core/groundingLag.js +27 -0
  9. package/dist/core/hookText.d.ts +4 -0
  10. package/dist/core/hookText.js +8 -0
  11. package/dist/core/pipeline.d.ts +28 -0
  12. package/dist/core/pipeline.js +50 -0
  13. package/dist/core/shellwrites.d.ts +7 -0
  14. package/dist/core/shellwrites.js +131 -0
  15. package/dist/core/siblingfix.d.ts +124 -0
  16. package/dist/core/siblingfix.js +814 -0
  17. package/dist/core/taskReportHook.d.ts +1 -1
  18. package/dist/core/taskReportHook.js +19 -3
  19. package/dist/extractors/git.d.ts +31 -0
  20. package/dist/extractors/git.js +180 -1
  21. package/dist/extractors/nativeTreeSitter.d.ts +2 -1
  22. package/dist/extractors/nativeTreeSitter.js +128 -30
  23. package/dist/integrations/claudemd.d.ts +20 -2
  24. package/dist/integrations/claudemd.js +79 -53
  25. package/dist/integrations/providers.d.ts +9 -5
  26. package/dist/integrations/providers.js +34 -22
  27. package/dist/integrations/team.d.ts +24 -4
  28. package/dist/integrations/team.js +154 -16
  29. package/dist/integrations/worktree.d.ts +3 -2
  30. package/dist/integrations/worktree.js +7 -4
  31. package/dist/mcp/server.d.ts +489 -0
  32. package/dist/mcp/server.js +208 -62
  33. package/dist/mcp/taskReportTools.js +12 -9
  34. package/dist/mcp/toolset.d.ts +11 -1
  35. package/dist/mcp/toolset.js +28 -8
  36. package/dist/store/hunchStore.d.ts +25 -1
  37. package/dist/store/hunchStore.js +146 -21
  38. package/dist/store/jsonStore.js +25 -4
  39. package/package.json +1 -1
  40. package/server.json +2 -2
@@ -61,12 +61,12 @@ export { verificationLauncherFor };
61
61
  export function registerTaskReportTools(server, getRoot, getStore) {
62
62
  server.registerTool("hunch_task", {
63
63
  title: "Start or finish a task's contribution report",
64
- description: "Start once per user task, unless the host's prompt hook already opened the task and printed its verify command — then reuse that task_id and do not start. Pass the task_id to hunch_context. Finish before your final response and include the returned concise contribution card, without asking the user; skip finish only when the prompt hook's own instruction said this host closes the task and the task used no Hunch (no hunch_* call on this task_id, no verified check, no hook context you acted on, nothing to claim), and a task you started with this tool must always be finished. Applications are explicitly agent-reported and must name an exact delivered occurrence and record hash. Completion never implies successful verification. Not for storing decisions or claiming tests passed; use the CLI task verify wrapper for observed command results.",
64
+ description: "Start once per user task, unless the host's prompt hook already opened one — then reuse its task_id. Pass task_id to hunch_context. Finish before your final response and show the returned contribution card without asking; skip finish only when the hook said the host closes the task and the task used no Hunch (no hunch_* call, verified check, or hook context you acted on). A task you started must always be finished. Applications must name an exact delivered occurrence and record hash. Finishing never implies verification. Not for storing decisions or claiming tests passed; use the CLI task verify wrapper for command results.",
65
65
  inputSchema: {
66
66
  action: z.enum(["start", "finish"]), task_id: TaskIdSchema.optional(),
67
67
  title: z.string().min(1).max(200).optional(),
68
- outcome: z.enum(["completed", "interrupted"]).optional(),
69
- applications: z.array(ReportClaimSchema).max(20).optional(),
68
+ outcome: z.enum(["completed", "interrupted"]).optional().describe("\"interrupted\" when cut short."),
69
+ applications: z.array(ReportClaimSchema).max(20).optional().describe("Only lessons actually applied. Copy occurrence_id, record_id, content_hash exactly from hunch_report(task_id) application_references; never derive an ID from a receipt or use a scope hash."),
70
70
  cwd: z.string().optional().describe("Actual repository/worktree directory for this task."),
71
71
  },
72
72
  }, async ({ action, task_id, title, outcome, applications }) => {
@@ -88,7 +88,9 @@ export function registerTaskReportTools(server, getRoot, getStore) {
88
88
  task = readTaskReport(root, task_id, reportSourceSnapshot(root).hash).task;
89
89
  }
90
90
  const launcher = verificationLauncher();
91
- return { content: [{ type: "text", text: `Task ${task.task_id} · ${task.state}. Pass task_id to every hunch_context and decision/correction/finding capture call. Before the final response, finish with hunch_task and include its contribution card. For checks use this exact installation (the global hunch binary may be stale): ${launcher.shell} task verify ${task.task_id} -- <command> [arguments]${launcher.note}. The default budget is 15 minutes; add --timeout <seconds> before -- for a longer suite.` }], structuredContent: { task, verification_argv: [...launcher.argv, "task", "verify", task.task_id, "--"] } };
91
+ // Compact by design (#370): the rules an agent needs to act, and exact
92
+ // identities; scope, title and timestamps stay in `hunch report <id> --json`.
93
+ return { content: [{ type: "text", text: `Task ${task.task_id} · ${task.state}. Pass task_id to hunch_context and every decision/correction/finding capture (a capture is not proof of a commit or push). To claim an application, copy occurrence_id, record_id and content_hash exactly from hunch_report(task_id) application_references, with the action you took; omit applications you did not make. Before the final response, finish with hunch_task (outcome "interrupted" if cut short) and include its contribution card verbatim, Evidence line and agent-reported label included, unless presentation_enabled is false. Never rerun an expensive check only for reporting; missing evidence stays unverified. Checks, with this exact installation (a global hunch may be stale): ${launcher.shell} task verify ${task.task_id} -- <command> [arguments]${launcher.note} (15-minute default; --timeout <seconds> before -- for longer).` }], structuredContent: { task: { task_id: task.task_id, state: task.state }, verification_argv: [...launcher.argv, "task", "verify", task.task_id, "--"] } };
92
94
  }
93
95
  if (!task_id)
94
96
  throw new Error("finish requires the exact task_id");
@@ -105,12 +107,10 @@ export function registerTaskReportTools(server, getRoot, getStore) {
105
107
  finishReportTask(root, task_id, outcome ?? "completed");
106
108
  // The finished task becomes graph memory through the normal capture path;
107
109
  // a failed write is disclosed on the card, never a reason to lose it.
108
- let graph = null;
109
110
  let graphNote = "";
110
111
  try {
111
112
  const saved = persistTaskRecord(root, getStore(), task_id);
112
113
  if (saved) {
113
- graph = { id: saved.record.id, home: saved.home, flushed: saved.flushed, changed: saved.changed };
114
114
  graphNote = `\nGraph ${saved.changed ? "saved" : "already saved"} as ${saved.record.id} (${saved.home}${saved.flushed ? `, ${saved.flushed}` : ""})`;
115
115
  }
116
116
  else {
@@ -125,7 +125,10 @@ export function registerTaskReportTools(server, getRoot, getStore) {
125
125
  // The HTML evidence view is rendered on demand (hunch_report(html: true),
126
126
  // `hunch report <id> --html`, or the VS Code view); finish writes no file.
127
127
  const card = renderTaskReport(report) + graphNote;
128
- return { content: [{ type: "text", text: show ? card : "Task report retained. Automatic presentation is disabled; omit the contribution card from the final response." }], structuredContent: { ...boundedTaskReportForHost(report), presentation_enabled: show, contribution_card: show ? card : null, report_path: null, graph_record: graph } };
128
+ // Compact by design (#370): the card (in structuredContent too — some hosts
129
+ // show only that) and what an agent acts on. Deliveries, checks, conformance
130
+ // and the graph record stay in hunch_report(task_id) / `hunch report <id> --json`.
131
+ return { content: [{ type: "text", text: show ? card : "Task report retained. Automatic presentation is disabled; omit the contribution card from the final response." }], structuredContent: { schema: "hunch.task-finish/1", task_id: report.task.task_id, state: report.task.state, presentation_enabled: show, contribution_card: show ? card : null, full_report: `hunch report ${report.task.task_id} --json` } };
129
132
  }
130
133
  catch (error) {
131
134
  const message = `Task report unavailable: ${error.message}`;
@@ -143,8 +146,8 @@ export function registerTaskReportTools(server, getRoot, getStore) {
143
146
  });
144
147
  server.registerTool("hunch_report", {
145
148
  title: "Inspect the evidence for Hunch's contribution to a task",
146
- description: "Read task reports: exact delivered memory, agent-reported applications, observed command results and explicit unknowns, as a bounded summary (identities and verdicts, not envelope text; the full report is `hunch report <id> --json`). Supply lesson for exact revision history across tasks. With neither task_id nor lesson, lists recent tasks without guessing which is yours. html writes a local private evidence view. Not a causal impact score, public export, or authority to execute verification commands.",
147
- inputSchema: { task_id: TaskIdSchema.optional(), lesson: LessonReferenceSchema.optional().describe("Exact kind and record_id, optionally content_hash, to inspect retained appearances across tasks. Partial indexing requires refreshing before pagination."), before: z.number().int().positive().optional(), html: z.boolean().optional(), cwd: z.string().optional() },
149
+ description: "Read task reports: exact delivered memory, agent-reported applications, observed command results and explicit unknowns, as a bounded summary (identities and verdicts, not envelope text; full report is `hunch report <id> --json`). Supply lesson for exact revision history across tasks. With neither task_id nor lesson, lists recent tasks without guessing which is yours. html writes a local private evidence view. Not a causal impact score, public export, or authority to execute verification commands.",
150
+ inputSchema: { task_id: TaskIdSchema.optional(), lesson: LessonReferenceSchema.optional().describe("Exact kind and record_id, optionally content_hash, for retained appearances across tasks. Partial indexing needs a refresh before pagination."), before: z.number().int().positive().optional(), html: z.boolean().optional(), cwd: z.string().optional() },
148
151
  }, async ({ task_id, lesson, before, html }) => {
149
152
  try {
150
153
  const root = getRoot();
@@ -3,6 +3,10 @@ export declare const MCP_TOOL_GROUPS: {
3
3
  readonly nuryel: readonly ["nuryel_capabilities", "nuryel_read", "nuryel_write", "nuryel_capture", "nuryel_capture_batch", "nuryel_subscribe", "nuryel_records"];
4
4
  /** Constitution G2/G3 experiment-track tools; the CLI remains the primary surface. */
5
5
  readonly "constitution-experiments": readonly ["hunch_constitution_g2_readiness", "hunch_constitution_g3_readiness", "hunch_constitution_g2_shadow_queue", "hunch_constitution_g2_operational_drill", "hunch_constitution_g2_candidates", "hunch_constitution_g2_behavior_candidates", "hunch_constitution_g2_behavior_replay", "hunch_constitution_g2_behavior_materialization", "hunch_constitution_g2_behavior_policy_materialize"];
6
+ /** Constitution policy review; on by default once the repo holds a policy. */
7
+ readonly policy: readonly ["hunch_policy_candidates", "hunch_policy_plan", "hunch_policy_card", "hunch_policy_shadow", "hunch_policy_proof", "hunch_policy_evaluate", "hunch_policy_upgrade_correction"];
8
+ /** Research, audit and sealed-proof tools outside the everyday loop; opt-in only. */
9
+ readonly analysis: readonly ["hunch_project_dna", "hunch_project_dna_delta", "hunch_project_match", "hunch_change_proof", "hunch_change_identity", "hunch_shortlist", "hunch_evidence_map", "hunch_wiki_status"];
6
10
  };
7
11
  export type McpToolGroup = keyof typeof MCP_TOOL_GROUPS;
8
12
  export declare const MCP_TOOL_GROUP_NAMES: McpToolGroup[];
@@ -22,9 +26,15 @@ export declare function parseToolsetSpec(spec: string): McpToolGroup[] | null;
22
26
  export declare function rootStoresState(root: string): boolean;
23
27
  /** `pinned` is `hunch mcp --root <dir>`: a server dedicated to one root, which is
24
28
  * how state partitions are served — including a brand-new partition that has no
25
- * state records yet and could never receive its first nuryel_write otherwise. */
29
+ * state records yet and could never receive its first nuryel_write otherwise.
30
+ * `hasPolicies` is the caller's policy evidence (public or private overlay); the
31
+ * first policy comes from `hunch index` or `hunch policy upgrade-correction`. The
32
+ * grounding block counts public policies only, so private-only policies register
33
+ * the tools without advertising them. The set is resolved once per process; a
34
+ * re-homed server keeps it until restart, as with the nuryel group. */
26
35
  export declare function resolveMcpToolset(root: string, opts?: {
27
36
  env?: NodeJS.ProcessEnv;
28
37
  configSpec?: string | null;
29
38
  pinned?: boolean;
39
+ hasPolicies?: boolean;
30
40
  }): McpToolset;
@@ -1,10 +1,11 @@
1
1
  /** Which MCP tool groups a server exposes. Every host shares one server, so the
2
- * selection surface is the same for all of them: 57 tools with ~24 KB of
3
- * descriptions dilute tool choice for everyday grounding. The everyday set is
4
- * the default; the two specialist groups are enabled by evidence (a root that
5
- * stores nuryel state records), by `.hunch/config.json` `mcp_tools`, or by the
6
- * `HUNCH_MCP_TOOLS` environment variable. Hidden tools are not registered at
7
- * all, so a client never sees them in tools/list. */
2
+ * selection surface is the same for all of them, and a host that loads every
3
+ * schema pays for each tool on every session (#368). The everyday set is the
4
+ * default; the specialist groups are enabled by evidence (a root that stores
5
+ * nuryel state records, a repo with Constitution policies), by
6
+ * `.hunch/config.json` `mcp_tools`, or by the `HUNCH_MCP_TOOLS` environment
7
+ * variable. Hidden tools are not registered at all, so a client never sees
8
+ * them in tools/list. */
8
9
  import { existsSync, readdirSync } from "node:fs";
9
10
  import { join } from "node:path";
10
11
  import { STATE_KINDS } from "../core/stateDelivery.js";
@@ -17,6 +18,16 @@ export const MCP_TOOL_GROUPS = {
17
18
  "hunch_constitution_g2_operational_drill", "hunch_constitution_g2_candidates", "hunch_constitution_g2_behavior_candidates",
18
19
  "hunch_constitution_g2_behavior_replay", "hunch_constitution_g2_behavior_materialization", "hunch_constitution_g2_behavior_policy_materialize",
19
20
  ],
21
+ /** Constitution policy review; on by default once the repo holds a policy. */
22
+ policy: [
23
+ "hunch_policy_candidates", "hunch_policy_plan", "hunch_policy_card", "hunch_policy_shadow",
24
+ "hunch_policy_proof", "hunch_policy_evaluate", "hunch_policy_upgrade_correction",
25
+ ],
26
+ /** Research, audit and sealed-proof tools outside the everyday loop; opt-in only. */
27
+ analysis: [
28
+ "hunch_project_dna", "hunch_project_dna_delta", "hunch_project_match", "hunch_change_proof",
29
+ "hunch_change_identity", "hunch_shortlist", "hunch_evidence_map", "hunch_wiki_status",
30
+ ],
20
31
  };
21
32
  export const MCP_TOOL_GROUP_NAMES = Object.keys(MCP_TOOL_GROUPS);
22
33
  /** Grammar shared by the env var and the config value: `all`, `core`, or a
@@ -45,7 +56,12 @@ export function rootStoresState(root) {
45
56
  }
46
57
  /** `pinned` is `hunch mcp --root <dir>`: a server dedicated to one root, which is
47
58
  * how state partitions are served — including a brand-new partition that has no
48
- * state records yet and could never receive its first nuryel_write otherwise. */
59
+ * state records yet and could never receive its first nuryel_write otherwise.
60
+ * `hasPolicies` is the caller's policy evidence (public or private overlay); the
61
+ * first policy comes from `hunch index` or `hunch policy upgrade-correction`. The
62
+ * grounding block counts public policies only, so private-only policies register
63
+ * the tools without advertising them. The set is resolved once per process; a
64
+ * re-homed server keeps it until restart, as with the nuryel group. */
49
65
  export function resolveMcpToolset(root, opts = {}) {
50
66
  const env = opts.env ?? process.env;
51
67
  let groups = null;
@@ -62,7 +78,11 @@ export function resolveMcpToolset(root, opts = {}) {
62
78
  source = "config";
63
79
  }
64
80
  if (!groups) {
65
- groups = opts.pinned || rootStoresState(root) ? ["nuryel"] : [];
81
+ groups = [];
82
+ if (opts.pinned || rootStoresState(root))
83
+ groups.push("nuryel");
84
+ if (opts.hasPolicies)
85
+ groups.push("policy");
66
86
  source = "default";
67
87
  }
68
88
  const set = new Set(groups);
@@ -66,6 +66,11 @@ export declare class HunchStore {
66
66
  /** How privateDir was selected. Multi-store consumers can use this instead of
67
67
  * inferring process-global routing from process.env. */
68
68
  readonly overlaySource: OverlayResolutionSource;
69
+ /** A per-worktree overlay pointer this machine's setup never registered (ignored). */
70
+ private ignoredLocalPointer;
71
+ /** The routing that ignored pointer asked for, so the refusal can name the exact
72
+ * setup command that re-registers it. */
73
+ private ignoredLocalPointerSetup;
69
74
  /** Present only when HUNCH_PRIVATE_DIR redirects this store away from the
70
75
  * repo/worktree-local pointer. Precedence is compatibility-sensitive and stays
71
76
  * env-first; making the redirection queryable removes the silent footgun. */
@@ -102,6 +107,9 @@ export declare class HunchStore {
102
107
  * Callers decide where it is safe to emit (CLI/MCP stderr, doctor output); the
103
108
  * store constructor stays side-effect-free for hooks and embedded consumers. */
104
109
  overlayResolutionWarning(teamConfigBypassed?: boolean): string | null;
110
+ /** Why an unregistered per-worktree pointer blocks this store, with the exact command
111
+ * that re-registers it. Never auto-re-registered: the pointer may be repository content. */
112
+ private unregisteredPointerMessage;
105
113
  /** Where a capture belongs: an explicit private:true always goes to the overlay
106
114
  * (putPrivate throws rather than silently landing public when none is configured);
107
115
  * otherwise the overlay in unified ("shared") mode, else the public store. ONE home
@@ -138,10 +146,26 @@ export declare class HunchStore {
138
146
  * accidentally target a public record with the same id. */
139
147
  deleteWhereItLives<K extends EntityKind>(kind: K, id: string): boolean;
140
148
  /** The private-overlay config from the gitignored `.hunch/local.json` (per-machine,
141
- * never committed). Tolerant: returns {} on missing/invalid so reads never crash.
149
+ * never committed). An absent file reads as {}; a present one that is unreadable,
150
+ * not valid JSON (a leading UTF-8 BOM is tolerated), not a JSON object, or a symlink
151
+ * throws: reading it as "no overlay" would silently open this repository PUBLIC.
142
152
  * `autoCommit` is tri-state: true/false when the file says so, undefined when unset.
143
153
  * `mode` records HOW the overlay was set up ("private" split vs "shared" unified). */
144
154
  private localConfig;
155
+ /** Why a present overlay pointer file blocks this store. */
156
+ private invalidPointerMessage;
157
+ /** The per-worktree pointer's shape, WITHOUT following any link: "absent" when neither
158
+ * it nor a symlink sits there, "plain" for a regular file inside a real `.hunch`
159
+ * directory of this checkout, and "unsafe" for anything else that exists (a symlinked
160
+ * `local.json`, a directory, or a pointer reachable only through a symlinked `.hunch`). */
161
+ private localPointerFileState;
162
+ /** Whether a per-worktree `.hunch/local.json` naming `privateDir` was registered by this
163
+ * machine's own setup rather than shipped with the checkout. The file is gitignored by
164
+ * convention only, so a repository or archive can still carry one. Every setup path
165
+ * (`hunch init`, `hunch private`, `hunch shared`, `hunch worktree`) also writes the
166
+ * pointer in the git common dir, which no clone or checkout content can supply; the
167
+ * per-worktree pointer is honored only when that pointer names the same store. */
168
+ private ownsLocalPointer;
145
169
  /** Merged read: public ∪ private overlay (private wins on id collision). Every
146
170
  * QUERY / REINDEX path uses this so MCP + the guards see private memory. Public-
147
171
  * artifact writers keep using this.json.loadAll (public-only) so a private record
@@ -11,14 +11,14 @@
11
11
  * - fragility(): ranked fragility report with evidence
12
12
  */
13
13
  import { resolve, join, dirname, isAbsolute, relative } from "node:path";
14
- import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
14
+ import { existsSync, lstatSync, readFileSync, realpathSync, statSync } from "node:fs";
15
15
  import { toPosixTarget, repoRelativeTarget, isRepoFile, hunchPathsForDir } from "../core/paths.js";
16
16
  import { ENTITY_KINDS } from "../core/types.js";
17
17
  import { openDb, withTx } from "./db.js";
18
18
  import { RESET_SQL, embedHash } from "./schema.js";
19
19
  import { selectEmbedder } from "./embedder.js";
20
20
  import { JsonStore } from "./jsonStore.js";
21
- import { gitCommonDir, gitWorktreeRoot, isolatedHeadSha, sameGitPublication, scopedLastChangeDates, } from "../extractors/git.js";
21
+ import { checkoutCommonDir, sameFilesystemEntry, gitWorktreeRoot, isolatedHeadSha, sameGitPublication, scopedLastChangeDates, } from "../extractors/git.js";
22
22
  import { pathMatchesGlob, pathsRelated, isIndexedPath, matchSymbolsTiered } from "../core/glob.js";
23
23
  import { cochangeFor } from "../core/cochange.js";
24
24
  import { withServedDatabase } from "../core/served.js";
@@ -64,6 +64,11 @@ export class HunchStore {
64
64
  /** How privateDir was selected. Multi-store consumers can use this instead of
65
65
  * inferring process-global routing from process.env. */
66
66
  overlaySource;
67
+ /** A per-worktree overlay pointer this machine's setup never registered (ignored). */
68
+ ignoredLocalPointer = null;
69
+ /** The routing that ignored pointer asked for, so the refusal can name the exact
70
+ * setup command that re-registers it. */
71
+ ignoredLocalPointerSetup = {};
67
72
  /** Present only when HUNCH_PRIVATE_DIR redirects this store away from the
68
73
  * repo/worktree-local pointer. Precedence is compatibility-sensitive and stays
69
74
  * env-first; making the redirection queryable removes the silent footgun. */
@@ -109,6 +114,13 @@ export class HunchStore {
109
114
  const resolvedEnvironmentDir = environmentDir
110
115
  ? resolve(this.paths.root, environmentDir)
111
116
  : undefined;
117
+ // Fail closed: a per-worktree pointer naming an overlay this machine never registered
118
+ // (a pre-v0.33 setup, or one shipped with the checkout) must not silently demote this
119
+ // store to public mode — the next hook capture or sync would publish memory meant for
120
+ // the overlay. Only explicit setup (or HUNCH_PRIVATE_DIR) re-establishes ownership.
121
+ if (this.ignoredLocalPointer && !configuredDir && !resolvedEnvironmentDir) {
122
+ throw new Error(this.unregisteredPointerMessage(this.ignoredLocalPointer));
123
+ }
112
124
  const priv = resolvedEnvironmentDir || configuredDir;
113
125
  this.overlaySource = resolvedEnvironmentDir
114
126
  ? "environment"
@@ -181,8 +193,22 @@ export class HunchStore {
181
193
  if (this.overlaySource === "environment" && teamConfigBypassed && this.privateDir) {
182
194
  return `HUNCH_PRIVATE_DIR bypasses .hunch/team.json and selects ${this.privateDir} in ${this.mode} mode; team-store auto-discovery is disabled for this process. Unset HUNCH_PRIVATE_DIR to use the advertised team store.`;
183
195
  }
196
+ if (this.ignoredLocalPointer && this.overlaySource === "local-config" && this.privateDir) {
197
+ return `.hunch/local.json points at ${this.ignoredLocalPointer}, which this machine's setup did not register, so that per-worktree pointer is ignored and the registered store ${this.privateDir} is used. Delete .hunch/local.json, or run \`hunch private\` (or \`hunch shared\`) here to change the registered store.`;
198
+ }
184
199
  return null;
185
200
  }
201
+ /** Why an unregistered per-worktree pointer blocks this store, with the exact command
202
+ * that re-registers it. Never auto-re-registered: the pointer may be repository content. */
203
+ unregisteredPointerMessage(dir) {
204
+ const { mode, autoCommit } = this.ignoredLocalPointerSetup;
205
+ const quoted = /^[\w@%+=:,./-]+$/.test(dir) ? dir : JSON.stringify(dir);
206
+ const command = `hunch ${mode === "shared" ? "shared" : "private"} ${quoted}${autoCommit === false ? " --no-auto-commit" : ""}`;
207
+ return `.hunch/local.json names a memory store at ${dir} that this machine's setup has not registered in the git common dir, ` +
208
+ "so Hunch refuses to open this repository's memory rather than silently writing captures to the public .hunch/. " +
209
+ `If you set up that store for THIS repository, register it by running \`${command}\` in this checkout. ` +
210
+ "Otherwise delete .hunch/local.json. (HUNCH_PRIVATE_DIR also selects an overlay explicitly for one process.)";
211
+ }
186
212
  /** Where a capture belongs: an explicit private:true always goes to the overlay
187
213
  * (putPrivate throws rather than silently landing public when none is configured);
188
214
  * otherwise the overlay in unified ("shared") mode, else the public store. ONE home
@@ -276,41 +302,140 @@ export class HunchStore {
276
302
  return this.json.delete(kind, id);
277
303
  }
278
304
  /** The private-overlay config from the gitignored `.hunch/local.json` (per-machine,
279
- * never committed). Tolerant: returns {} on missing/invalid so reads never crash.
305
+ * never committed). An absent file reads as {}; a present one that is unreadable,
306
+ * not valid JSON (a leading UTF-8 BOM is tolerated), not a JSON object, or a symlink
307
+ * throws: reading it as "no overlay" would silently open this repository PUBLIC.
280
308
  * `autoCommit` is tri-state: true/false when the file says so, undefined when unset.
281
309
  * `mode` records HOW the overlay was set up ("private" split vs "shared" unified). */
282
310
  localConfig() {
283
311
  const read = (file) => {
312
+ let raw;
284
313
  try {
285
- if (!existsSync(file))
314
+ raw = readFileSync(file, "utf8");
315
+ }
316
+ catch (error) {
317
+ if (["ENOENT", "ENOTDIR"].includes(error.code ?? ""))
286
318
  return {};
287
- const v = JSON.parse(readFileSync(file, "utf8"));
288
- const privateDir = typeof v.privateDir === "string" && v.privateDir.trim() ? v.privateDir.trim() : undefined;
289
- const mode = v.mode === "private" || v.mode === "shared" ? v.mode : undefined;
290
- return { privateDir, autoCommit: typeof v.autoCommit === "boolean" ? v.autoCommit : undefined, mode };
319
+ throw new Error(this.invalidPointerMessage(file, `cannot be read (${error.code ?? String(error)})`));
291
320
  }
292
- catch {
293
- return {};
321
+ let v;
322
+ try {
323
+ v = JSON.parse(raw.startsWith("\uFEFF") ? raw.slice(1) : raw);
324
+ }
325
+ catch (error) {
326
+ throw new Error(this.invalidPointerMessage(file, `is not valid JSON (${error instanceof Error ? error.message : String(error)})`));
327
+ }
328
+ if (!v || typeof v !== "object" || Array.isArray(v)) {
329
+ throw new Error(this.invalidPointerMessage(file, "is not a JSON object"));
294
330
  }
331
+ const o = v;
332
+ let privateDir;
333
+ if ("privateDir" in o) {
334
+ if (typeof o.privateDir !== "string" || !o.privateDir.trim()) {
335
+ throw new Error(this.invalidPointerMessage(file, "has an invalid privateDir"));
336
+ }
337
+ privateDir = o.privateDir.trim();
338
+ }
339
+ let mode;
340
+ if ("mode" in o) {
341
+ if (o.mode !== "private" && o.mode !== "shared") {
342
+ throw new Error(this.invalidPointerMessage(file, "has an invalid mode"));
343
+ }
344
+ mode = o.mode;
345
+ }
346
+ return { privateDir, autoCommit: typeof o.autoCommit === "boolean" ? o.autoCommit : undefined, mode };
295
347
  };
296
348
  // Per-worktree pointer first (explicit / back-compat). If it names no overlay, fall back to
297
349
  // the SHARED pointer in the git common dir — identical across ALL worktrees, so a freshly
298
350
  // added worktree (whose gitignored .hunch/local.json doesn't exist yet) still auto-discovers
299
- // the same memory. The git lookup runs ONLY when the cheap per-worktree read is empty, keeping
300
- // it off the hot path for already-configured checkouts.
301
- const perWorktree = read(join(this.paths.hunch, "local.json"));
302
- if (perWorktree.privateDir)
303
- return perWorktree;
304
- const common = gitCommonDir(this.paths.root);
351
+ // the same memory. A per-worktree pointer that names an overlay must be one this machine's
352
+ // setup registered (ownsLocalPointer) before it wins.
353
+ const perWorktreeFile = join(this.paths.hunch, "local.json");
354
+ const perWorktreeState = this.localPointerFileState(perWorktreeFile);
355
+ if (perWorktreeState === "unsafe") {
356
+ throw new Error(`${perWorktreeFile} (or its .hunch directory) is a symlink or not a plain file; a symlinked .hunch/local.json is never followed, ` +
357
+ "so Hunch refuses to open this repository's memory rather than silently writing captures to the public .hunch/. " +
358
+ "Re-create it as a plain file, or run `hunch private <dir>` here, or delete it.");
359
+ }
360
+ let perWorktree = perWorktreeState === "plain" ? read(perWorktreeFile) : {};
361
+ // The shared pointer is trusted only in a repository Git reached through this
362
+ // checkout's own `.git` entry, and only with the absolute path setup always writes.
363
+ const common = checkoutCommonDir(this.paths.root);
364
+ let registered = {};
305
365
  if (common) {
306
- const shared = read(join(common, "hunch", "local.json"));
307
- // A per-worktree `autoCommit: false` (hunch init --no-auto-commit) is an explicit
308
- // local opt-out — it must survive the fall-through to the shared overlay pointer.
309
- if (shared.privateDir)
310
- return { ...shared, autoCommit: perWorktree.autoCommit ?? shared.autoCommit };
366
+ const commonFile = join(common, "hunch", "local.json");
367
+ const commonState = this.localPointerFileState(commonFile);
368
+ if (commonState === "unsafe") {
369
+ throw new Error(`${commonFile} (or its .hunch directory) is a symlink or not a plain file; a symlinked local.json is never followed, ` +
370
+ "so Hunch refuses to open this repository's memory rather than silently writing captures to the public .hunch/. " +
371
+ "Re-create it as a plain file, or run `hunch private <dir>` (or `hunch shared`) here.");
372
+ }
373
+ registered = commonState === "plain" ? read(commonFile) : {};
374
+ if (registered.privateDir && !isAbsolute(registered.privateDir)) {
375
+ throw new Error(this.invalidPointerMessage(commonFile, "has a non-absolute privateDir"));
376
+ }
311
377
  }
378
+ const shared = registered.privateDir ? registered : {};
379
+ if (perWorktree.privateDir && !this.ownsLocalPointer(perWorktree.privateDir, shared.privateDir)) {
380
+ this.ignoredLocalPointer = resolve(this.paths.root, perWorktree.privateDir);
381
+ this.ignoredLocalPointerSetup = { mode: perWorktree.mode, autoCommit: perWorktree.autoCommit };
382
+ perWorktree = {}; // not registered by this machine's setup: ignore every field it carries
383
+ }
384
+ // The registered pointer decides the store and its routing mode. A per-worktree
385
+ // `autoCommit: false` (hunch init --no-auto-commit) is an explicit local opt-out that
386
+ // survives; a checkout can never turn auto-commit on.
387
+ if (shared.privateDir)
388
+ return { ...shared, autoCommit: perWorktree.autoCommit === false ? false : shared.autoCommit };
312
389
  return perWorktree;
313
390
  }
391
+ /** Why a present overlay pointer file blocks this store. */
392
+ invalidPointerMessage(file, problem) {
393
+ return `${file} ${problem}, so Hunch refuses to open this repository's memory rather than silently ` +
394
+ "writing captures to the public .hunch/. Fix the file, or delete it and re-run `hunch private <dir>` (or `hunch shared`) here.";
395
+ }
396
+ /** The per-worktree pointer's shape, WITHOUT following any link: "absent" when neither
397
+ * it nor a symlink sits there, "plain" for a regular file inside a real `.hunch`
398
+ * directory of this checkout, and "unsafe" for anything else that exists (a symlinked
399
+ * `local.json`, a directory, or a pointer reachable only through a symlinked `.hunch`). */
400
+ localPointerFileState(file) {
401
+ const present = (path) => {
402
+ try {
403
+ lstatSync(path);
404
+ return true;
405
+ }
406
+ catch {
407
+ return false;
408
+ }
409
+ };
410
+ let hunchStat;
411
+ try {
412
+ hunchStat = lstatSync(dirname(file));
413
+ }
414
+ catch {
415
+ return "absent";
416
+ }
417
+ if (hunchStat.isSymbolicLink())
418
+ return present(file) ? "unsafe" : "absent";
419
+ if (!hunchStat.isDirectory())
420
+ return "absent";
421
+ let stat;
422
+ try {
423
+ stat = lstatSync(file);
424
+ }
425
+ catch {
426
+ return "absent";
427
+ }
428
+ return !stat.isSymbolicLink() && stat.isFile() ? "plain" : "unsafe";
429
+ }
430
+ /** Whether a per-worktree `.hunch/local.json` naming `privateDir` was registered by this
431
+ * machine's own setup rather than shipped with the checkout. The file is gitignored by
432
+ * convention only, so a repository or archive can still carry one. Every setup path
433
+ * (`hunch init`, `hunch private`, `hunch shared`, `hunch worktree`) also writes the
434
+ * pointer in the git common dir, which no clone or checkout content can supply; the
435
+ * per-worktree pointer is honored only when that pointer names the same store. */
436
+ ownsLocalPointer(privateDir, sharedDir) {
437
+ return !!sharedDir && sameFilesystemEntry(resolve(this.paths.root, privateDir), sharedDir);
438
+ }
314
439
  /** Merged read: public ∪ private overlay (private wins on id collision). Every
315
440
  * QUERY / REINDEX path uses this so MCP + the guards see private memory. Public-
316
441
  * artifact writers keep using this.json.loadAll (public-only) so a private record
@@ -39,7 +39,29 @@ export const MAX_JSON_RECORD_BYTES = 8 * 1024 * 1024;
39
39
  export const MAX_JSON_INDEX_BYTES = 256 * 1024 * 1024;
40
40
  export const MAX_JSON_MANIFEST_BYTES = 64 * 1024;
41
41
  export const MAX_JSON_DIRECTORY_ENTRIES_PER_KIND = 100_000;
42
+ /** Reads of ownership metadata that a concurrent release + re-acquire can make
43
+ * fail once. A real refusal (hardlink, symlink, oversize) is persistent and
44
+ * still throws after the last attempt. */
45
+ const RMW_OWNER_READ_ATTEMPTS = 3;
42
46
  function readRmwOwner(lock) {
47
+ for (let attempt = 1;; attempt++) {
48
+ try {
49
+ return readRmwOwnerOnce(lock);
50
+ }
51
+ catch (error) {
52
+ // Residual of the excuse in readRmwOwnerOnce (issue #293, CI run
53
+ // 35843843780): a release AND re-acquire between the reader's failure and
54
+ // its re-lstat leaves the next holder's owner file in place, so a refusal
55
+ // caused only by the race looked real. Re-reading from scratch settles it:
56
+ // the race does not repeat on demand, an unsafe file does. Retrying never
57
+ // licenses a steal: the caller still judges the owner it finally reads.
58
+ if (attempt >= RMW_OWNER_READ_ATTEMPTS)
59
+ throw error;
60
+ Atomics.wait(RMW_LOCK_WAITER, 0, 0, 5);
61
+ }
62
+ }
63
+ }
64
+ function readRmwOwnerOnce(lock) {
43
65
  let text;
44
66
  try {
45
67
  text = readStoreArtifact(lock, ["owner.tmp.json"], 4096);
@@ -59,10 +81,9 @@ function readRmwOwner(lock) {
59
81
  // requires the lock itself to be either gone (the release we are modelling)
60
82
  // or a REAL directory that is not a link.
61
83
  //
62
- // Residual accepted: a release + re-acquire between the reader's failure and
63
- // this re-lstat makes a REAL refusal (hardlink/oversize) throw for a file
64
- // that already belongs to the next holder. That only turns a retry into an
65
- // error — it never licenses a steal, so the safe direction is preserved.
84
+ // A release + re-acquire between the reader's failure and this re-lstat
85
+ // finds the next holder's owner file and throws here; readRmwOwner re-reads
86
+ // from scratch before letting that surface.
66
87
  let ownerMissing = false;
67
88
  try {
68
89
  lstatSync(join(lock, "owner.tmp.json"));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davesheffer/hunch",
3
- "version": "1.41.6",
3
+ "version": "1.42.0",
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.41.6",
10
+ "version": "1.42.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.41.6",
16
+ "version": "1.42.0",
17
17
  "runtimeHint": "npx",
18
18
  "packageArguments": [
19
19
  {