@davesheffer/hunch 1.41.5 → 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.
- package/README.md +1 -1
- package/dist/cli/index.js +402 -154
- package/dist/constitution/experiment.d.ts +3 -3
- package/dist/constitution/g3.d.ts +1 -1
- package/dist/core/docanchors.js +2 -1
- package/dist/core/eol.d.ts +10 -0
- package/dist/core/eol.js +23 -0
- package/dist/core/footprint.d.ts +15 -0
- package/dist/core/footprint.js +167 -0
- package/dist/core/groundingLag.d.ts +15 -0
- package/dist/core/groundingLag.js +27 -0
- package/dist/core/groundingMerge.js +8 -6
- package/dist/core/hookText.d.ts +4 -0
- package/dist/core/hookText.js +8 -0
- package/dist/core/pipeline.d.ts +28 -0
- package/dist/core/pipeline.js +50 -0
- package/dist/core/shellwrites.d.ts +7 -0
- package/dist/core/shellwrites.js +131 -0
- package/dist/core/siblingfix.d.ts +124 -0
- package/dist/core/siblingfix.js +814 -0
- package/dist/core/taskReportHook.d.ts +1 -1
- package/dist/core/taskReportHook.js +19 -3
- package/dist/extractors/git.d.ts +31 -0
- package/dist/extractors/git.js +180 -1
- package/dist/extractors/k8sManifest.js +15 -42
- package/dist/extractors/nativeTreeSitter.d.ts +2 -1
- package/dist/extractors/nativeTreeSitter.js +128 -30
- package/dist/integrations/claudemd.d.ts +20 -2
- package/dist/integrations/claudemd.js +79 -53
- package/dist/integrations/gitignore.js +2 -1
- package/dist/integrations/hooks.js +5 -4
- package/dist/integrations/providers.d.ts +9 -5
- package/dist/integrations/providers.js +34 -22
- package/dist/integrations/team.d.ts +24 -4
- package/dist/integrations/team.js +154 -16
- package/dist/integrations/worktree.d.ts +3 -2
- package/dist/integrations/worktree.js +7 -4
- package/dist/mcp/server.d.ts +489 -0
- package/dist/mcp/server.js +208 -62
- package/dist/mcp/taskReportTools.js +12 -9
- package/dist/mcp/toolset.d.ts +11 -1
- package/dist/mcp/toolset.js +28 -8
- package/dist/store/hunchStore.d.ts +25 -1
- package/dist/store/hunchStore.js +146 -21
- package/dist/store/jsonStore.js +25 -4
- package/package.json +1 -1
- 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
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
|
147
|
-
inputSchema: { task_id: TaskIdSchema.optional(), lesson: LessonReferenceSchema.optional().describe("Exact kind and record_id, optionally content_hash,
|
|
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();
|
package/dist/mcp/toolset.d.ts
CHANGED
|
@@ -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;
|
package/dist/mcp/toolset.js
CHANGED
|
@@ -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
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* `
|
|
7
|
-
* all, so a client never sees
|
|
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 =
|
|
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).
|
|
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
|
package/dist/store/hunchStore.js
CHANGED
|
@@ -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 {
|
|
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).
|
|
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
|
-
|
|
314
|
+
raw = readFileSync(file, "utf8");
|
|
315
|
+
}
|
|
316
|
+
catch (error) {
|
|
317
|
+
if (["ENOENT", "ENOTDIR"].includes(error.code ?? ""))
|
|
286
318
|
return {};
|
|
287
|
-
|
|
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
|
-
|
|
293
|
-
|
|
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.
|
|
300
|
-
//
|
|
301
|
-
const
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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
|
package/dist/store/jsonStore.js
CHANGED
|
@@ -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
|
-
//
|
|
63
|
-
//
|
|
64
|
-
//
|
|
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
package/server.json
CHANGED
|
@@ -7,13 +7,13 @@
|
|
|
7
7
|
"source": "github"
|
|
8
8
|
},
|
|
9
9
|
"websiteUrl": "https://www.hunchmemory.com",
|
|
10
|
-
"version": "1.
|
|
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.
|
|
16
|
+
"version": "1.42.0",
|
|
17
17
|
"runtimeHint": "npx",
|
|
18
18
|
"packageArguments": [
|
|
19
19
|
{
|