@davesheffer/hunch 1.32.4 → 1.32.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/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 +9 -4
- package/dist/core/checkreport.js +1 -1
- package/dist/core/events.js +19 -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/integrations/claudeConfig.js +20 -3
- package/dist/integrations/health.js +1 -1
- package/dist/integrations/providers.js +58 -15
- package/dist/integrations/scaffold.js +17 -3
- package/dist/mcp/server.js +8 -5
- 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
|
@@ -130,7 +130,7 @@ export function evaluateExecutableBehaviorPolicy(root, policy, opts = {}) {
|
|
|
130
130
|
// longer match the commit's dependency inputs. Name each with its recovery;
|
|
131
131
|
// both stay `error`, never a coerced pass.
|
|
132
132
|
if (!existsSync(join(root, ".hunch-cache", "behavior-deps"))) {
|
|
133
|
-
return evaluation(policy, commit, { ...baseExecution, commit, error_code: "dependency-snapshot-cache-absent" }, "error", "no dependency snapshot cache exists on this machine (.hunch-cache/behavior-deps); executable behavior is unevaluated here, not failed — provision the policy's snapshots (hunch constitution
|
|
133
|
+
return evaluation(policy, commit, { ...baseExecution, commit, error_code: "dependency-snapshot-cache-absent" }, "error", "no dependency snapshot cache exists on this machine (.hunch-cache/behavior-deps); executable behavior is unevaluated here, not failed — provision the policy's snapshots (hunch constitution g2 --behavior-deps <candidate> --behavior-review-hash <hash>) or evaluate where they were built");
|
|
134
134
|
}
|
|
135
135
|
return evaluation(policy, commit, { ...baseExecution, commit, error_code: "dependency-snapshot-unavailable" }, "error", `no unique exact dependency snapshot matches this commit's package.json/package-lock.json among the policy's pinned ids (${assertion.dependency_snapshot_ids.join(", ")}); dependency inputs changed since compilation — re-plan and re-prove the policy (rb_g2_stale_policy_01)`);
|
|
136
136
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { existsSync, mkdirSync,
|
|
2
|
-
import { join } from "node:path";
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, rmSync } from "node:fs";
|
|
2
|
+
import { basename, dirname, join } from "node:path";
|
|
3
3
|
import { writeFileAtomic, writeFileAtomicIfAbsent } from "../core/io.js";
|
|
4
|
+
import { readStoreArtifact, storeArtifactPath } from "../core/storeArtifact.js";
|
|
4
5
|
import { shortHash } from "../core/ids.js";
|
|
5
6
|
import { canonicalHash, policySemanticHash, proofPlanContentHash } from "./canonical.js";
|
|
6
7
|
import { proofCorpusContentHash } from "./corpus.js";
|
|
@@ -9,13 +10,40 @@ import { assertCompositionBinding, compositionDescendants, policyProofHash } fro
|
|
|
9
10
|
import { currentShadowDispositions, policyEvaluationContentHash, shadowDispositionContentHash, shadowDispositionJudgmentHash, shadowEvaluationContentHash, shadowEvaluationIdentityHash, } from "./shadow.js";
|
|
10
11
|
import { HistoryDispositionSchema, ProofCorpusSchema, PolicyProofSchema, ProofPlanSchema, PolicySpecSchema, ShadowRecordSchema, EvidenceEventSchema, } from "./schema.js";
|
|
11
12
|
const encode = (value) => JSON.stringify(value, null, 2) + "\n";
|
|
13
|
+
const MAX_POLICY_ARTIFACT_BYTES = 8 * 1024 * 1024;
|
|
14
|
+
function assertPolicyArtifactSize(data) {
|
|
15
|
+
if (Buffer.byteLength(data, "utf8") > MAX_POLICY_ARTIFACT_BYTES) {
|
|
16
|
+
throw new Error(`policy artifact exceeds the ${MAX_POLICY_ARTIFACT_BYTES}-byte limit`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
function artifactFile(dir, name) {
|
|
20
|
+
return storeArtifactPath(dir, name);
|
|
21
|
+
}
|
|
22
|
+
function writeArtifact(dir, name, data) {
|
|
23
|
+
assertPolicyArtifactSize(data);
|
|
24
|
+
const file = artifactFile(dir, name);
|
|
25
|
+
writeFileAtomic(file, data);
|
|
26
|
+
artifactFile(dir, name);
|
|
27
|
+
}
|
|
28
|
+
function writeArtifactIfAbsent(dir, name, data) {
|
|
29
|
+
assertPolicyArtifactSize(data);
|
|
30
|
+
const file = artifactFile(dir, name);
|
|
31
|
+
const created = writeFileAtomicIfAbsent(file, data);
|
|
32
|
+
if (created)
|
|
33
|
+
artifactFile(dir, name);
|
|
34
|
+
return created;
|
|
35
|
+
}
|
|
12
36
|
function loadRecords(dir, parse, label) {
|
|
13
|
-
|
|
37
|
+
const safeDir = storeArtifactPath(dirname(dir), basename(dir));
|
|
38
|
+
if (!existsSync(safeDir))
|
|
14
39
|
return [];
|
|
15
40
|
const out = [];
|
|
16
|
-
for (const name of readdirSync(
|
|
41
|
+
for (const name of readdirSync(safeDir).filter((n) => n.endsWith(".json")).sort()) {
|
|
17
42
|
try {
|
|
18
|
-
|
|
43
|
+
const raw = readStoreArtifact(safeDir, [name], MAX_POLICY_ARTIFACT_BYTES);
|
|
44
|
+
if (raw === null)
|
|
45
|
+
throw new Error("record disappeared while it was being read");
|
|
46
|
+
out.push(parse(JSON.parse(raw)));
|
|
19
47
|
}
|
|
20
48
|
catch (e) {
|
|
21
49
|
// A policy store can control CI. Skipping a corrupt record would turn an
|
|
@@ -41,7 +69,12 @@ export class PolicyRepository {
|
|
|
41
69
|
const base = home === "private" ? this.privateHome : this.publicHome;
|
|
42
70
|
if (!base)
|
|
43
71
|
throw new Error("No private Hunch overlay is configured; refusing to write a private policy.");
|
|
44
|
-
return
|
|
72
|
+
return storeArtifactPath(base, kind);
|
|
73
|
+
}
|
|
74
|
+
ensureDir(home, kind) {
|
|
75
|
+
const dir = this.dir(home, kind);
|
|
76
|
+
mkdirSync(dir, { recursive: true });
|
|
77
|
+
return storeArtifactPath(dirname(dir), basename(dir));
|
|
45
78
|
}
|
|
46
79
|
policiesIn(home) {
|
|
47
80
|
if (home === "private" && !this.privateHome)
|
|
@@ -200,9 +233,9 @@ export class PolicyRepository {
|
|
|
200
233
|
return dispositions.sort((left, right) => left.id.localeCompare(right.id));
|
|
201
234
|
}
|
|
202
235
|
homeOfPolicy(id) {
|
|
203
|
-
if (this.privateHome && existsSync(
|
|
236
|
+
if (this.privateHome && existsSync(artifactFile(this.dir("private", "policies"), `${id}.json`)))
|
|
204
237
|
return "private";
|
|
205
|
-
if (existsSync(
|
|
238
|
+
if (existsSync(artifactFile(this.dir("public", "policies"), `${id}.json`)))
|
|
206
239
|
return "public";
|
|
207
240
|
return undefined;
|
|
208
241
|
}
|
|
@@ -213,9 +246,8 @@ export class PolicyRepository {
|
|
|
213
246
|
throw new Error(`refusing to write ${parsed.data_class} policy ${parsed.id} into its existing public home; migrate it to the private overlay first`);
|
|
214
247
|
}
|
|
215
248
|
const home = opts.private ? "private" : existing ?? (parsed.data_class !== "public" || this.store.unified ? "private" : "public");
|
|
216
|
-
const dir = this.
|
|
217
|
-
|
|
218
|
-
writeFileAtomic(join(dir, `${parsed.id}.json`), encode(parsed));
|
|
249
|
+
const dir = this.ensureDir(home, "policies");
|
|
250
|
+
writeArtifact(dir, `${parsed.id}.json`, encode(parsed));
|
|
219
251
|
return parsed;
|
|
220
252
|
}
|
|
221
253
|
/** Publish a new policy lifecycle record without overwriting a concurrent
|
|
@@ -238,9 +270,8 @@ export class PolicyRepository {
|
|
|
238
270
|
return { policy: existing, created: false };
|
|
239
271
|
if (otherHome)
|
|
240
272
|
throw new Error(`policy ${parsed.id} already exists in the ${home === "public" ? "private" : "public"} home`);
|
|
241
|
-
const dir = this.
|
|
242
|
-
|
|
243
|
-
if (writeFileAtomicIfAbsent(join(dir, `${parsed.id}.json`), encode(parsed))) {
|
|
273
|
+
const dir = this.ensureDir(home, "policies");
|
|
274
|
+
if (writeArtifactIfAbsent(dir, `${parsed.id}.json`, encode(parsed))) {
|
|
244
275
|
const racedOtherHome = this.getPolicy(parsed.id, home === "public" ? { privateOnly: true } : { publicOnly: true });
|
|
245
276
|
if (racedOtherHome)
|
|
246
277
|
throw new Error(`policy ${parsed.id} was published concurrently in both public and private homes`);
|
|
@@ -277,9 +308,8 @@ export class PolicyRepository {
|
|
|
277
308
|
assertCompositionBinding(policy, composition, plan.composition);
|
|
278
309
|
}
|
|
279
310
|
}
|
|
280
|
-
const dir = this.
|
|
281
|
-
|
|
282
|
-
writeFileAtomic(join(dir, `${parsed.id}.json`), encode(parsed));
|
|
311
|
+
const dir = this.ensureDir(home, "proofs");
|
|
312
|
+
writeArtifact(dir, `${parsed.id}.json`, encode(parsed));
|
|
283
313
|
return parsed;
|
|
284
314
|
}
|
|
285
315
|
/** Publish an immutable proof without replacing a concurrent writer. */
|
|
@@ -315,9 +345,8 @@ export class PolicyRepository {
|
|
|
315
345
|
throw new Error(`proof ${parsed.id} already exists with different immutable content`);
|
|
316
346
|
return { proof: existing, created: false };
|
|
317
347
|
}
|
|
318
|
-
const dir = this.
|
|
319
|
-
|
|
320
|
-
if (writeFileAtomicIfAbsent(join(dir, `${parsed.id}.json`), encode(parsed)))
|
|
348
|
+
const dir = this.ensureDir(home, "proofs");
|
|
349
|
+
if (writeArtifactIfAbsent(dir, `${parsed.id}.json`, encode(parsed)))
|
|
321
350
|
return { proof: parsed, created: true };
|
|
322
351
|
const winner = this.getProof(parsed.id, homeOpts);
|
|
323
352
|
if (!winner)
|
|
@@ -346,9 +375,8 @@ export class PolicyRepository {
|
|
|
346
375
|
if (parsed.policy_candidate_hash !== policyProofHash(policy, composition))
|
|
347
376
|
throw new Error(`composite plan ${parsed.id} policy hash mismatch`);
|
|
348
377
|
}
|
|
349
|
-
const dir = this.
|
|
350
|
-
|
|
351
|
-
writeFileAtomic(join(dir, `${parsed.id}.json`), encode(parsed));
|
|
378
|
+
const dir = this.ensureDir(home, "plans");
|
|
379
|
+
writeArtifact(dir, `${parsed.id}.json`, encode(parsed));
|
|
352
380
|
return parsed;
|
|
353
381
|
}
|
|
354
382
|
/** Publish an immutable proof plan without replacing a concurrent writer. */
|
|
@@ -378,9 +406,8 @@ export class PolicyRepository {
|
|
|
378
406
|
throw new Error(`proof plan ${parsed.id} already exists with different immutable content`);
|
|
379
407
|
return { plan: existing, created: false };
|
|
380
408
|
}
|
|
381
|
-
const dir = this.
|
|
382
|
-
|
|
383
|
-
if (writeFileAtomicIfAbsent(join(dir, `${parsed.id}.json`), encode(parsed)))
|
|
409
|
+
const dir = this.ensureDir(home, "plans");
|
|
410
|
+
if (writeArtifactIfAbsent(dir, `${parsed.id}.json`, encode(parsed)))
|
|
384
411
|
return { plan: parsed, created: true };
|
|
385
412
|
const winner = this.getPlan(parsed.id, homeOpts);
|
|
386
413
|
if (!winner)
|
|
@@ -400,9 +427,8 @@ export class PolicyRepository {
|
|
|
400
427
|
if (!policy || parsed.data_class !== policy.data_class || parsed.policy_hash !== policySemanticHash(policy)) {
|
|
401
428
|
throw new Error(`corpus ${parsed.id} does not match policy ${policyId} semantics/data class`);
|
|
402
429
|
}
|
|
403
|
-
const dir = this.
|
|
404
|
-
|
|
405
|
-
writeFileAtomic(join(dir, `${policyId}.json`), encode(parsed));
|
|
430
|
+
const dir = this.ensureDir(home, "corpora");
|
|
431
|
+
writeArtifact(dir, `${policyId}.json`, encode(parsed));
|
|
406
432
|
return parsed;
|
|
407
433
|
}
|
|
408
434
|
putEvidence(event, opts = {}) {
|
|
@@ -413,9 +439,8 @@ export class PolicyRepository {
|
|
|
413
439
|
if (home === "public" && parsed.data_class !== "public") {
|
|
414
440
|
throw new Error(`refusing to write ${parsed.data_class} evidence ${parsed.id} into the public home`);
|
|
415
441
|
}
|
|
416
|
-
const dir = this.
|
|
417
|
-
|
|
418
|
-
writeFileAtomic(join(dir, `${parsed.id}.json`), encode(parsed));
|
|
442
|
+
const dir = this.ensureDir(home, "evidence");
|
|
443
|
+
writeArtifact(dir, `${parsed.id}.json`, encode(parsed));
|
|
419
444
|
return parsed;
|
|
420
445
|
}
|
|
421
446
|
putDisposition(disposition, policyId) {
|
|
@@ -451,9 +476,8 @@ export class PolicyRepository {
|
|
|
451
476
|
if (!current && parsed.supersedes)
|
|
452
477
|
throw new Error(`history disposition ${parsed.id} supersedes no current disposition for this proof hit`);
|
|
453
478
|
currentHistoryDispositions([...records, parsed]);
|
|
454
|
-
const dir = this.
|
|
455
|
-
|
|
456
|
-
writeFileAtomic(join(dir, `${parsed.id}.json`), encode(parsed));
|
|
479
|
+
const dir = this.ensureDir(home, "dispositions");
|
|
480
|
+
writeArtifact(dir, `${parsed.id}.json`, encode(parsed));
|
|
457
481
|
return parsed;
|
|
458
482
|
}
|
|
459
483
|
putShadowEvaluation(evaluation, policyId) {
|
|
@@ -483,9 +507,8 @@ export class PolicyRepository {
|
|
|
483
507
|
const existing = this.listShadowEvaluations(homeOpts).find((record) => shadowEvaluationIdentityHash(record) === shadowEvaluationIdentityHash(parsed));
|
|
484
508
|
if (existing)
|
|
485
509
|
return existing;
|
|
486
|
-
const dir = this.
|
|
487
|
-
|
|
488
|
-
writeFileAtomic(join(dir, `${parsed.id}.json`), encode(parsed));
|
|
510
|
+
const dir = this.ensureDir(home, "shadow");
|
|
511
|
+
writeArtifact(dir, `${parsed.id}.json`, encode(parsed));
|
|
489
512
|
return parsed;
|
|
490
513
|
}
|
|
491
514
|
putShadowDisposition(disposition, policyId) {
|
|
@@ -521,9 +544,8 @@ export class PolicyRepository {
|
|
|
521
544
|
if (!current && parsed.supersedes)
|
|
522
545
|
throw new Error(`shadow disposition ${parsed.id} supersedes no current disposition for this evaluation`);
|
|
523
546
|
currentShadowDispositions([...records, parsed]);
|
|
524
|
-
const dir = this.
|
|
525
|
-
|
|
526
|
-
writeFileAtomic(join(dir, `${parsed.id}.json`), encode(parsed));
|
|
547
|
+
const dir = this.ensureDir(home, "shadow");
|
|
548
|
+
writeArtifact(dir, `${parsed.id}.json`, encode(parsed));
|
|
527
549
|
return parsed;
|
|
528
550
|
}
|
|
529
551
|
}
|
|
@@ -653,14 +675,18 @@ export function movePolicyArtifactsToPrivate(publicHunchDir, privateHunchDir) {
|
|
|
653
675
|
}
|
|
654
676
|
}
|
|
655
677
|
for (const { kind, from, to, pub, priv, keyFor } of staged) {
|
|
678
|
+
storeArtifactPath(dirname(from), basename(from));
|
|
656
679
|
if (!pub.length && !existsSync(from))
|
|
657
680
|
continue;
|
|
681
|
+
storeArtifactPath(dirname(to), basename(to));
|
|
658
682
|
mkdirSync(to, { recursive: true });
|
|
683
|
+
storeArtifactPath(dirname(to), basename(to));
|
|
659
684
|
for (const rec of pub) {
|
|
660
685
|
const key = keyFor(rec);
|
|
661
686
|
if (!priv.has(key))
|
|
662
|
-
|
|
687
|
+
writeArtifact(to, `${key}.json`, encode(rec));
|
|
663
688
|
}
|
|
689
|
+
storeArtifactPath(dirname(from), basename(from));
|
|
664
690
|
rmSync(from, { recursive: true, force: true });
|
|
665
691
|
counts[kind] = pub.length;
|
|
666
692
|
}
|
package/dist/core/agenthook.js
CHANGED
|
@@ -60,11 +60,15 @@ function applyPatchInput(raw) {
|
|
|
60
60
|
const file = PATCH_FILE.exec(patch)?.[1];
|
|
61
61
|
return file ? { file_path: file, content: patch } : undefined;
|
|
62
62
|
}
|
|
63
|
-
function normalizeToolInput(value) {
|
|
63
|
+
function normalizeToolInput(value, allowPatch = false) {
|
|
64
64
|
const raw = obj(value);
|
|
65
65
|
if (!raw)
|
|
66
66
|
return undefined;
|
|
67
|
-
|
|
67
|
+
// Only Codex's apply_patch tool carries patch text in a generic `input`,
|
|
68
|
+
// `patch`, or `content` field. A normal Write can contain documentation or
|
|
69
|
+
// examples with these markers; interpreting those as a patch would retarget
|
|
70
|
+
// policy to the first file named in the prose.
|
|
71
|
+
const patched = allowPatch ? applyPatchInput(raw) : undefined;
|
|
68
72
|
if (patched)
|
|
69
73
|
return patched;
|
|
70
74
|
const replacementChunks = Array.isArray(raw.ReplacementChunks) ? raw.ReplacementChunks : raw.replacementChunks;
|
|
@@ -205,7 +209,8 @@ export function normalizeHookEvent(raw, provider) {
|
|
|
205
209
|
const event = eventName(input.hook_event_name ?? input.hookEventName ?? input.event, provider);
|
|
206
210
|
if (!event)
|
|
207
211
|
return null;
|
|
208
|
-
const
|
|
212
|
+
const rawToolName = stringAt(input, "tool_name", "toolName");
|
|
213
|
+
const toolInput = normalizeToolInput(input.tool_input ?? input.toolInput, provider === "codex" && /^(?:apply_patch|patch)$/i.test(rawToolName ?? ""));
|
|
209
214
|
const toolOutcome = normalizeToolOutcome(input, event);
|
|
210
215
|
return {
|
|
211
216
|
hook_event_name: event,
|
|
@@ -214,7 +219,7 @@ export function normalizeHookEvent(raw, provider) {
|
|
|
214
219
|
// says `prompt_id`; both are native per-prompt identities, never synthesized.
|
|
215
220
|
...(provider === "codex" && input.prompt_id === undefined && input.turn_id !== undefined ? { prompt_id: typeof input.turn_id === "string" ? input.turn_id : "" } : {}),
|
|
216
221
|
...(provider === "claude" || provider === "codex" ? Object.fromEntries(["prompt_id", "cwd", "agent_id"].filter(key => input[key] !== undefined).map(key => [key, typeof input[key] === "string" ? input[key] : ""])) : {}),
|
|
217
|
-
tool_name: hunchToolName(
|
|
222
|
+
tool_name: hunchToolName(rawToolName, toolInput ?? {}),
|
|
218
223
|
tool_input: toolInput,
|
|
219
224
|
...(toolOutcome ? { tool_outcome: toolOutcome } : {}),
|
|
220
225
|
prompt: stringAt(input, "prompt", "user_prompt", "userPrompt"),
|
package/dist/core/checkreport.js
CHANGED
|
@@ -200,7 +200,7 @@ export function renderMarkdown(r) {
|
|
|
200
200
|
r.regBlocking ? `${r.regBlocking} blocking-linked regression(s)` : "",
|
|
201
201
|
r.vetoBlocking ? `${r.vetoBlocking} reversed-decision veto(es)` : "",
|
|
202
202
|
].filter(Boolean).join(" + ");
|
|
203
|
-
out.push(`❌ **
|
|
203
|
+
out.push(`❌ **Merge requires review: ${reasons}.** Check the cited evidence and verify that the recorded requirements still hold before merging.`);
|
|
204
204
|
}
|
|
205
205
|
else if (r.strict) {
|
|
206
206
|
out.push(`ℹ️ Nothing here is a direct, high-confidence, non-stale blocking invariant — **not blocking** this PR.`);
|
package/dist/core/events.js
CHANGED
|
@@ -16,8 +16,9 @@
|
|
|
16
16
|
* assert — those are conformance-only predicates checked by a different gate
|
|
17
17
|
* (`hunch conform`), not the edit hook. This schema records only what each gate
|
|
18
18
|
* actually knows; it never fabricates the conformance shape for a plain block. */
|
|
19
|
-
import { appendFileSync,
|
|
19
|
+
import { appendFileSync, closeSync, constants, fstatSync, lstatSync, openSync } from "node:fs";
|
|
20
20
|
import { join } from "node:path";
|
|
21
|
+
import { readStoreArtifact, storeArtifactPath } from "./storeArtifact.js";
|
|
21
22
|
export function eventsLogPath(paths) {
|
|
22
23
|
return join(paths.hunch, "events.log");
|
|
23
24
|
}
|
|
@@ -25,19 +26,34 @@ export function eventsLogPath(paths) {
|
|
|
25
26
|
* call site is the edit hook, which MUST NEVER break an agent on failure
|
|
26
27
|
* (con_03a0b94b2e). A dropped catch-log line is an acceptable loss. */
|
|
27
28
|
export function appendEvent(paths, event) {
|
|
29
|
+
let fd;
|
|
28
30
|
try {
|
|
29
|
-
|
|
31
|
+
const file = storeArtifactPath(paths.hunch, "events.log");
|
|
32
|
+
fd = openSync(file, constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | constants.O_NOFOLLOW);
|
|
33
|
+
const opened = fstatSync(fd);
|
|
34
|
+
const current = lstatSync(storeArtifactPath(paths.hunch, "events.log"));
|
|
35
|
+
if (!opened.isFile() || opened.nlink !== 1 || opened.dev !== current.dev || opened.ino !== current.ino)
|
|
36
|
+
return;
|
|
37
|
+
appendFileSync(fd, `${JSON.stringify(event)}\n`);
|
|
30
38
|
}
|
|
31
39
|
catch {
|
|
32
40
|
/* best effort — a lost audit line must never surface to the agent */
|
|
33
41
|
}
|
|
42
|
+
finally {
|
|
43
|
+
if (fd !== undefined) {
|
|
44
|
+
try {
|
|
45
|
+
closeSync(fd);
|
|
46
|
+
}
|
|
47
|
+
catch { /* logging remains best effort on close failure too */ }
|
|
48
|
+
}
|
|
49
|
+
}
|
|
34
50
|
}
|
|
35
51
|
/** Read + parse the catch-log. Malformed lines are skipped, not fatal (the log is
|
|
36
52
|
* derived; one bad line never poisons the aggregation). Missing log → []. */
|
|
37
53
|
export function readEvents(paths) {
|
|
38
54
|
let raw;
|
|
39
55
|
try {
|
|
40
|
-
raw =
|
|
56
|
+
raw = readStoreArtifact(paths.hunch, ["events.log"]) ?? "";
|
|
41
57
|
}
|
|
42
58
|
catch {
|
|
43
59
|
return []; // no catches recorded yet
|
package/dist/core/io.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** Durable file writes for the Hunch. */
|
|
2
|
-
import { closeSync, fsyncSync, linkSync, openSync, renameSync, rmSync,
|
|
2
|
+
import { closeSync, fchmodSync, fsyncSync, linkSync, lstatSync, openSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { dirname } from "node:path";
|
|
4
4
|
let counter = 0;
|
|
5
5
|
const renameRetryDelaysMs = [10, 20, 40, 80];
|
|
@@ -24,20 +24,17 @@ const renameRetryWaiter = new Int32Array(new SharedArrayBuffer(Int32Array.BYTES_
|
|
|
24
24
|
*/
|
|
25
25
|
export function writeFileAtomic(file, data) {
|
|
26
26
|
const tmp = `${file}.tmp${process.pid}.${counter++}`;
|
|
27
|
+
let mode;
|
|
27
28
|
try {
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
fsyncSync(fd); // data blocks reach disk before the rename's metadata can
|
|
32
|
-
}
|
|
33
|
-
finally {
|
|
34
|
-
closeSync(fd);
|
|
35
|
-
}
|
|
29
|
+
const existing = lstatSync(file);
|
|
30
|
+
if (existing.isFile())
|
|
31
|
+
mode = existing.mode & 0o777;
|
|
36
32
|
}
|
|
37
|
-
catch (
|
|
38
|
-
|
|
39
|
-
|
|
33
|
+
catch (error) {
|
|
34
|
+
if (error.code !== "ENOENT")
|
|
35
|
+
throw error;
|
|
40
36
|
}
|
|
37
|
+
writeFileAtomicTmp(tmp, data, mode);
|
|
41
38
|
try {
|
|
42
39
|
renameWithContentionRetry(tmp, file);
|
|
43
40
|
}
|
|
@@ -85,8 +82,10 @@ function isRenameContention(error) {
|
|
|
85
82
|
* semantics, so concurrent lifecycle writers can never be overwritten. */
|
|
86
83
|
export function writeFileAtomicIfAbsent(file, data) {
|
|
87
84
|
const tmp = `${file}.tmp${process.pid}.${counter++}`;
|
|
85
|
+
// An occupied temp path is an error, not evidence that the target exists.
|
|
86
|
+
// Only enter the publication/cleanup block once we own the temporary file.
|
|
87
|
+
writeFileAtomicTmp(tmp, data);
|
|
88
88
|
try {
|
|
89
|
-
writeFileAtomicTmp(tmp, data);
|
|
90
89
|
linkSync(tmp, file);
|
|
91
90
|
return true;
|
|
92
91
|
}
|
|
@@ -104,14 +103,26 @@ export function writeFileAtomicIfAbsent(file, data) {
|
|
|
104
103
|
}
|
|
105
104
|
}
|
|
106
105
|
/** Write + fsync a fresh temp file (shared by both atomic writers). */
|
|
107
|
-
function writeFileAtomicTmp(tmp, data) {
|
|
108
|
-
|
|
106
|
+
function writeFileAtomicTmp(tmp, data, mode) {
|
|
107
|
+
// Exclusive creation rejects stale files and links without truncating their
|
|
108
|
+
// contents. If open fails, the path belongs to somebody else: never unlink it.
|
|
109
|
+
const fd = openSync(tmp, "wx", mode ?? 0o666);
|
|
109
110
|
try {
|
|
110
|
-
|
|
111
|
-
|
|
111
|
+
try {
|
|
112
|
+
writeFileSync(fd, data);
|
|
113
|
+
// The replacement inode must retain an existing file's permissions,
|
|
114
|
+
// including private config files that contain credentials.
|
|
115
|
+
if (mode !== undefined)
|
|
116
|
+
fchmodSync(fd, mode);
|
|
117
|
+
fsyncSync(fd);
|
|
118
|
+
}
|
|
119
|
+
finally {
|
|
120
|
+
closeSync(fd);
|
|
121
|
+
}
|
|
112
122
|
}
|
|
113
|
-
|
|
114
|
-
|
|
123
|
+
catch (error) {
|
|
124
|
+
cleanupTmp(tmp);
|
|
125
|
+
throw error;
|
|
115
126
|
}
|
|
116
127
|
}
|
|
117
128
|
/** Remove a temp file, riding out a transient external hold (AV/indexer) with one
|
package/dist/core/jsonc.js
CHANGED
|
@@ -24,7 +24,7 @@ export function parseJsonc(raw) {
|
|
|
24
24
|
continue;
|
|
25
25
|
}
|
|
26
26
|
if (char === "/" && next === "/") {
|
|
27
|
-
while (index < raw.length && raw[index] !== "\n")
|
|
27
|
+
while (index < raw.length && raw[index] !== "\n" && raw[index] !== "\r")
|
|
28
28
|
index += 1;
|
|
29
29
|
withoutComments += "\n";
|
|
30
30
|
continue;
|
|
@@ -33,7 +33,12 @@ export function parseJsonc(raw) {
|
|
|
33
33
|
index += 2;
|
|
34
34
|
while (index < raw.length && !(raw[index] === "*" && raw[index + 1] === "/"))
|
|
35
35
|
index += 1;
|
|
36
|
+
if (index >= raw.length)
|
|
37
|
+
throw new SyntaxError("unterminated JSONC block comment");
|
|
36
38
|
index += 1;
|
|
39
|
+
// A comment separates tokens. Removing it outright would silently turn
|
|
40
|
+
// invalid input such as 1/* comment */2 into the valid number 12.
|
|
41
|
+
withoutComments += " ";
|
|
37
42
|
continue;
|
|
38
43
|
}
|
|
39
44
|
withoutComments += char;
|
|
@@ -62,8 +67,13 @@ export function parseJsonc(raw) {
|
|
|
62
67
|
let cursor = index + 1;
|
|
63
68
|
while (cursor < withoutComments.length && /\s/.test(withoutComments[cursor]))
|
|
64
69
|
cursor += 1;
|
|
65
|
-
if (withoutComments[cursor] === "}" || withoutComments[cursor] === "]")
|
|
66
|
-
|
|
70
|
+
if (withoutComments[cursor] === "}" || withoutComments[cursor] === "]") {
|
|
71
|
+
const previous = normalized.trimEnd().at(-1);
|
|
72
|
+
// A trailing comma must follow a value, never an empty collection or
|
|
73
|
+
// another comma. Keep malformed input intact for JSON.parse to reject.
|
|
74
|
+
if (previous && !"[{,:".includes(previous))
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
67
77
|
}
|
|
68
78
|
normalized += char;
|
|
69
79
|
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** Containment for store artifacts outside JsonStore's entity registry (ledgers,
|
|
2
|
+
* policy proofs, and local audit logs). The explicitly selected store's parent
|
|
3
|
+
* may have a platform alias, but no component inside that store may be a link. */
|
|
4
|
+
export declare function storeArtifactPath(hunchDir: string, ...parts: string[]): string;
|
|
5
|
+
/** A missing artifact is distinct from an unsafe/unreadable artifact. Reuse the
|
|
6
|
+
* scanner's bounded descriptor read; policy and ledger corruption must fail visibly. */
|
|
7
|
+
export declare function readStoreArtifact(hunchDir: string, parts: string[], maxBytes?: number): string | null;
|
|
@@ -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
|
|
@@ -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
|
}
|
|
@@ -52,7 +52,7 @@ function hookCommands(value) {
|
|
|
52
52
|
if (obj.enabled === false || (obj.type !== undefined && obj.type !== "command"))
|
|
53
53
|
return [];
|
|
54
54
|
const command = typeof obj.command === "string" ? obj.command : "";
|
|
55
|
-
const own = /(?:@davesheffer\/hunch|[\\/]index\.(?:js|ts))/.test(command)
|
|
55
|
+
const own = /(?:@davesheffer\/hunch|(?:dist|src)[\\/]+cli[\\/]+index\.(?:js|ts))/.test(command)
|
|
56
56
|
&& /\s"?hook"?(?:\s+"?--provider"?\s+"?[a-z]+"?)?\s*$/.test(command);
|
|
57
57
|
return [...(own ? [command] : []), ...(obj.hooks ? hookCommands(obj.hooks) : [])];
|
|
58
58
|
}
|
|
@@ -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
|
}
|
|
@@ -347,9 +386,13 @@ function antigravityHandler(command) {
|
|
|
347
386
|
export function writeAntigravityHooks(root, inv) {
|
|
348
387
|
const file = join(root, ".agents", "hooks.json");
|
|
349
388
|
const json = readJsonObj(file);
|
|
350
|
-
const group = json
|
|
351
|
-
|
|
352
|
-
|
|
389
|
+
const group = objectField(json, "hunch", file);
|
|
390
|
+
for (const event of ["PreInvocation", "PreToolUse", "Stop"]) {
|
|
391
|
+
const existing = group[event];
|
|
392
|
+
if (existing !== undefined && !Array.isArray(existing)) {
|
|
393
|
+
throw new Error(`refusing to edit ${file}: hunch.${event} must be an array when present; fix it, then re-run.`);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
353
396
|
const command = hookCommand(inv, "antigravity");
|
|
354
397
|
const keep = (event) => Array.isArray(group[event])
|
|
355
398
|
? 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";
|
|
@@ -1869,7 +1869,8 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
1869
1869
|
try {
|
|
1870
1870
|
// Same cross-process lock `hunch serve` takes: a second agent writing over stdio must
|
|
1871
1871
|
// not race the HTTP server between the ledger read and the record write.
|
|
1872
|
-
const
|
|
1872
|
+
const { hunchDir } = stateHomeFor(store, input.scope);
|
|
1873
|
+
const result = await withWriteLock(hunchDir, () => writeState(store, { schema: STATE_WRITE_VERSION, ...input }, {
|
|
1873
1874
|
flush: (isPrivate, message) => flushCapture(store, hunchPaths(root).hunch, isPrivate, message, startupTeamRoute ?? undefined),
|
|
1874
1875
|
}));
|
|
1875
1876
|
return stateResult(`${result.outcome} ${result.record_id} (${result.durability}) ${result.record_hash}`, result);
|
|
@@ -1885,7 +1886,8 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
1885
1886
|
outputSchema: WriteResultSchema.shape,
|
|
1886
1887
|
}, async ({ cwd: _cwd, ...input }) => {
|
|
1887
1888
|
try {
|
|
1888
|
-
const
|
|
1889
|
+
const { hunchDir } = stateHomeFor(store, input.scope);
|
|
1890
|
+
const result = await withWriteLock(hunchDir, () => captureState(store, { schema: STATE_CAPTURE_VERSION, ...input }, {
|
|
1889
1891
|
flush: (isPrivate, message) => flushCapture(store, hunchPaths(root).hunch, isPrivate, message, startupTeamRoute ?? undefined),
|
|
1890
1892
|
}));
|
|
1891
1893
|
return stateResult(`${result.outcome} observation ${result.record_id} (${result.durability}); this does not assert currentness. ${result.record_hash}`, result);
|
|
@@ -1901,7 +1903,8 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
1901
1903
|
outputSchema: CaptureBatchResultSchema.shape,
|
|
1902
1904
|
}, async ({ cwd: _cwd, ...input }) => {
|
|
1903
1905
|
try {
|
|
1904
|
-
const
|
|
1906
|
+
const { hunchDir } = stateHomeFor(store, input.scope);
|
|
1907
|
+
const result = await withWriteLock(hunchDir, () => captureBatchState(store, { schema: STATE_CAPTURE_BATCH_VERSION, ...input }, {
|
|
1905
1908
|
flush: (isPrivate, message) => flushCapture(store, hunchPaths(root).hunch, isPrivate, message, startupTeamRoute ?? undefined),
|
|
1906
1909
|
}));
|
|
1907
1910
|
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 +2060,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
2057
2060
|
const report = store.buildCheckReport(files, diff, { strict: true, lastChange: (f) => lastChangeDate(f, root) });
|
|
2058
2061
|
const v = verdict(report);
|
|
2059
2062
|
const head = v === "block"
|
|
2060
|
-
? "VERDICT: ⛔ BLOCK —
|
|
2063
|
+
? "VERDICT: ⛔ BLOCK — a recorded guard requires review; inspect the cited scope and evidence below before merge."
|
|
2061
2064
|
: v === "warn"
|
|
2062
2065
|
? "VERDICT: ⚠ WARN — this change touches engineering memory; review the cited why below before merge."
|
|
2063
2066
|
: "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];
|
package/dist/store/jsonStore.js
CHANGED
|
@@ -4,10 +4,12 @@
|
|
|
4
4
|
* authoritative read/write surface; SQLite is rebuilt from it.
|
|
5
5
|
*/
|
|
6
6
|
import { closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, opendirSync, readSync, realpathSync, rmSync, } from "node:fs";
|
|
7
|
+
import { hostname } from "node:os";
|
|
7
8
|
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
8
9
|
import { ENTITY_KINDS, SCHEMAS } from "../core/types.js";
|
|
9
10
|
import { BASELINE_VERSION, migrateRaw, SCHEMA_VERSION } from "../core/migrate.js";
|
|
10
11
|
import { writeFileAtomic } from "../core/io.js";
|
|
12
|
+
import { readStoreArtifact } from "../core/storeArtifact.js";
|
|
11
13
|
/** High-cardinality collections (symbols, edges) are stored as a single
|
|
12
14
|
* index.json array — there can be thousands, and one file per edge would create
|
|
13
15
|
* enormous git noise. Curated, low-volume entities (components, decisions, bugs,
|
|
@@ -35,6 +37,29 @@ export const MAX_JSON_RECORD_BYTES = 8 * 1024 * 1024;
|
|
|
35
37
|
export const MAX_JSON_INDEX_BYTES = 256 * 1024 * 1024;
|
|
36
38
|
export const MAX_JSON_MANIFEST_BYTES = 64 * 1024;
|
|
37
39
|
export const MAX_JSON_DIRECTORY_ENTRIES_PER_KIND = 100_000;
|
|
40
|
+
function readRmwOwner(lock) {
|
|
41
|
+
const text = readStoreArtifact(lock, ["owner.tmp.json"], 4096);
|
|
42
|
+
if (text === null)
|
|
43
|
+
return undefined;
|
|
44
|
+
try {
|
|
45
|
+
const parsed = JSON.parse(text);
|
|
46
|
+
if (typeof parsed.pid !== "number" || !Number.isInteger(parsed.pid) || parsed.pid < 1 || typeof parsed.host !== "string")
|
|
47
|
+
return undefined;
|
|
48
|
+
return { pid: parsed.pid, host: parsed.host };
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function rmwPidAlive(pid) {
|
|
55
|
+
try {
|
|
56
|
+
process.kill(pid, 0);
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
return error.code === "EPERM";
|
|
61
|
+
}
|
|
62
|
+
}
|
|
38
63
|
function missing(error) {
|
|
39
64
|
return error.code === "ENOENT";
|
|
40
65
|
}
|
|
@@ -431,30 +456,51 @@ export class JsonStore {
|
|
|
431
456
|
* two unsynchronized RMWs over index.json each read the same base array and the
|
|
432
457
|
* second rename silently erases the first's record. `mkdirSync` is the atomic
|
|
433
458
|
* acquire (EEXIST = held). A stale lock (killed process) is taken over by age;
|
|
434
|
-
* against a live contender we wait briefly and then
|
|
435
|
-
*
|
|
436
|
-
*
|
|
459
|
+
* against a live contender we wait briefly and then refuse the write. Proceeding
|
|
460
|
+
* without the lock would reintroduce the record-loss race this mutex exists to
|
|
461
|
+
* prevent. */
|
|
437
462
|
withSingleFileLock(kind, directory, fn) {
|
|
438
463
|
const lock = join(directory.lexical, ".rmw-lock");
|
|
439
464
|
const deadline = Date.now() + 2_000;
|
|
440
465
|
for (;;) {
|
|
466
|
+
if (Date.now() >= deadline)
|
|
467
|
+
throw new Error(`[hunch] timed out acquiring the ${kind} index lock (still held: ${lock})`);
|
|
441
468
|
try {
|
|
442
469
|
mkdirSync(lock);
|
|
470
|
+
// Record ownership inside the already-exclusive directory. A live local
|
|
471
|
+
// writer may exceed the stale-age heuristic while serializing a large
|
|
472
|
+
// index; its PID must prevent a second writer from taking over.
|
|
473
|
+
try {
|
|
474
|
+
writeFileAtomic(join(lock, "owner.tmp.json"), JSON.stringify({ pid: process.pid, host: hostname() }));
|
|
475
|
+
}
|
|
476
|
+
catch (error) {
|
|
477
|
+
try {
|
|
478
|
+
rmSync(lock, { recursive: true, force: true });
|
|
479
|
+
}
|
|
480
|
+
catch { /* report the ownership failure below */ }
|
|
481
|
+
throw new Error(`[hunch] could not record ownership for the ${kind} index lock: ${error.message}`, { cause: error });
|
|
482
|
+
}
|
|
443
483
|
break;
|
|
444
484
|
}
|
|
445
|
-
catch {
|
|
485
|
+
catch (error) {
|
|
486
|
+
if (error.code !== "EEXIST")
|
|
487
|
+
throw error;
|
|
488
|
+
let stat;
|
|
446
489
|
try {
|
|
447
|
-
|
|
448
|
-
rmSync(lock, { recursive: true, force: true }); // no live spawn holds a lock this old
|
|
449
|
-
continue;
|
|
450
|
-
}
|
|
490
|
+
stat = lstatSync(lock);
|
|
451
491
|
}
|
|
452
|
-
catch {
|
|
453
|
-
|
|
492
|
+
catch (statError) {
|
|
493
|
+
if (statError.code === "ENOENT")
|
|
494
|
+
continue; // vanished between mkdir and inspect
|
|
495
|
+
throw statError;
|
|
454
496
|
}
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
497
|
+
const owner = readRmwOwner(lock);
|
|
498
|
+
const stale = owner && owner.host === hostname()
|
|
499
|
+
? !rmwPidAlive(owner.pid)
|
|
500
|
+
: Date.now() - stat.mtimeMs > 10_000;
|
|
501
|
+
if (stale) {
|
|
502
|
+
rmSync(lock, { recursive: true, force: true });
|
|
503
|
+
continue;
|
|
458
504
|
}
|
|
459
505
|
Atomics.wait(RMW_LOCK_WAITER, 0, 0, 25);
|
|
460
506
|
}
|
|
@@ -512,7 +558,12 @@ export class JsonStore {
|
|
|
512
558
|
// Sorted by id so the index has ONE canonical order — re-indexing after a
|
|
513
559
|
// git merge (which the driver also id-sorts) doesn't churn the whole file.
|
|
514
560
|
validated.sort((a, b) => String(a.id).localeCompare(String(b.id)));
|
|
515
|
-
|
|
561
|
+
// A rebuild is also a read-modify-write boundary from the perspective of
|
|
562
|
+
// concurrent put/delete callers: without the same mutex it can publish
|
|
563
|
+
// over an update that acquired the lock moments earlier (or vice versa).
|
|
564
|
+
this.withSingleFileLock(kind, directory, () => {
|
|
565
|
+
this.writeContainedFile(directory, this.fileFor(kind, "index"), encode(validated), this.maxBytes(kind));
|
|
566
|
+
});
|
|
516
567
|
return;
|
|
517
568
|
}
|
|
518
569
|
// One file per record: preflight EVERY existing JSON file before touching
|
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.32.
|
|
10
|
+
"version": "1.32.5",
|
|
11
11
|
"packages": [
|
|
12
12
|
{
|
|
13
13
|
"registryType": "npm",
|
|
14
14
|
"registryBaseUrl": "https://registry.npmjs.org",
|
|
15
15
|
"identifier": "@davesheffer/hunch",
|
|
16
|
-
"version": "1.32.
|
|
16
|
+
"version": "1.32.5",
|
|
17
17
|
"runtimeHint": "npx",
|
|
18
18
|
"packageArguments": [
|
|
19
19
|
{
|