@davesheffer/hunch 1.42.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 (56) hide show
  1. package/README.md +14 -0
  2. package/dist/cli/index.js +206 -37
  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/delivery.d.ts +12 -0
  9. package/dist/core/delivery.js +4 -4
  10. package/dist/core/docanchors.d.ts +11 -2
  11. package/dist/core/docanchors.js +232 -65
  12. package/dist/core/drift.d.ts +1 -1
  13. package/dist/core/drift.js +10 -3
  14. package/dist/core/eol.d.ts +2 -0
  15. package/dist/core/eol.js +6 -0
  16. package/dist/core/hookcache.d.ts +22 -0
  17. package/dist/core/hookcache.js +53 -2
  18. package/dist/core/procstart.d.ts +15 -4
  19. package/dist/core/procstart.js +56 -18
  20. package/dist/core/publication.js +17 -3
  21. package/dist/core/runbookDraft.d.ts +7 -0
  22. package/dist/core/runbookDraft.js +43 -0
  23. package/dist/core/taskReportHook.js +1 -1
  24. package/dist/core/taskSelection.d.ts +67 -0
  25. package/dist/core/taskSelection.js +196 -0
  26. package/dist/core/taskTouched.d.ts +2 -0
  27. package/dist/core/taskTouched.js +1 -1
  28. package/dist/extractors/git.d.ts +36 -6
  29. package/dist/extractors/git.js +86 -9
  30. package/dist/extractors/indexer.js +53 -14
  31. package/dist/extractors/k8sManifest.d.ts +31 -1
  32. package/dist/extractors/k8sManifest.js +133 -8
  33. package/dist/integrations/claudemd.d.ts +2 -0
  34. package/dist/integrations/claudemd.js +4 -2
  35. package/dist/integrations/gitignore.d.ts +7 -0
  36. package/dist/integrations/gitignore.js +18 -0
  37. package/dist/integrations/hooks.d.ts +15 -0
  38. package/dist/integrations/hooks.js +49 -1
  39. package/dist/integrations/mergeDriver.d.ts +2 -0
  40. package/dist/integrations/mergeDriver.js +2 -2
  41. package/dist/integrations/providers.d.ts +7 -0
  42. package/dist/integrations/providers.js +6 -5
  43. package/dist/integrations/scaffold.d.ts +5 -0
  44. package/dist/integrations/scaffold.js +14 -8
  45. package/dist/integrations/uninstall.d.ts +40 -0
  46. package/dist/integrations/uninstall.js +435 -0
  47. package/dist/mcp/server.d.ts +2 -1
  48. package/dist/mcp/server.js +58 -4
  49. package/dist/mcp/taskReportTools.js +1 -1
  50. package/dist/serve/writelock.d.ts +7 -0
  51. package/dist/serve/writelock.js +30 -3
  52. package/dist/store/changeLedger.d.ts +5 -2
  53. package/dist/store/changeLedger.js +88 -37
  54. package/dist/store/hunchStore.js +4 -1
  55. package/package.json +3 -3
  56. package/server.json +2 -2
@@ -32,7 +32,7 @@ import { knownRepoDeps } from "../synthesis/tripwires.js";
32
32
  import { refreshExistingGrounding } from "../integrations/providers.js";
33
33
  import { workspaceLedgerView, renderWorktreeTable, renderBranchTable, workspaceSummaryLine, snapshotHasHome, recordWorkspaceSnapshot, branchRows, worktreeRows } from "../integrations/workspaceLedger.js";
34
34
  import { workspacesConfig } from "../core/config.js";
35
- import { revParse, asOfDate, revExists, lastChangeDate, rangeFiles, rangeGateDiff, commitFiles, commitGateDiff, stagedFiles, stagedGateDiff, workingFiles, workingGateDiff, pullHunchStatus, sameRemoteUrl, currentBranch, worktreePaths, pathKnownToHistory } from "../extractors/git.js";
35
+ import { revParse, asOfDate, revExists, lastChangeDate, checkFreshnessRevision, teamPullIsFresh, rangeFiles, rangeGateDiff, commitFiles, commitGateDiff, stagedFiles, stagedGateDiff, workingFiles, workingGateDiff, pullHunchStatus, sameRemoteUrl, currentBranch, worktreePaths, pathKnownToHistory } from "../extractors/git.js";
36
36
  import { flushCapture, flushMemoryHome, pinSharedRemote } from "../integrations/sync.js";
