@davesheffer/hunch 1.43.0 → 1.44.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -0
- package/dist/cli/index.js +114 -19
- package/dist/core/changeProof.js +2 -1
- package/dist/core/commitrepair.d.ts +9 -3
- package/dist/core/commitrepair.js +23 -6
- package/dist/core/correction.d.ts +1 -1
- package/dist/core/correction.js +4 -3
- package/dist/core/docanchors.d.ts +11 -2
- package/dist/core/docanchors.js +232 -65
- package/dist/core/drift.d.ts +1 -1
- package/dist/core/drift.js +10 -3
- package/dist/core/eol.d.ts +2 -0
- package/dist/core/eol.js +6 -0
- package/dist/core/procstart.d.ts +15 -4
- package/dist/core/procstart.js +56 -18
- package/dist/core/runbookDraft.d.ts +7 -0
- package/dist/core/runbookDraft.js +43 -0
- package/dist/core/taskTouched.d.ts +2 -0
- package/dist/core/taskTouched.js +1 -1
- package/dist/extractors/git.d.ts +36 -6
- package/dist/extractors/git.js +86 -9
- package/dist/extractors/indexer.js +53 -14
- package/dist/extractors/k8sManifest.d.ts +31 -1
- package/dist/extractors/k8sManifest.js +133 -8
- package/dist/integrations/claudemd.d.ts +2 -0
- package/dist/integrations/claudemd.js +4 -2
- package/dist/integrations/gitignore.d.ts +7 -0
- package/dist/integrations/gitignore.js +18 -0
- package/dist/integrations/hooks.d.ts +15 -0
- package/dist/integrations/hooks.js +49 -1
- package/dist/integrations/mergeDriver.d.ts +2 -0
- package/dist/integrations/mergeDriver.js +2 -2
- package/dist/integrations/providers.d.ts +7 -0
- package/dist/integrations/providers.js +4 -4
- package/dist/integrations/scaffold.d.ts +5 -0
- package/dist/integrations/scaffold.js +12 -7
- package/dist/integrations/uninstall.d.ts +40 -0
- package/dist/integrations/uninstall.js +435 -0
- package/dist/mcp/server.d.ts +2 -1
- package/dist/mcp/server.js +58 -4
- package/dist/serve/writelock.d.ts +7 -0
- package/dist/serve/writelock.js +30 -3
- package/dist/store/changeLedger.d.ts +5 -2
- package/dist/store/changeLedger.js +88 -37
- package/dist/store/hunchStore.js +4 -1
- package/package.json +3 -3
- package/server.json +2 -2
package/dist/serve/writelock.js
CHANGED
|
@@ -10,7 +10,10 @@
|
|
|
10
10
|
* stealable once its owner is provably gone (pid dead — or RECYCLED — on the same
|
|
11
11
|
* host) or older than any plausible write. "Recycled" is decided by comparing the
|
|
12
12
|
* owner's recorded process-instance token with the current holder's, by string
|
|
13
|
-
* equality and never by clock arithmetic (see core/procstart.ts).
|
|
13
|
+
* equality and never by clock arithmetic (see core/procstart.ts). Where no token can
|
|
14
|
+
* compare them (macOS, Windows, legacy records) a stale-looking lock whose live pid
|
|
15
|
+
* belongs to a process CREATED after the lock's recorded `at` is taken over too: that
|
|
16
|
+
* process cannot be the one that took it (issue #287). Removing a stale
|
|
14
17
|
* lock is itself serialized behind an exclusive `.reclaim` claim directory, because
|
|
15
18
|
* judging and removing are two steps and two contenders can otherwise both "win"
|
|
16
19
|
* (see `takeOver`).
|
|
@@ -19,7 +22,7 @@ import { hostname } from "node:os";
|
|
|
19
22
|
import { randomBytes } from "node:crypto";
|
|
20
23
|
import { closeSync, mkdirSync, openSync, readFileSync, rmSync, rmdirSync, statSync, writeSync } from "node:fs";
|
|
21
24
|
import { join } from "node:path";
|
|
22
|
-
import { heldLockNonces, judgeSameHostOwner, selfStartToken, startTokenOf } from "../core/procstart.js";
|
|
25
|
+
import { heldLockNonces, judgeSameHostOwner, processStartedAt, selfStartToken, startTokenOf } from "../core/procstart.js";
|
|
23
26
|
/** Longest a write may hold the lock before another process may steal it: a put plus a
|
|
24
27
|
* git commit and push — seconds, not minutes. */
|
|
25
28
|
export const STALE_AFTER_MS = 60_000;
|
|
@@ -30,6 +33,10 @@ export const ACQUIRE_TIMEOUT_MS = 10_000;
|
|
|
30
33
|
* crashed inside it. */
|
|
31
34
|
export const CLAIM_STALE_MS = 10_000;
|
|
32
35
|
const POLL_MS = 25;
|
|
36
|
+
/** How much later than the lock's recorded `at` the pid's current process must
|
|
37
|
+
* have been created before it is proven not to be the owner. A genuine owner
|
|
38
|
+
* started BEFORE it took the lock; the slack only absorbs clock granularity. */
|
|
39
|
+
export const CREATED_AFTER_SLACK_MS = 5_000;
|
|
33
40
|
export class WriteLockTimeout extends Error {
|
|
34
41
|
path;
|
|
35
42
|
heldBy;
|
|
@@ -109,11 +116,22 @@ function stealable(path, owner, now, token) {
|
|
|
109
116
|
selfPid: process.pid,
|
|
110
117
|
heldNonces: heldLockNonces,
|
|
111
118
|
});
|
|
119
|
+
if (verdict === "unproven")
|
|
120
|
+
return ageMs > STALE_AFTER_MS && createdAfterLock(owner, token);
|
|
112
121
|
if (verdict !== "age")
|
|
113
122
|
return verdict === "stale";
|
|
114
123
|
}
|
|
115
124
|
return ageMs > STALE_AFTER_MS;
|
|
116
125
|
}
|
|
126
|
+
/** Is the process now holding the owner's pid provably newer than the lock?
|
|
127
|
+
* Unreadable either way means not proven, and a live pid keeps its lock. */
|
|
128
|
+
function createdAfterLock(owner, token) {
|
|
129
|
+
const at = Date.parse(owner.at);
|
|
130
|
+
if (!Number.isFinite(at))
|
|
131
|
+
return false;
|
|
132
|
+
const started = (token.startedAt ?? processStartedAt)(owner.pid);
|
|
133
|
+
return started !== null && started > at + CREATED_AFTER_SLACK_MS;
|
|
134
|
+
}
|
|
117
135
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
118
136
|
/** The claim a contender must hold to REMOVE a stale lock. A sibling directory,
|
|
119
137
|
* created with the same exclusive `mkdir` the commit lock uses
|
|
@@ -204,7 +222,16 @@ function takeOver(path, token) {
|
|
|
204
222
|
export async function withWriteLock(hunchDir, fn, opts = {}) {
|
|
205
223
|
const path = writeLockPath(hunchDir);
|
|
206
224
|
const timeoutMs = opts.timeoutMs ?? ACQUIRE_TIMEOUT_MS;
|
|
207
|
-
const
|
|
225
|
+
const probe = opts.startToken ?? { self: selfStartToken(), of: startTokenOf };
|
|
226
|
+
// The creation-time probe spawns; ask it once per pid for this acquisition, not
|
|
227
|
+
// on every poll.
|
|
228
|
+
const createdAt = new Map();
|
|
229
|
+
const startedAt = probe.startedAt ?? processStartedAt;
|
|
230
|
+
const token = { ...probe, startedAt: (pid) => {
|
|
231
|
+
if (!createdAt.has(pid))
|
|
232
|
+
createdAt.set(pid, startedAt(pid));
|
|
233
|
+
return createdAt.get(pid);
|
|
234
|
+
} };
|
|
208
235
|
const nonce = randomBytes(8).toString("hex");
|
|
209
236
|
const started = Date.now();
|
|
210
237
|
let fd;
|
|
@@ -127,8 +127,11 @@ export declare function compactLedger(hunchDir: string, scope: Scope, opts?: {
|
|
|
127
127
|
};
|
|
128
128
|
/** Three-way merge of one scope's ledger, for the git merge driver: two clones that both
|
|
129
129
|
* appended to the same partition. The union of events is kept (identity = what changed, to
|
|
130
|
-
* which hash, when, by whom),
|
|
131
|
-
* the
|
|
130
|
+
* which hash, when, by whom), interleaved so each side keeps its own order (see `interleave`;
|
|
131
|
+
* the result does not depend on which side is "ours"), and RE-SEQUENCED from the higher floor.
|
|
132
|
+
* Two sides that published opposite orders (left by the side-biased merge before #361) are
|
|
133
|
+
* resolved by (time, identity), never a conflict: that moves published seqs, so the floor
|
|
134
|
+
* rule below makes every subscriber resynchronize onto the one merged order. When that renumbering moves an event either side had already published,
|
|
132
135
|
* no pre-merge cursor of EITHER side can be trusted — a cursor could otherwise sit above an
|
|
133
136
|
* event that now holds a lower seq and never see it (issue #285). The floor is then raised
|
|
134
137
|
* past BOTH pre-merge heads, so every such cursor satisfies subscribe's `after_seq <
|
|
@@ -149,10 +149,79 @@ export function compactLedger(hunchDir, scope, opts = {}) {
|
|
|
149
149
|
return { dropped, floor_seq: ledger.floor_seq, head_seq: ledger.head_seq };
|
|
150
150
|
}
|
|
151
151
|
const eventIdentity = (e) => [e.change, e.facet, e.record_id, e.record_hash, e.at, e.cause ? JSON.stringify(e.cause) : ""].join("|");
|
|
152
|
+
/** A total order: by instant (so `...:09Z`, `...:09.500Z` and `+02:00` offsets compare as
|
|
153
|
+
* times, not text), unparseable `at` after every parseable one, then `at` text, then identity. */
|
|
154
|
+
const byTimeThenIdentity = (a, b) => {
|
|
155
|
+
const ta = Date.parse(a.at), tb = Date.parse(b.at);
|
|
156
|
+
if (Number.isNaN(ta) !== Number.isNaN(tb))
|
|
157
|
+
return Number.isNaN(ta) ? 1 : -1;
|
|
158
|
+
if (!Number.isNaN(ta) && ta !== tb)
|
|
159
|
+
return ta < tb ? -1 : 1;
|
|
160
|
+
if (a.at !== b.at)
|
|
161
|
+
return a.at < b.at ? -1 : 1;
|
|
162
|
+
const ka = eventIdentity(a), kb = eventIdentity(b);
|
|
163
|
+
return ka < kb ? -1 : ka > kb ? 1 : 0;
|
|
164
|
+
};
|
|
165
|
+
/** Interleave two event sequences without reordering either (issue #361). Each side's order
|
|
166
|
+
* is history it already published — a batch, or successive updates to one record — so it is
|
|
167
|
+
* kept; only events neither side orders relative to each other are placed by (time, identity).
|
|
168
|
+
* Every rule is mirrored, so swapping the sides yields the same sequence: merge(A,B) and
|
|
169
|
+
* merge(B,A) agree, and re-merging their results renumbers nothing. Where the two sides
|
|
170
|
+
* order a pair oppositely — clones an older, side-biased merge already left diverged — the
|
|
171
|
+
* same (time, identity) rule decides, so even they converge on one order. */
|
|
172
|
+
function interleave(a, b) {
|
|
173
|
+
const posA = new Map(a.map((e, i) => [eventIdentity(e), i]));
|
|
174
|
+
const posB = new Map(b.map((e, i) => [eventIdentity(e), i]));
|
|
175
|
+
const emitted = new Set();
|
|
176
|
+
const out = [];
|
|
177
|
+
const emit = (e) => { emitted.add(eventIdentity(e)); out.push(e); };
|
|
178
|
+
let i = 0, j = 0;
|
|
179
|
+
for (;;) {
|
|
180
|
+
while (i < a.length && emitted.has(eventIdentity(a[i])))
|
|
181
|
+
i++;
|
|
182
|
+
while (j < b.length && emitted.has(eventIdentity(b[j])))
|
|
183
|
+
j++;
|
|
184
|
+
if (i >= a.length) {
|
|
185
|
+
for (; j < b.length; j++)
|
|
186
|
+
if (!emitted.has(eventIdentity(b[j])))
|
|
187
|
+
emit(b[j]);
|
|
188
|
+
break;
|
|
189
|
+
}
|
|
190
|
+
if (j >= b.length) {
|
|
191
|
+
for (; i < a.length; i++)
|
|
192
|
+
if (!emitted.has(eventIdentity(a[i])))
|
|
193
|
+
emit(a[i]);
|
|
194
|
+
break;
|
|
195
|
+
}
|
|
196
|
+
const x = a[i], y = b[j];
|
|
197
|
+
const kx = eventIdentity(x), ky = eventIdentity(y);
|
|
198
|
+
if (kx === ky) {
|
|
199
|
+
emit(x);
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
const xLaterInB = (posB.get(kx) ?? -1) > j; // b orders y before x
|
|
203
|
+
const yLaterInA = (posA.get(ky) ?? -1) > i; // a orders x before y
|
|
204
|
+
if (xLaterInB && !yLaterInA)
|
|
205
|
+
emit(y);
|
|
206
|
+
else if (yLaterInA && !xLaterInB)
|
|
207
|
+
emit(x);
|
|
208
|
+
else
|
|
209
|
+
emit(byTimeThenIdentity(x, y) <= 0 ? x : y);
|
|
210
|
+
}
|
|
211
|
+
return out;
|
|
212
|
+
}
|
|
213
|
+
function uniqueByIdentity(events) {
|
|
214
|
+
const seen = new Set();
|
|
215
|
+
return events.filter((e) => { const k = eventIdentity(e); if (seen.has(k))
|
|
216
|
+
return false; seen.add(k); return true; });
|
|
217
|
+
}
|
|
152
218
|
/** Three-way merge of one scope's ledger, for the git merge driver: two clones that both
|
|
153
219
|
* appended to the same partition. The union of events is kept (identity = what changed, to
|
|
154
|
-
* which hash, when, by whom),
|
|
155
|
-
* the
|
|
220
|
+
* which hash, when, by whom), interleaved so each side keeps its own order (see `interleave`;
|
|
221
|
+
* the result does not depend on which side is "ours"), and RE-SEQUENCED from the higher floor.
|
|
222
|
+
* Two sides that published opposite orders (left by the side-biased merge before #361) are
|
|
223
|
+
* resolved by (time, identity), never a conflict: that moves published seqs, so the floor
|
|
224
|
+
* rule below makes every subscriber resynchronize onto the one merged order. When that renumbering moves an event either side had already published,
|
|
156
225
|
* no pre-merge cursor of EITHER side can be trusted — a cursor could otherwise sit above an
|
|
157
226
|
* event that now holds a lower seq and never see it (issue #285). The floor is then raised
|
|
158
227
|
* past BOTH pre-merge heads, so every such cursor satisfies subscribe's `after_seq <
|
|
@@ -164,47 +233,29 @@ const eventIdentity = (e) => [e.change, e.facet, e.record_id, e.record_hash, e.a
|
|
|
164
233
|
export function mergeLedgers(base, ours, theirs) {
|
|
165
234
|
if (scopePath(ours.scope) !== scopePath(theirs.scope))
|
|
166
235
|
throw new Error("ledgers for different scopes cannot be merged");
|
|
167
|
-
const
|
|
168
|
-
const
|
|
169
|
-
const
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
const
|
|
174
|
-
const theirIdentities = new Set(theirs.events.map(eventIdentity));
|
|
175
|
-
// Base is a merge ancestor, not a source of history both clones have compacted away.
|
|
176
|
-
for (const e of base?.events ?? []) {
|
|
236
|
+
const oursEvents = uniqueByIdentity(ours.events);
|
|
237
|
+
const theirsEvents = uniqueByIdentity(theirs.events);
|
|
238
|
+
const ourIdentities = new Set(oursEvents.map(eventIdentity));
|
|
239
|
+
const theirIdentities = new Set(theirsEvents.map(eventIdentity));
|
|
240
|
+
// Base is a merge ancestor, not a source of history both clones have compacted away: it adds
|
|
241
|
+
// only events neither side holds that are still above both floors.
|
|
242
|
+
const baseOnly = uniqueByIdentity((base?.events ?? []).filter((e) => {
|
|
177
243
|
const key = eventIdentity(e);
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
add(e);
|
|
185
|
-
// The tiebreak is "ours before theirs", so only an event theirs ALONE contributed sorts late:
|
|
186
|
-
// side 1 iff theirs holds it and neither ours nor base does. Deciding by object identity
|
|
187
|
-
// (`includes`) would instead put anything ours compacted away — base's object, which `add`
|
|
188
|
-
// keeps — after its same-`at` siblings and silently reorder a batch both sides already had.
|
|
189
|
-
const baseIdentities = new Set((base?.events ?? []).map(eventIdentity));
|
|
190
|
-
const theirsOnly = (e) => {
|
|
191
|
-
const k = eventIdentity(e);
|
|
192
|
-
return !ourIdentities.has(k) && !baseIdentities.has(k);
|
|
193
|
-
};
|
|
194
|
-
const ranked = order.map((e, i) => ({ e, i, side: (theirsOnly(e) ? 1 : 0) }));
|
|
195
|
-
ranked.sort((a, b) => a.e.at.localeCompare(b.e.at) || a.side - b.side || a.i - b.i);
|
|
196
|
-
const theirSeqs = new Map(theirs.events.map((e) => [eventIdentity(e), e.seq]));
|
|
197
|
-
const ourSeqs = new Map(ours.events.map((e) => [eventIdentity(e), e.seq]));
|
|
244
|
+
return !ourIdentities.has(key) && !theirIdentities.has(key) && e.seq > ours.floor_seq && e.seq > theirs.floor_seq;
|
|
245
|
+
}));
|
|
246
|
+
const merged = interleave(interleave(oursEvents, theirsEvents), baseOnly);
|
|
247
|
+
const conflicts = [];
|
|
248
|
+
const theirSeqs = new Map(theirsEvents.map((e) => [eventIdentity(e), e.seq]));
|
|
249
|
+
const ourSeqs = new Map(oursEvents.map((e) => [eventIdentity(e), e.seq]));
|
|
198
250
|
let floor = Math.max(base?.floor_seq ?? 0, ours.floor_seq, theirs.floor_seq);
|
|
199
|
-
const renumbers =
|
|
251
|
+
const renumbers = merged.some((e, i) => {
|
|
200
252
|
const k = eventIdentity(e);
|
|
201
|
-
const
|
|
202
|
-
return (ourSeqs.get(k) ??
|
|
253
|
+
const seq = floor + i + 1;
|
|
254
|
+
return (ourSeqs.get(k) ?? seq) !== seq || (theirSeqs.get(k) ?? seq) !== seq;
|
|
203
255
|
});
|
|
204
256
|
if (renumbers)
|
|
205
257
|
floor = Math.max(floor, Math.max(ours.head_seq, theirs.head_seq) + 1);
|
|
206
|
-
const events =
|
|
207
|
-
const conflicts = [];
|
|
258
|
+
const events = merged.map((e, i) => ({ ...e, seq: floor + i + 1 }));
|
|
208
259
|
const idempotency = { ...(base?.idempotency ?? {}), ...theirs.idempotency, ...ours.idempotency };
|
|
209
260
|
for (const [key, entry] of Object.entries(theirs.idempotency)) {
|
|
210
261
|
const mine = ours.idempotency[key];
|
package/dist/store/hunchStore.js
CHANGED
|
@@ -202,7 +202,10 @@ export class HunchStore {
|
|
|
202
202
|
* that re-registers it. Never auto-re-registered: the pointer may be repository content. */
|
|
203
203
|
unregisteredPointerMessage(dir) {
|
|
204
204
|
const { mode, autoCommit } = this.ignoredLocalPointerSetup;
|
|
205
|
-
|
|
205
|
+
// A Windows drive path is shown with forward slashes: it means the same path to Hunch and
|
|
206
|
+
// pastes unquoted into bash, PowerShell and cmd. JSON quoting would double every backslash.
|
|
207
|
+
const shown = /^[a-z]:\\/i.test(dir) ? dir.replace(/\\/g, "/") : dir;
|
|
208
|
+
const quoted = /^[\w@%+=:,./-]+$/.test(shown) ? shown : JSON.stringify(shown);
|
|
206
209
|
const command = `hunch ${mode === "shared" ? "shared" : "private"} ${quoted}${autoCommit === false ? " --no-auto-commit" : ""}`;
|
|
207
210
|
return `.hunch/local.json names a memory store at ${dir} that this machine's setup has not registered in the git common dir, ` +
|
|
208
211
|
"so Hunch refuses to open this repository's memory rather than silently writing captures to the public .hunch/. " +
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@davesheffer/hunch",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.44.0",
|
|
4
4
|
"mcpName": "io.github.davesheffer/hunch",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Dave Sheffer <dave.sheffer1@gmail.com>",
|
|
@@ -96,11 +96,11 @@
|
|
|
96
96
|
"prepublishOnly": "npm run build"
|
|
97
97
|
},
|
|
98
98
|
"dependencies": {
|
|
99
|
-
"@modelcontextprotocol/sdk": "^1.
|
|
99
|
+
"@modelcontextprotocol/sdk": "^1.31.0",
|
|
100
100
|
"@tree-sitter-grammars/tree-sitter-yaml": "^0.6.1",
|
|
101
101
|
"commander": "^15.0.0",
|
|
102
102
|
"cross-spawn": "^7.0.6",
|
|
103
|
-
"smol-toml": "1.
|
|
103
|
+
"smol-toml": "1.9.0",
|
|
104
104
|
"tree-sitter": "0.21.1",
|
|
105
105
|
"tree-sitter-go": "^0.23.4",
|
|
106
106
|
"tree-sitter-php": "0.23.12",
|
package/server.json
CHANGED
|
@@ -7,13 +7,13 @@
|
|
|
7
7
|
"source": "github"
|
|
8
8
|
},
|
|
9
9
|
"websiteUrl": "https://www.hunchmemory.com",
|
|
10
|
-
"version": "1.
|
|
10
|
+
"version": "1.44.0",
|
|
11
11
|
"packages": [
|
|
12
12
|
{
|
|
13
13
|
"registryType": "npm",
|
|
14
14
|
"registryBaseUrl": "https://registry.npmjs.org",
|
|
15
15
|
"identifier": "@davesheffer/hunch",
|
|
16
|
-
"version": "1.
|
|
16
|
+
"version": "1.44.0",
|
|
17
17
|
"runtimeHint": "npx",
|
|
18
18
|
"packageArguments": [
|
|
19
19
|
{
|