@davesheffer/hunch 1.32.4 → 1.32.7
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 +2 -2
- package/dist/cli/index.js +1 -1
- package/dist/constitution/behaviorEvaluator.js +1 -1
- package/dist/constitution/repository.d.ts +1 -0
- package/dist/constitution/repository.js +68 -42
- package/dist/core/agenthook.js +42 -9
- package/dist/core/checkreport.js +1 -1
- package/dist/core/events.js +19 -3
- package/dist/core/hookObservations.d.ts +4 -1
- package/dist/core/hookObservations.js +16 -3
- package/dist/core/io.js +30 -19
- package/dist/core/jsonc.js +13 -3
- package/dist/core/storeArtifact.d.ts +7 -0
- package/dist/core/storeArtifact.js +62 -0
- package/dist/extractors/git.js +5 -1
- package/dist/integrations/claudeConfig.js +20 -3
- package/dist/integrations/health.js +16 -2
- package/dist/integrations/providers.js +61 -16
- package/dist/integrations/scaffold.js +17 -3
- package/dist/mcp/server.js +13 -6
- package/dist/serve/app.js +9 -6
- package/dist/serve/writelock.js +8 -2
- package/dist/store/changeLedger.js +7 -6
- package/dist/store/jsonStore.d.ts +3 -3
- package/dist/store/jsonStore.js +65 -14
- package/package.json +1 -1
- package/server.json +2 -2
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { lstatSync, realpathSync } from "node:fs";
|
|
2
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
3
|
+
import { createRepoFileReader } from "./safeRepoFile.js";
|
|
4
|
+
/** Containment for store artifacts outside JsonStore's entity registry (ledgers,
|
|
5
|
+
* policy proofs, and local audit logs). The explicitly selected store's parent
|
|
6
|
+
* may have a platform alias, but no component inside that store may be a link. */
|
|
7
|
+
export function storeArtifactPath(hunchDir, ...parts) {
|
|
8
|
+
let path = resolve(hunchDir);
|
|
9
|
+
let parent;
|
|
10
|
+
try {
|
|
11
|
+
parent = realpathSync(dirname(path));
|
|
12
|
+
}
|
|
13
|
+
catch (error) {
|
|
14
|
+
if (error.code !== "ENOENT")
|
|
15
|
+
throw error;
|
|
16
|
+
parent = dirname(path);
|
|
17
|
+
}
|
|
18
|
+
let expected = join(parent, basename(path));
|
|
19
|
+
const check = (directory) => {
|
|
20
|
+
try {
|
|
21
|
+
const stat = lstatSync(path);
|
|
22
|
+
if (stat.isSymbolicLink() || (directory ? !stat.isDirectory() : !stat.isFile() && !stat.isDirectory())
|
|
23
|
+
|| (stat.isFile() && stat.nlink !== 1) || realpathSync(path) !== expected) {
|
|
24
|
+
throw new Error(`unsafe store artifact path ${path}: symlinks, hard links and special files are refused`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
if (error.code !== "ENOENT")
|
|
29
|
+
throw error;
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
check(true);
|
|
33
|
+
for (let index = 0; index < parts.length; index++) {
|
|
34
|
+
const part = parts[index];
|
|
35
|
+
if (!/^[A-Za-z0-9._-]+$/.test(part) || part === "." || part === "..")
|
|
36
|
+
throw new Error("unsafe store artifact path component");
|
|
37
|
+
path = join(path, part);
|
|
38
|
+
expected = join(expected, part);
|
|
39
|
+
check(index < parts.length - 1);
|
|
40
|
+
}
|
|
41
|
+
return path;
|
|
42
|
+
}
|
|
43
|
+
/** A missing artifact is distinct from an unsafe/unreadable artifact. Reuse the
|
|
44
|
+
* scanner's bounded descriptor read; policy and ledger corruption must fail visibly. */
|
|
45
|
+
export function readStoreArtifact(hunchDir, parts, maxBytes = 256 * 1024 * 1024) {
|
|
46
|
+
const file = storeArtifactPath(hunchDir, ...parts);
|
|
47
|
+
try {
|
|
48
|
+
if (!lstatSync(file).isFile())
|
|
49
|
+
throw new Error(`unsafe store artifact path ${file}: expected an ordinary file`);
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
if (error.code === "ENOENT")
|
|
53
|
+
return null;
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
56
|
+
const text = createRepoFileReader(dirname(resolve(hunchDir)), { maxBytes })(file);
|
|
57
|
+
if (text === null)
|
|
58
|
+
throw new Error(`unsafe or unreadable store artifact ${file}`);
|
|
59
|
+
storeArtifactPath(hunchDir, ...parts);
|
|
60
|
+
return text;
|
|
61
|
+
}
|
|
62
|
+
//# sourceMappingURL=storeArtifact.js.map
|
package/dist/extractors/git.js
CHANGED
|
@@ -1769,7 +1769,11 @@ function waitForCommitLockHandoff(lock, first, timeoutMs) {
|
|
|
1769
1769
|
while (Date.now() < deadline) {
|
|
1770
1770
|
if (attempt.state === "acquired")
|
|
1771
1771
|
return true;
|
|
1772
|
-
|
|
1772
|
+
// Once the first snapshot proved a live owner, an owner-less snapshot can be
|
|
1773
|
+
// the normal release window: recursive cleanup removes owner-<pid> before
|
|
1774
|
+
// removing the outer lock directory. Keep the bounded handoff wait through
|
|
1775
|
+
// that transient state instead of reporting a false busy/no-op result.
|
|
1776
|
+
if (attempt.state === "held-live" && attempt.ownerPid === process.pid)
|
|
1773
1777
|
return false;
|
|
1774
1778
|
Atomics.wait(sleeper, 0, 0, Math.min(25, deadline - Date.now()));
|
|
1775
1779
|
attempt = acquireCommitLock(lock);
|
|
@@ -146,12 +146,29 @@ export function healClaudeConfigCaseSplit(opts = {}) {
|
|
|
146
146
|
if (keys.length < 2)
|
|
147
147
|
continue; // no casing split for this directory
|
|
148
148
|
keys.sort(); // deterministic first-wins union
|
|
149
|
-
|
|
149
|
+
// A root object can still contain malformed project blocks. Do not replace a
|
|
150
|
+
// user's scalar/array block, or normalize malformed nested MCP/list fields,
|
|
151
|
+
// merely because another drive-letter casing is valid.
|
|
152
|
+
for (const key of keys) {
|
|
153
|
+
const block = projects[key];
|
|
154
|
+
if (!isPlainObject(block)) {
|
|
155
|
+
throw new Error(`refusing to modify ${file}: project ${key} is not an object; fix it, then re-run.`);
|
|
156
|
+
}
|
|
157
|
+
const mcp = block.mcpServers;
|
|
158
|
+
if (mcp !== undefined && !isPlainObject(mcp)) {
|
|
159
|
+
throw new Error(`refusing to modify ${file}: project ${key}.mcpServers must be an object; fix it, then re-run.`);
|
|
160
|
+
}
|
|
161
|
+
for (const listKey of ["enabledMcpjsonServers", "disabledMcpjsonServers"]) {
|
|
162
|
+
const list = block[listKey];
|
|
163
|
+
if (list !== undefined && (!Array.isArray(list) || !list.every((value) => typeof value === "string"))) {
|
|
164
|
+
throw new Error(`refusing to modify ${file}: project ${key}.${listKey} must be a string array; fix it, then re-run.`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
const blocks = keys.map((k) => projects[k]);
|
|
150
169
|
const u = unionConfig(blocks);
|
|
151
170
|
let groupChanged = false;
|
|
152
171
|
for (const k of keys) {
|
|
153
|
-
if (!isPlainObject(projects[k]))
|
|
154
|
-
projects[k] = {};
|
|
155
172
|
if (applyUnion(projects[k], u))
|
|
156
173
|
groupChanged = true;
|
|
157
174
|
}
|
|
@@ -14,6 +14,8 @@ import { readHookObservations } from "../core/hookObservations.js";
|
|
|
14
14
|
const CAPABILITY_EVIDENCE = {
|
|
15
15
|
context: ["SessionStart", "UserPromptSubmit"],
|
|
16
16
|
"edit-blocking": ["PreToolUse"],
|
|
17
|
+
// A successful PostToolUse only proves that the post hook ran. A provider
|
|
18
|
+
// may instead include an explicit failure status in that same event.
|
|
17
19
|
"failure-capture": ["PostToolUseFailure", "PostToolUse"],
|
|
18
20
|
compaction: ["PreCompact"],
|
|
19
21
|
};
|
|
@@ -34,6 +36,15 @@ const object = (v) => {
|
|
|
34
36
|
throw new Error("expected a configuration object");
|
|
35
37
|
return v;
|
|
36
38
|
};
|
|
39
|
+
function evidenceFor(capability, harness, observed, expectedVersion) {
|
|
40
|
+
const matches = observed.filter(o => o.provider === harness && (capability !== "failure-capture"
|
|
41
|
+
? CAPABILITY_EVIDENCE[capability].includes(o.event)
|
|
42
|
+
: o.event === "PostToolUseFailure" || (o.event === "PostToolUse" && o.outcome === "failure")));
|
|
43
|
+
// A stale row must not hide a fresh result recorded by a newer hook. Keep a
|
|
44
|
+
// matching stale row as the fallback so the caller can explain why it is not
|
|
45
|
+
// verified rather than treating the evidence as absent.
|
|
46
|
+
return matches.find(o => o.version === expectedVersion && Date.now() - Date.parse(o.at) <= OBSERVATION_FRESH_MS) ?? matches[0];
|
|
47
|
+
}
|
|
37
48
|
function strings(value) {
|
|
38
49
|
if (typeof value === "string")
|
|
39
50
|
return [value];
|
|
@@ -52,7 +63,7 @@ function hookCommands(value) {
|
|
|
52
63
|
if (obj.enabled === false || (obj.type !== undefined && obj.type !== "command"))
|
|
53
64
|
return [];
|
|
54
65
|
const command = typeof obj.command === "string" ? obj.command : "";
|
|
55
|
-
const own = /(?:@davesheffer\/hunch|[\\/]index\.(?:js|ts))/.test(command)
|
|
66
|
+
const own = /(?:@davesheffer\/hunch|(?:dist|src)[\\/]+cli[\\/]+index\.(?:js|ts))/.test(command)
|
|
56
67
|
&& /\s"?hook"?(?:\s+"?--provider"?\s+"?[a-z]+"?)?\s*$/.test(command);
|
|
57
68
|
return [...(own ? [command] : []), ...(obj.hooks ? hookCommands(obj.hooks) : [])];
|
|
58
69
|
}
|
|
@@ -192,7 +203,7 @@ export function inspectIntegrations(root, selected) {
|
|
|
192
203
|
else {
|
|
193
204
|
// Verified only by an event the host actually delivered, on the expected
|
|
194
205
|
// version, recently. Matchers and tool coverage beyond that event stay unproven.
|
|
195
|
-
const hit =
|
|
206
|
+
const hit = evidenceFor(capability, harness, observed, report.expectedVersion);
|
|
196
207
|
const fresh = hit !== undefined && Date.now() - Date.parse(hit.at) <= OBSERVATION_FRESH_MS;
|
|
197
208
|
if (hit && fresh && hit.version === report.expectedVersion) {
|
|
198
209
|
status.status = "verified";
|
|
@@ -201,6 +212,9 @@ export function inspectIntegrations(root, selected) {
|
|
|
201
212
|
else if (hit) {
|
|
202
213
|
status.detail = `${event} configured; last observed ${hit.at} on Hunch ${hit.version}${hit.version === report.expectedVersion ? " (stale)" : `, not the expected ${report.expectedVersion}`}`;
|
|
203
214
|
}
|
|
215
|
+
else if (capability === "failure-capture" && observed.some(o => o.provider === harness && o.event === "PostToolUse")) {
|
|
216
|
+
status.detail = `${event} configured; PostToolUse was observed, but no explicit failed-tool event was delivered, so failure capture remains untested`;
|
|
217
|
+
}
|
|
204
218
|
else {
|
|
205
219
|
status.detail = `${event} configured; host delivery, matchers, and tool coverage are not verified`;
|
|
206
220
|
}
|
|
@@ -24,6 +24,7 @@ import { join, dirname } from "node:path";
|
|
|
24
24
|
import { renderHunchSection, stripManagedSection, upsertSection, updateClaudeMd } from "./claudemd.js";
|
|
25
25
|
import { headFileContent, isGitCleanPath } from "../extractors/git.js";
|
|
26
26
|
import { parseJsonc } from "../core/jsonc.js";
|
|
27
|
+
import { parse as parseToml } from "smol-toml";
|
|
27
28
|
/** Read a JSON/JSONC object. Returns {} only for an ABSENT or empty file. A
|
|
28
29
|
* non-empty file we cannot parse THROWS — overwriting it would silently wipe the
|
|
29
30
|
* user's other MCP servers. */
|
|
@@ -65,6 +66,18 @@ function writeJson(file, obj) {
|
|
|
65
66
|
writeFileAtomic(file, JSON.stringify(obj, null, 2) + "\n");
|
|
66
67
|
return file;
|
|
67
68
|
}
|
|
69
|
+
/** A present managed container must have the object shape its host expects.
|
|
70
|
+
* Arrays and primitives are valid JSON/TOML values, but assigning properties to
|
|
71
|
+
* them either throws or silently disappears during JSON serialization. Refuse
|
|
72
|
+
* those shapes instead of claiming an install succeeded. */
|
|
73
|
+
function objectField(json, key, file) {
|
|
74
|
+
const value = json[key];
|
|
75
|
+
if (value === undefined)
|
|
76
|
+
return {};
|
|
77
|
+
if (value && typeof value === "object" && !Array.isArray(value))
|
|
78
|
+
return value;
|
|
79
|
+
throw new Error(`refusing to edit ${file}: ${key} must be a JSON object when present; fix it, then re-run.`);
|
|
80
|
+
}
|
|
68
81
|
/** Quote one argv token only when it needs quoting. These commands are run by
|
|
69
82
|
* whatever shell the host assistant uses, which on Windows is PowerShell — and
|
|
70
83
|
* PowerShell parses a QUOTED first token as a string expression, not a command,
|
|
@@ -103,7 +116,10 @@ function isHunchProviderHook(entry) {
|
|
|
103
116
|
// the bare tail must still carry --provider to match; only the LEGACY
|
|
104
117
|
// fully-quoted form (written before this quoting fix, and by hunch versions
|
|
105
118
|
// that predate --provider) may omit it, and its quotes keep it unambiguous.
|
|
106
|
-
|
|
119
|
+
// Source-checkout invocations generated by resolveInvocation point at the
|
|
120
|
+
// CLI entry specifically. A generic `other-tool/index.js hook` is foreign
|
|
121
|
+
// even when it happens to use Hunch's old command tail.
|
|
122
|
+
const launcher = /@davesheffer\/hunch|(?:dist|src)[\\/]+cli[\\/]+index\.(?:js|ts)(?=["\s]|$)/.test(command);
|
|
107
123
|
const legacyTail = /\s"hook"(?:\s+"--provider"\s+"[a-z]+")?\s*$/.test(command);
|
|
108
124
|
const tail = /\s"?hook"?\s+"?--provider"?\s+"?[a-z]+"?\s*$/.test(command);
|
|
109
125
|
return launcher && (legacyTail || tail);
|
|
@@ -112,11 +128,13 @@ function isHunchProviderHook(entry) {
|
|
|
112
128
|
* We replace only old Hunch commands and leave every foreign hook in place. */
|
|
113
129
|
function writeHookConfig(file, entries) {
|
|
114
130
|
const json = readJsonObj(file);
|
|
115
|
-
const hooks = json
|
|
116
|
-
? json.hooks
|
|
117
|
-
: {};
|
|
131
|
+
const hooks = objectField(json, "hooks", file);
|
|
118
132
|
for (const [event, next] of Object.entries(entries)) {
|
|
119
|
-
const
|
|
133
|
+
const existing = hooks[event];
|
|
134
|
+
if (existing !== undefined && !Array.isArray(existing)) {
|
|
135
|
+
throw new Error(`refusing to edit ${file}: hooks.${event} must be an array when present; fix it, then re-run.`);
|
|
136
|
+
}
|
|
137
|
+
const old = (existing ?? []);
|
|
120
138
|
hooks[event] = [...old.filter((entry) => !isHunchProviderHook(entry)), ...next];
|
|
121
139
|
}
|
|
122
140
|
json.hooks = hooks;
|
|
@@ -126,7 +144,7 @@ function writeHookConfig(file, entries) {
|
|
|
126
144
|
export function writeCursorMcp(root, inv) {
|
|
127
145
|
const file = join(root, ".cursor", "mcp.json");
|
|
128
146
|
const json = readJsonObj(file);
|
|
129
|
-
json.mcpServers = json
|
|
147
|
+
json.mcpServers = objectField(json, "mcpServers", file);
|
|
130
148
|
json.mcpServers.hunch = { command: inv.command, args: [...inv.args, "mcp"] };
|
|
131
149
|
return writeJson(file, json);
|
|
132
150
|
}
|
|
@@ -135,7 +153,7 @@ export function writeCursorMcp(root, inv) {
|
|
|
135
153
|
export function writeVscodeMcp(root, inv) {
|
|
136
154
|
const file = join(root, ".vscode", "mcp.json");
|
|
137
155
|
const json = readJsonObj(file);
|
|
138
|
-
json.servers = json
|
|
156
|
+
json.servers = objectField(json, "servers", file);
|
|
139
157
|
json.servers.hunch = { type: "stdio", command: inv.command, args: [...inv.args, "mcp"] };
|
|
140
158
|
return writeJson(file, json);
|
|
141
159
|
}
|
|
@@ -166,7 +184,7 @@ export function writeAntigravityMcp(inv, home = homedir()) {
|
|
|
166
184
|
if (!file)
|
|
167
185
|
return null;
|
|
168
186
|
const json = readJsonObj(file);
|
|
169
|
-
json.mcpServers = json
|
|
187
|
+
json.mcpServers = objectField(json, "mcpServers", file);
|
|
170
188
|
json.mcpServers.hunch = { command: inv.command, args: [...inv.args, "mcp"] };
|
|
171
189
|
return writeJson(file, json);
|
|
172
190
|
}
|
|
@@ -176,7 +194,7 @@ export function writeAntigravityMcp(inv, home = homedir()) {
|
|
|
176
194
|
export function writeAntigravityWorkspaceMcp(root, inv) {
|
|
177
195
|
const file = join(root, ".agents", "mcp_config.json");
|
|
178
196
|
const json = readJsonObj(file);
|
|
179
|
-
json.mcpServers = json
|
|
197
|
+
json.mcpServers = objectField(json, "mcpServers", file);
|
|
180
198
|
json.mcpServers.hunch = { command: inv.command, args: [...inv.args, "mcp"] };
|
|
181
199
|
return writeJson(file, json);
|
|
182
200
|
}
|
|
@@ -213,9 +231,30 @@ export function writeCodexConfig(root, inv) {
|
|
|
213
231
|
if (/^\s*\[mcp_servers\.hunch\]/m.test(base)) {
|
|
214
232
|
throw new Error(`refusing to edit ${file}: it already defines [mcp_servers.hunch] outside Hunch's managed block. Remove it, then re-run.`);
|
|
215
233
|
}
|
|
234
|
+
// Validate the complete original document before stripping any managed block.
|
|
235
|
+
// Even Hunch-owned malformed content must be preserved until the user reviews
|
|
236
|
+
// it; otherwise a repair can silently erase an unparseable configuration.
|
|
237
|
+
if (content.trim()) {
|
|
238
|
+
try {
|
|
239
|
+
parseToml(content);
|
|
240
|
+
}
|
|
241
|
+
catch (e) {
|
|
242
|
+
throw new Error(`refusing to overwrite ${file}: could not parse TOML (${e.message}). Fix it, then re-run.`);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
216
245
|
base = base.trimEnd();
|
|
246
|
+
const next = base ? `${base}\n\n${block}\n` : `${block}\n`;
|
|
247
|
+
// A syntactically valid parent value can still make the appended table
|
|
248
|
+
// illegal (`mcp_servers = 42` followed by `[mcp_servers.hunch]`). Validate
|
|
249
|
+
// the exact candidate before touching the user's file.
|
|
250
|
+
try {
|
|
251
|
+
parseToml(next);
|
|
252
|
+
}
|
|
253
|
+
catch (e) {
|
|
254
|
+
throw new Error(`refusing to overwrite ${file}: resulting TOML is invalid (${e.message}). Fix it, then re-run.`);
|
|
255
|
+
}
|
|
217
256
|
mkdirSync(dirname(file), { recursive: true });
|
|
218
|
-
writeFileAtomic(file,
|
|
257
|
+
writeFileAtomic(file, next);
|
|
219
258
|
return file;
|
|
220
259
|
}
|
|
221
260
|
/** AGENTS.md — the cross-tool ambient-instruction standard (Codex and a growing
|
|
@@ -242,7 +281,7 @@ export function writeCursorRule(root, store) {
|
|
|
242
281
|
export function writeWindsurfMcp(root, inv) {
|
|
243
282
|
const file = join(root, ".windsurf", "mcp_config.json");
|
|
244
283
|
const json = readJsonObj(file);
|
|
245
|
-
json.mcpServers = json
|
|
284
|
+
json.mcpServers = objectField(json, "mcpServers", file);
|
|
246
285
|
json.mcpServers.hunch = { command: inv.command, args: [...inv.args, "mcp"] };
|
|
247
286
|
return writeJson(file, json);
|
|
248
287
|
}
|
|
@@ -258,7 +297,7 @@ export function writeWindsurfGlobalMcp(inv, home = homedir()) {
|
|
|
258
297
|
if (!file)
|
|
259
298
|
return null;
|
|
260
299
|
const json = readJsonObj(file);
|
|
261
|
-
json.mcpServers = json
|
|
300
|
+
json.mcpServers = objectField(json, "mcpServers", file);
|
|
262
301
|
json.mcpServers.hunch = { command: inv.command, args: [...inv.args, "mcp"] };
|
|
263
302
|
return writeJson(file, json);
|
|
264
303
|
}
|
|
@@ -306,7 +345,9 @@ export function writeCodexHooks(root, inv) {
|
|
|
306
345
|
SessionStart: [entry()],
|
|
307
346
|
UserPromptSubmit: [entry()],
|
|
308
347
|
PreToolUse: [entry("apply_patch")],
|
|
309
|
-
|
|
348
|
+
// Codex's native command tool arrives as `Bash` (or `PowerShell` on
|
|
349
|
+
// Windows), while older hosts may expose shell/local_shell names.
|
|
350
|
+
PostToolUse: [entry("apply_patch|Bash|PowerShell|shell|local_shell")],
|
|
310
351
|
Stop: [entry()],
|
|
311
352
|
PreCompact: [entry()],
|
|
312
353
|
SubagentStart: [entry()],
|
|
@@ -347,9 +388,13 @@ function antigravityHandler(command) {
|
|
|
347
388
|
export function writeAntigravityHooks(root, inv) {
|
|
348
389
|
const file = join(root, ".agents", "hooks.json");
|
|
349
390
|
const json = readJsonObj(file);
|
|
350
|
-
const group = json
|
|
351
|
-
|
|
352
|
-
|
|
391
|
+
const group = objectField(json, "hunch", file);
|
|
392
|
+
for (const event of ["PreInvocation", "PreToolUse", "Stop"]) {
|
|
393
|
+
const existing = group[event];
|
|
394
|
+
if (existing !== undefined && !Array.isArray(existing)) {
|
|
395
|
+
throw new Error(`refusing to edit ${file}: hunch.${event} must be an array when present; fix it, then re-run.`);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
353
398
|
const command = hookCommand(inv, "antigravity");
|
|
354
399
|
const keep = (event) => Array.isArray(group[event])
|
|
355
400
|
? group[event].filter((entry) => {
|
|
@@ -27,7 +27,11 @@ export function writeMcpJson(root, inv) {
|
|
|
27
27
|
}
|
|
28
28
|
}
|
|
29
29
|
}
|
|
30
|
-
|
|
30
|
+
const servers = json.mcpServers;
|
|
31
|
+
if (servers !== undefined && (!servers || typeof servers !== "object" || Array.isArray(servers))) {
|
|
32
|
+
throw new Error(`refusing to edit ${file}: mcpServers must be a JSON object when present; fix it, then re-run.`);
|
|
33
|
+
}
|
|
34
|
+
json.mcpServers = servers ?? {};
|
|
31
35
|
json.mcpServers.hunch = { command: inv.command, args: [...inv.args, "mcp"] };
|
|
32
36
|
// Atomic: .mcp.json holds the user's other servers — a torn write would leave
|
|
33
37
|
// it unparseable, which this writer then refuses to touch (issue #43).
|
|
@@ -114,7 +118,7 @@ function isHunchHook(entry) {
|
|
|
114
118
|
if (typeof h.command !== "string")
|
|
115
119
|
return false;
|
|
116
120
|
const command = h.command;
|
|
117
|
-
const nativeOrSource = /[\\/]index\.(js|ts)"?\s+hook\s*$/.test(command);
|
|
121
|
+
const nativeOrSource = /(?:dist|src)[\\/]+cli[\\/]+index\.(js|ts)"?\s+hook\s*$/.test(command);
|
|
118
122
|
const publishedNpx = /^\s*"?npx(?:\.cmd)?"?\s+/i.test(command)
|
|
119
123
|
&& /--package=(?:hunch-exact@npm:)?@davesheffer\/hunch(?:@[^"\s]+)?/.test(command)
|
|
120
124
|
&& /\s"?hunch"?\s+"?hook"?\s*$/.test(command);
|
|
@@ -151,7 +155,17 @@ export function installClaudeHooks(root, hookCmd) {
|
|
|
151
155
|
}
|
|
152
156
|
}
|
|
153
157
|
}
|
|
154
|
-
|
|
158
|
+
const hooks = json.hooks;
|
|
159
|
+
if (hooks !== undefined && (!hooks || typeof hooks !== "object" || Array.isArray(hooks))) {
|
|
160
|
+
throw new Error(`refusing to edit ${file}: hooks must be a JSON object when present; fix it, then re-run.`);
|
|
161
|
+
}
|
|
162
|
+
json.hooks = hooks ?? {};
|
|
163
|
+
for (const event of ["PreToolUse", "UserPromptSubmit", "SessionStart", "SubagentStart", "PreCompact", "PostToolUse", "PostToolUseFailure", "Stop"]) {
|
|
164
|
+
const existing = json.hooks[event];
|
|
165
|
+
if (existing !== undefined && !Array.isArray(existing)) {
|
|
166
|
+
throw new Error(`refusing to edit ${file}: hooks.${event} must be an array when present; fix it, then re-run.`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
155
169
|
const keep = (arr) => (Array.isArray(arr) ? arr.filter((e) => !isHunchHook(e)) : []);
|
|
156
170
|
json.hooks.PreToolUse = [
|
|
157
171
|
...keep(json.hooks.PreToolUse),
|
package/dist/mcp/server.js
CHANGED
|
@@ -15,7 +15,7 @@ import { resolveMcpToolset } from "./toolset.js";
|
|
|
15
15
|
import { readConfig } from "../core/config.js";
|
|
16
16
|
import { canonicalRootPath, resolveActiveRoot } from "./roots.js";
|
|
17
17
|
import { HunchStore } from "../store/hunchStore.js";
|
|
18
|
-
import { StateRefusal, SubscribeResponseSchema, capabilities, partitionOf, readState, recordsState, subscribeState, writeState } from "../store/stateBinding.js";
|
|
18
|
+
import { StateRefusal, SubscribeResponseSchema, capabilities, partitionOf, readState, recordsState, stateHomeFor, subscribeState, writeState } from "../store/stateBinding.js";
|
|
19
19
|
import { captureState, captureBatchState } from "../store/stateCapture.js";
|
|
20
20
|
import { CaptureRequestSchema, CaptureBatchRequestSchema, CaptureBatchResultSchema, STATE_CAPTURE_VERSION, STATE_CAPTURE_BATCH_VERSION } from "../core/stateContract.js";
|
|
21
21
|
import { ReadRequestSchema, ReadResponseSchema, WriteRequestSchema, WriteResultSchema, SubscribeRequestSchema, RecordsRequestSchema, RecordsResponseSchema, STATE_READ_VERSION, STATE_WRITE_VERSION, STATE_SUBSCRIBE_VERSION, STATE_RECORDS_VERSION, stateHash } from "../core/stateContract.js";
|
|
@@ -1753,7 +1753,11 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
1753
1753
|
spawned_decision: finding.spawned_decision ?? existing?.spawned_decision ?? null,
|
|
1754
1754
|
observed_at: existing?.observed_at ?? now, // first observation wins — updates re-verify, not re-date
|
|
1755
1755
|
resolved_commit: finding.resolved_commit ?? existing?.resolved_commit ?? null,
|
|
1756
|
-
|
|
1756
|
+
// Findings have no authenticated capture front door. Calling this MCP tool is
|
|
1757
|
+
// agent testimony, even when the observation is updating a record that a human
|
|
1758
|
+
// confirmed previously; only an explicit human-authored path may mint the
|
|
1759
|
+
// human_confirmed tier.
|
|
1760
|
+
provenance: { source: "agent_recorded", confidence: 0.75, evidence: finding.evidence ?? existing?.provenance.evidence ?? [], last_verified: now },
|
|
1757
1761
|
};
|
|
1758
1762
|
const stored = store.putCapture("findings", rec, !!finding.private);
|
|
1759
1763
|
const observed = observeReportCapture(root, task_id, "findings", stored, home, !!existing, home === "private" ? store.privateDir ?? undefined : hunchPaths(root).hunch);
|
|
@@ -1869,7 +1873,8 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
1869
1873
|
try {
|
|
1870
1874
|
// Same cross-process lock `hunch serve` takes: a second agent writing over stdio must
|
|
1871
1875
|
// not race the HTTP server between the ledger read and the record write.
|
|
1872
|
-
const
|
|
1876
|
+
const { hunchDir } = stateHomeFor(store, input.scope);
|
|
1877
|
+
const result = await withWriteLock(hunchDir, () => writeState(store, { schema: STATE_WRITE_VERSION, ...input }, {
|
|
1873
1878
|
flush: (isPrivate, message) => flushCapture(store, hunchPaths(root).hunch, isPrivate, message, startupTeamRoute ?? undefined),
|
|
1874
1879
|
}));
|
|
1875
1880
|
return stateResult(`${result.outcome} ${result.record_id} (${result.durability}) ${result.record_hash}`, result);
|
|
@@ -1885,7 +1890,8 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
1885
1890
|
outputSchema: WriteResultSchema.shape,
|
|
1886
1891
|
}, async ({ cwd: _cwd, ...input }) => {
|
|
1887
1892
|
try {
|
|
1888
|
-
const
|
|
1893
|
+
const { hunchDir } = stateHomeFor(store, input.scope);
|
|
1894
|
+
const result = await withWriteLock(hunchDir, () => captureState(store, { schema: STATE_CAPTURE_VERSION, ...input }, {
|
|
1889
1895
|
flush: (isPrivate, message) => flushCapture(store, hunchPaths(root).hunch, isPrivate, message, startupTeamRoute ?? undefined),
|
|
1890
1896
|
}));
|
|
1891
1897
|
return stateResult(`${result.outcome} observation ${result.record_id} (${result.durability}); this does not assert currentness. ${result.record_hash}`, result);
|
|
@@ -1901,7 +1907,8 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
1901
1907
|
outputSchema: CaptureBatchResultSchema.shape,
|
|
1902
1908
|
}, async ({ cwd: _cwd, ...input }) => {
|
|
1903
1909
|
try {
|
|
1904
|
-
const
|
|
1910
|
+
const { hunchDir } = stateHomeFor(store, input.scope);
|
|
1911
|
+
const result = await withWriteLock(hunchDir, () => captureBatchState(store, { schema: STATE_CAPTURE_BATCH_VERSION, ...input }, {
|
|
1905
1912
|
flush: (isPrivate, message) => flushCapture(store, hunchPaths(root).hunch, isPrivate, message, startupTeamRoute ?? undefined),
|
|
1906
1913
|
}));
|
|
1907
1914
|
return stateResult(`Capture batch: ${result.results.filter(r => r.status === "saved").length} saved/replayed, ${result.results.filter(r => r.status === "refused").length} refused.${result.reviews ? ` Reviews: ${result.reviews.filter(r => r.status === "saved").length} withdrawn/replayed, ${result.reviews.filter(r => r.status === "refused").length} refused.` : ''} Inspect each indexed result.`, result);
|
|
@@ -2057,7 +2064,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
2057
2064
|
const report = store.buildCheckReport(files, diff, { strict: true, lastChange: (f) => lastChangeDate(f, root) });
|
|
2058
2065
|
const v = verdict(report);
|
|
2059
2066
|
const head = v === "block"
|
|
2060
|
-
? "VERDICT: ⛔ BLOCK —
|
|
2067
|
+
? "VERDICT: ⛔ BLOCK — a recorded guard requires review; inspect the cited scope and evidence below before merge."
|
|
2061
2068
|
: v === "warn"
|
|
2062
2069
|
? "VERDICT: ⚠ WARN — this change touches engineering memory; review the cited why below before merge."
|
|
2063
2070
|
: "VERDICT: ✅ PASS — touches no recorded invariants and re-introduces nothing deliberately retired.";
|
package/dist/serve/app.js
CHANGED
|
@@ -19,7 +19,7 @@ import { createServer } from "node:http";
|
|
|
19
19
|
import { HunchStore } from "../store/hunchStore.js";
|
|
20
20
|
import { hunchPaths } from "../core/paths.js";
|
|
21
21
|
import { flushCapture } from "../integrations/sync.js";
|
|
22
|
-
import { StateRefusal, capabilities, mergeReadResponses, readState, recordsState, subscribeState, writeState } from "../store/stateBinding.js";
|
|
22
|
+
import { StateRefusal, capabilities, mergeReadResponses, readState, recordsState, stateHomeFor, subscribeState, writeState } from "../store/stateBinding.js";
|
|
23
23
|
import { STATE_READ_VERSION, STATE_RECORDS_VERSION, STATE_SUBSCRIBE_VERSION, STATE_WRITE_VERSION, ReadScopesSchema, ScopeSchema, scopePath } from "../core/stateContract.js";
|
|
24
24
|
import { partitionFor, resolvePrincipal } from "./config.js";
|
|
25
25
|
import { WriteLockTimeout, withWriteLock } from "./writelock.js";
|
|
@@ -177,23 +177,26 @@ export function createServeApp(config, opts = {}) {
|
|
|
177
177
|
if (url.pathname === "/nuryel/v1/write") {
|
|
178
178
|
const scope = requireScope(principal, body);
|
|
179
179
|
const { store, root } = storeFor(scope);
|
|
180
|
-
const
|
|
180
|
+
const { hunchDir } = stateHomeFor(store, scope);
|
|
181
|
+
const result = await withWriteLock(hunchDir, () => writeState(store, { schema: STATE_WRITE_VERSION, principal, ...body }, {
|
|
181
182
|
flush: (isPrivate, message) => flushCapture(store, hunchPaths(root).hunch, isPrivate, message),
|
|
182
183
|
}));
|
|
183
184
|
return send(res, result.outcome === "created" ? 201 : 200, result);
|
|
184
185
|
}
|
|
185
186
|
if (url.pathname === "/nuryel/v1/capture") {
|
|
186
187
|
const scope = requireScope(principal, body);
|
|
187
|
-
const { store } = storeFor(scope);
|
|
188
|
-
const
|
|
189
|
-
|
|
188
|
+
const { store, root } = storeFor(scope);
|
|
189
|
+
const { hunchDir } = stateHomeFor(store, scope);
|
|
190
|
+
const result = await withWriteLock(hunchDir, () => captureState(store, { schema: STATE_CAPTURE_VERSION, principal, ...body }, {
|
|
191
|
+
flush: (isPrivate, message) => flushCapture(store, hunchPaths(root).hunch, isPrivate, message),
|
|
190
192
|
}));
|
|
191
193
|
return send(res, result.outcome === "created" ? 201 : 200, result);
|
|
192
194
|
}
|
|
193
195
|
if (url.pathname === "/nuryel/v1/capture-batch") {
|
|
194
196
|
const scope = requireScope(principal, body);
|
|
195
197
|
const { store, root } = storeFor(scope);
|
|
196
|
-
const
|
|
198
|
+
const { hunchDir } = stateHomeFor(store, scope);
|
|
199
|
+
const result = await withWriteLock(hunchDir, () => captureBatchState(store, { schema: STATE_CAPTURE_BATCH_VERSION, principal, ...body }, {
|
|
197
200
|
flush: (isPrivate, message) => flushCapture(store, hunchPaths(root).hunch, isPrivate, message),
|
|
198
201
|
}));
|
|
199
202
|
return send(res, 200, result);
|
package/dist/serve/writelock.js
CHANGED
|
@@ -65,8 +65,14 @@ function stealable(path, owner, now) {
|
|
|
65
65
|
catch {
|
|
66
66
|
return false;
|
|
67
67
|
}
|
|
68
|
-
|
|
69
|
-
|
|
68
|
+
// A same-host live PID is authoritative even when a long-running write has
|
|
69
|
+
// exceeded the stale-age heuristic. Age alone cannot distinguish a slow
|
|
70
|
+
// writer from a dead one; stealing here would let two writers interleave
|
|
71
|
+
// their record and ledger updates. The age fallback is only safe when the
|
|
72
|
+
// owner is from another host (whose PID we cannot probe) or its metadata is
|
|
73
|
+
// unreadable.
|
|
74
|
+
if (owner && owner.host === hostname())
|
|
75
|
+
return !pidAlive(owner.pid);
|
|
70
76
|
return ageMs > STALE_AFTER_MS;
|
|
71
77
|
}
|
|
72
78
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
@@ -9,11 +9,12 @@
|
|
|
9
9
|
* sequence to reconcile. Merging two clones' ledgers for the same scope is not decided
|
|
10
10
|
* here (see docs/nuryel-state-contract.md, "Not decided here").
|
|
11
11
|
*/
|
|
12
|
-
import {
|
|
13
|
-
import { join, resolve } from "node:path";
|
|
12
|
+
import { mkdirSync } from "node:fs";
|
|
13
|
+
import { basename, join, resolve } from "node:path";
|
|
14
14
|
import { createHash } from "node:crypto";
|
|
15
15
|
import { z } from "zod";
|
|
16
16
|
import { writeFileAtomic } from "../core/io.js";
|
|
17
|
+
import { readStoreArtifact, storeArtifactPath } from "../core/storeArtifact.js";
|
|
17
18
|
import { ChangeEventSchema, ScopeSchema, scopePath } from "../core/stateContract.js";
|
|
18
19
|
export const LEDGER_SCHEMA_VERSION = "nuryel.ledger/1";
|
|
19
20
|
export const CHANGES_DIR = "changes";
|
|
@@ -60,11 +61,11 @@ export function emptyLedger(scope) {
|
|
|
60
61
|
* error (never silently treated as empty — that would restart the sequence). */
|
|
61
62
|
export function readLedger(hunchDir, scope) {
|
|
62
63
|
const file = resolve(ledgerFile(hunchDir, scope));
|
|
63
|
-
|
|
64
|
+
const text = readStoreArtifact(hunchDir, [CHANGES_DIR, basename(file)]);
|
|
65
|
+
if (text === null) {
|
|
64
66
|
validatedSnapshots.delete(file);
|
|
65
67
|
return emptyLedger(scope);
|
|
66
68
|
}
|
|
67
|
-
const text = readFileSync(file, "utf8");
|
|
68
69
|
const cached = validatedSnapshots.get(file);
|
|
69
70
|
if (cached?.text === text && cached.scope === scopePath(scope)) {
|
|
70
71
|
validatedSnapshots.delete(file);
|
|
@@ -96,8 +97,8 @@ export function writeLedger(hunchDir, ledger) {
|
|
|
96
97
|
writeValidatedLedger(hunchDir, LedgerSchema.parse(ledger));
|
|
97
98
|
}
|
|
98
99
|
function writeValidatedLedger(hunchDir, ledger) {
|
|
99
|
-
const file = ledgerFile(hunchDir, ledger.scope);
|
|
100
|
-
mkdirSync(
|
|
100
|
+
const file = storeArtifactPath(hunchDir, CHANGES_DIR, basename(ledgerFile(hunchDir, ledger.scope)));
|
|
101
|
+
mkdirSync(storeArtifactPath(hunchDir, CHANGES_DIR), { recursive: true });
|
|
101
102
|
writeFileAtomic(file, JSON.stringify(ledger, null, 2) + "\n");
|
|
102
103
|
}
|
|
103
104
|
/** Append events (in order) and remember an idempotency key in ONE atomic write, so a
|
|
@@ -79,9 +79,9 @@ export declare class JsonStore {
|
|
|
79
79
|
* two unsynchronized RMWs over index.json each read the same base array and the
|
|
80
80
|
* second rename silently erases the first's record. `mkdirSync` is the atomic
|
|
81
81
|
* acquire (EEXIST = held). A stale lock (killed process) is taken over by age;
|
|
82
|
-
* against a live contender we wait briefly and then
|
|
83
|
-
*
|
|
84
|
-
*
|
|
82
|
+
* against a live contender we wait briefly and then refuse the write. Proceeding
|
|
83
|
+
* without the lock would reintroduce the record-loss race this mutex exists to
|
|
84
|
+
* prevent. */
|
|
85
85
|
private withSingleFileLock;
|
|
86
86
|
/** Write a single record (validated) to its JSON file / into the index array. */
|
|
87
87
|
put<K extends EntityKind>(kind: K, record: EntityFor[K]): EntityFor[K];
|