37
37
  import { withWriteLock } from "../serve/writelock.js";
38
38
  import { advertisedTeamRemoteContract, ensureTeamOverlay, isTeamStoreTrusted, overlayMatchesTeamRemote, teamWiringConsented, untrustedTeamStoreMessage, readTeamConfig, teamRemoteContract, teamSharedRef } from "../integrations/team.js";
@@ -940,6 +940,16 @@ function unconfirmedReason(c) {
940
940
  : "this client could not ask the human to confirm it (no MCP form elicitation, or the request failed)";
941
941
  }
942
942
  const clipForPrompt = (s, max = 400) => (s.length > max ? `${s.slice(0, max - 1)}…` : s);
943
+ /** Whether a re-record changes what a human signed: every field except bookkeeping
944
+ * (provenance, date, valid_from). Fails closed: a field added to Decision later counts
945
+ * as substance until it is named here. Absent, null and empty-array values are equal. */
946
+ const DECISION_BOOKKEEPING = new Set(["provenance", "date", "valid_from"]);
947
+ export function decisionSubstanceChanged(before, after) {
948
+ const norm = (v) => (v === undefined || (Array.isArray(v) && v.length === 0) ? null : v);
949
+ const keys = [...new Set([...Object.keys(before), ...Object.keys(after)])].filter((k) => !DECISION_BOOKKEEPING.has(k)).sort();
950
+ const substance = (d) => JSON.stringify(keys.map((k) => [k, norm(d[k])]));
951
+ return substance(before) !== substance(after);
952
+ }
943
953
  /** The interrogation protocol returned by hunch_capture_decision. With `deciding`,
944
954
  * the choice is NOT yet made: the verdict loop runs first so the record's
945
955
  * alternatives_rejected are attacks that actually ran — not post-hoc fiction. */
