@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.
Files changed (47) hide show
  1. package/README.md +10 -0
  2. package/dist/cli/index.js +114 -19
  3. package/dist/core/changeProof.js +2 -1
  4. package/dist/core/commitrepair.d.ts +9 -3
  5. package/dist/core/commitrepair.js +23 -6
  6. package/dist/core/correction.d.ts +1 -1
  7. package/dist/core/correction.js +4 -3
  8. package/dist/core/docanchors.d.ts +11 -2
  9. package/dist/core/docanchors.js +232 -65
  10. package/dist/core/drift.d.ts +1 -1
  11. package/dist/core/drift.js +10 -3
  12. package/dist/core/eol.d.ts +2 -0
  13. package/dist/core/eol.js +6 -0
  14. package/dist/core/procstart.d.ts +15 -4
  15. package/dist/core/procstart.js +56 -18
  16. package/dist/core/runbookDraft.d.ts +7 -0
  17. package/dist/core/runbookDraft.js +43 -0
  18. package/dist/core/taskTouched.d.ts +2 -0
  19. package/dist/core/taskTouched.js +1 -1
  20. package/dist/extractors/git.d.ts +36 -6
  21. package/dist/extractors/git.js +86 -9
  22. package/dist/extractors/indexer.js +53 -14
  23. package/dist/extractors/k8sManifest.d.ts +31 -1
  24. package/dist/extractors/k8sManifest.js +133 -8
  25. package/dist/integrations/claudemd.d.ts +2 -0
  26. package/dist/integrations/claudemd.js +4 -2
  27. package/dist/integrations/gitignore.d.ts +7 -0
  28. package/dist/integrations/gitignore.js +18 -0
  29. package/dist/integrations/hooks.d.ts +15 -0
  30. package/dist/integrations/hooks.js +49 -1
  31. package/dist/integrations/mergeDriver.d.ts +2 -0
  32. package/dist/integrations/mergeDriver.js +2 -2
  33. package/dist/integrations/providers.d.ts +7 -0
  34. package/dist/integrations/providers.js +4 -4
  35. package/dist/integrations/scaffold.d.ts +5 -0
  36. package/dist/integrations/scaffold.js +12 -7
  37. package/dist/integrations/uninstall.d.ts +40 -0
  38. package/dist/integrations/uninstall.js +435 -0
  39. package/dist/mcp/server.d.ts +2 -1
  40. package/dist/mcp/server.js +58 -4
  41. package/dist/serve/writelock.d.ts +7 -0
  42. package/dist/serve/writelock.js +30 -3
  43. package/dist/store/changeLedger.d.ts +5 -2
  44. package/dist/store/changeLedger.js +88 -37
  45. package/dist/store/hunchStore.js +4 -1
  46. package/package.json +3 -3
  47. package/server.json +2 -2
@@ -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). Removing a stale
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 token = opts.startToken ?? { self: selfStartToken(), of: startTokenOf };
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), ordered by time then ours-before-theirs, and RE-SEQUENCED from
131
- * the higher floor. When that renumbering moves an event either side had already published,
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), ordered by time then ours-before-theirs, and RE-SEQUENCED from
155
- * the higher floor. When that renumbering moves an event either side had already published,
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 seen = new Map();
168
- const order = [];
169
- const add = (e) => { const k = eventIdentity(e); if (!seen.has(k)) {
170
- seen.set(k, e);
171
- order.push(e);
172
- } };
173
- const ourIdentities = new Set(ours.events.map(eventIdentity));
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
- if (ourIdentities.has(key) || theirIdentities.has(key) || (e.seq > ours.floor_seq && e.seq > theirs.floor_seq))
179
- add(e);
180
- }
181
- for (const e of ours.events)
182
- add(e);
183
- for (const e of theirs.events)
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 = ranked.some(({ e }, i) => {
251
+ const renumbers = merged.some((e, i) => {
200
252
  const k = eventIdentity(e);
201
- const merged = floor + i + 1;
202
- return (ourSeqs.get(k) ?? merged) !== merged || (theirSeqs.get(k) ?? merged) !== merged;
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 = ranked.map(({ e }, i) => ({ ...e, seq: floor + i + 1 }));
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];
@@ -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
- const quoted = /^[\w@%+=:,./-]+$/.test(dir) ? dir : JSON.stringify(dir);
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.43.0",
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.30.0",
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.8.0",
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.43.0",
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.43.0",
16
+ "version": "1.44.0",
17
17
  "runtimeHint": "npx",
18
18
  "packageArguments": [
19
19
  {