@@ -1018,7 +1028,7 @@ function pullBackoff(status, finishedAt, failures) {
1018
1028
  if (status === "busy") {
1019
1029
  return { consecutivePullFailures: failures, nextRemotePullAt: finishedAt + 100 };
1020
1030
  }
1021
- if (status === "unconfigured") {
1031
+ if (status === "unconfigured" || status === "empty") {
1022
1032
  return { consecutivePullFailures: 0, nextRemotePullAt: finishedAt + 30_000 };
1023
1033
  }
1024
1034
  const consecutivePullFailures = Math.min(failures + 1, 6);
@@ -1954,6 +1964,24 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
1954
1964
  const decision = store.advisoryRecs("decisions").find((candidate) => candidate.id === decision_id);
1955
1965
  if (!decision)
1956
1966
  return invalid(`Imported ADR ${decision_id} is not present in the current advisory memory home.`);
1967
+ // Approval grants human authority, so the agent's report that the human approved
1968
+ // is not enough: the human confirms in the client prompt, a channel the agent does
1969
+ // not control, or approves with the CLI. A decline keeps the ADR advisory.
1970
+ if (disposition === "approve") {
1971
+ const confirmation = await askHumanToConfirm(server, [
1972
+ "Hunch: an agent reports that you approved this imported ADR as your decision. Confirm only if YOU approve it.",
1973
+ "",
1974
+ `Title: ${clipForPrompt(decision.title, 200)}`,
1975
+ `Decision: ${clipForPrompt(decision.decision || "(none)")}`,
1976
+ `Source hash: ${expected_source_hash}`,
1977
+ "",
1978
+ "Confirmed, it carries your authority (human_confirmed). Unconfirmed, it stays advisory.",
1979
+ ].join("\n"));
1980
+ if (confirmation !== "confirmed") {
1981
+ return refused(`Not approved: ${unconfirmedReason(confirmation)}, so ${decision_id} stays advisory and nothing was written. ` +
1982
+ `The human approves it by running: hunch review --approve-import ${decision_id} --expected-source-hash ${expected_source_hash} --expected-review-hash ${expected_review_hash} --reviewed-by <you>`);
1983
+ }
1984
+ }
1957
1985
  const reviewed = applyImportedAdrReview(decision, {
1958
1986
  disposition,
1959
1987
  expectedSourceHash: expected_source_hash,
@@ -2046,7 +2074,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
2046
2074
  // bounded convergence. An unavailable remote produces an explicit abstention.
2047
2075
  if (live.length === 0 && store.mode === "shared" && store.privateDir) {
2048
2076
  const status = pullTeamMemory(true);
2049
- if (status !== "updated" && status !== "current") {
2077
+ if (status === "cooldown" || status === "not_shared" || !teamPullIsFresh(status)) {
2050
2078
  return err(`Shared team memory refresh is ${status}; Hunch cannot confirm that topic "${topic}" has no current decision. Retry when the shared store is available.`);
2051
2079
  }
2052
2080
  try {
@@ -2212,6 +2240,8 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
2212
2240
  valid_from: existing?.valid_from ?? now,
2213
2241
  valid_to: existing?.valid_to ?? null,
2214
2242
  retired: existing?.retired ?? { symbols: [], deps: [] },
2243
+ ...(existing?.conformance ? { conformance: existing.conformance } : {}),
2244
+ ...(existing?.visibility ? { visibility: existing.visibility } : {}),
2215
2245
  provenance: { source: "agent_recorded", confidence: 0.75, evidence: (decision.related_files ?? existing?.provenance.evidence ?? []).map(toPosixTarget) },
2216
2246
  date: now,
2217
2247
  };
@@ -2261,6 +2291,23 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
2261
2291
  const humanSigned = confirmation === "confirmed";
2262
2292
  if (slotConflict && !humanSigned)
2263
2293
  return slotRefusal();
2294
+ // An inherited signature covers only the text the human signed. Changed substance
2295
+ // without a fresh confirmation would keep human_confirmed on words nobody vouched for.
2296
+ if (existingIsHuman && !humanSigned) {
2297
+ if (decisionSubstanceChanged(existing, rec)) {
2298
+ return refused(`Decision ${id} is human-confirmed and this write changes its content; refusing to keep the human's signature on unconfirmed text, so nothing was written. ` +
2299
+ "Record the change through /capture so the human confirms it in the client prompt.");
2300
+ }
2301
+ // Same substance: the human's record already says this. Rewriting it would only
2302
+ // replace the human's provenance with the agent's, so it is a no-op.
2303
+ return ok(`Decision ${id} is human-confirmed and already says this; nothing was written.`);
2304
+ }
2305
+ // Closing a human-confirmed decision is as much a change to it as rewriting it.
2306
+ const supersededIncumbent = decision.supersedes ? store.recs("decisions").find((d) => d.id === decision.supersedes) : undefined;
2307
+ if (supersededIncumbent && !humanSigned && supersededIncumbent.provenance.source.split("+").includes("human_confirmed")) {
2308
+ return refused(`Decision ${supersededIncumbent.id} is human-confirmed; an unconfirmed write cannot supersede it, so nothing was written. ` +
2309
+ "Record the change through /capture so the human confirms it in the client prompt.");
2310
+ }
2264
2311
  const tier = humanSigned || existingIsHuman ? "human_confirmed" : "agent_recorded";
2265
2312
  const source = existing && existing.provenance.source.includes("llm_draft")
2266
2313
  ? `llm_draft+${tier}`
@@ -2385,6 +2432,12 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
2385
2432
  }
2386
2433
  const rec = buildCorrectionConstraint({ ...input, knownDeps, root, vouched: false }, now);
2387
2434
  const existing = home === "private" ? store.getPrivateRec("constraints", rec.id) : store.json.get("constraints", rec.id);
2435
+ // A human-confirmed rule is changed only by the same human act that confirmed it.
2436
+ // Writing `rec` here would replace the signature with testimony and cap its severity.
2437
+ if (existing && existing.provenance.source.split("+").includes("human_confirmed")) {
2438
+ return refused(`Constraint ${rec.id} is already human-confirmed; an agent write cannot replace or downgrade it, so nothing was written. ` +
2439
+ `A change needs the human, outside the agent channel: ${confirmCommand(rec.id, { private: home === "private", severity: requested.severity })}`);
2440
+ }
2388
2441
  // Same cross-home twin guard as the decision path above.
2389
2442
  const stored = store.putCapture("constraints", rec, !!input.private);
2390
2443
  const observed = observeReportCapture(root, input.task_id, "constraints", stored, home, !!existing, home === "private" ? store.privateDir ?? undefined : hunchPaths(root).hunch);
@@ -2812,7 +2865,8 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
2812
2865
  if (!files.length)
2813
2866
  return ok(`VERDICT: ✅ PASS — no changed files in ${scope}.`);
2814
2867
  const gate = commit ? commitGateDiff(commit, root) : base ? rangeGateDiff(base, root) : working ? workingGateDiff(root) : stagedGateDiff(root);
2815
- const report = store.buildCheckReport(files, gate.diff, { strict: true, lastChange: (f) => lastChangeDate(f, root), diffStatus: gate });
2868
+ const freshAt = checkFreshnessRevision({ base, commit }, root);
2869
+ const report = store.buildCheckReport(files, gate.diff, { strict: true, lastChange: (f) => lastChangeDate(f, root, freshAt), diffStatus: gate });
2816
2870
  const v = verdict(report);
2817
2871
  const head = v === "block"
2818
2872
  ? "VERDICT: ⛔ BLOCK — a recorded guard requires review; inspect the cited scope and evidence below before merge."
@@ -90,7 +90,7 @@ export function registerTaskReportTools(server, getRoot, getStore) {
90
90
  const launcher = verificationLauncher();
91
91
  // Compact by design (#370): the rules an agent needs to act, and exact
92
92
  // identities; scope, title and timestamps stay in `hunch report <id> --json`.
93
- return { content: [{ type: "text", text: `Task ${task.task_id} · ${task.state}. Pass task_id to hunch_context and every decision/correction/finding capture (a capture is not proof of a commit or push). To claim an application, copy occurrence_id, record_id and content_hash exactly from hunch_report(task_id) application_references, with the action you took; omit applications you did not make. Before the final response, finish with hunch_task (outcome "interrupted" if cut short) and include its contribution card verbatim, Evidence line and agent-reported label included, unless presentation_enabled is false. Never rerun an expensive check only for reporting; missing evidence stays unverified. Checks, with this exact installation (a global hunch may be stale): ${launcher.shell} task verify ${task.task_id} -- <command> [arguments]${launcher.note} (15-minute default; --timeout <seconds> before -- for longer).` }], structuredContent: { task: { task_id: task.task_id, state: task.state }, verification_argv: [...launcher.argv, "task", "verify", task.task_id, "--"] } };
93
+ return { content: [{ type: "text", text: `Task ${task.task_id} · ${task.state}. Pass task_id to hunch_context and every decision/correction/finding capture (a capture is not proof of a commit or push). To claim an application, copy occurrence_id, record_id and content_hash exactly from hunch_report(task_id) application_references, with the action you took; omit applications you did not make. Before the final response, finish with hunch_task (outcome "interrupted" if cut short) and include its contribution card verbatim, Evidence line and agent-reported label included, unless presentation_enabled is false. Never rerun an expensive check only for reporting; missing evidence stays unverified. Checks, with this exact installation (a global hunch may be stale): ${launcher.shell} task verify ${task.task_id} -- <command> [arguments]${launcher.note} Verify with the tests that cover your change (the files you edited and their tests); the full suite is CI's job. The default budget is 15 minutes; --timeout <seconds> before -- is only for when the whole suite is really needed.` }], structuredContent: { task: { task_id: task.task_id, state: task.state }, verification_argv: [...launcher.argv, "task", "verify", task.task_id, "--"] } };
94
94
  }
95
95
  if (!task_id)
96
96
  throw new Error("finish requires the exact task_id");
@@ -7,6 +7,10 @@ export declare const ACQUIRE_TIMEOUT_MS = 10000;
7
7
  * claimed section is a few syscalls, so anything this old means its claimer
8
8
  * crashed inside it. */
9
9
  export declare const CLAIM_STALE_MS = 10000;
10
+ /** How much later than the lock's recorded `at` the pid's current process must
11
+ * have been created before it is proven not to be the owner. A genuine owner
12
+ * started BEFORE it took the lock; the slack only absorbs clock granularity. */
13
+ export declare const CREATED_AFTER_SLACK_MS = 5000;
10
14
  export interface LockOwner {
11
15
  pid: number;
12
16
  host: string;
@@ -22,6 +26,9 @@ export interface StartTokenProbe {
22
26
  self: string | null;
23
27
  of: (pid: number) => string | null;
24
28
  platform?: NodeJS.Platform;
29
+ /** When the process now holding `pid` was created (wall-clock ms), or null.
30
+ * Defaults to the OS probe `processStartedAt`. */
31
+ startedAt?: (pid: number) => number | null;
25
32
  }
26
33
  export declare class WriteLockTimeout extends Error {
27
34
  readonly path: string;
@@ -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.42.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.42.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.42.0",
16
+ "version": "1.44.0",
17
17
  "runtimeHint": "npx",
18
18
  "packageArguments": [
19
19
  {