@davesheffer/hunch 1.43.0 → 1.45.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 (57) hide show
  1. package/README.md +16 -16
  2. package/dist/cli/index.js +267 -89
  3. package/dist/core/agenthook.d.ts +1 -0
  4. package/dist/core/agenthook.js +11 -0
  5. package/dist/core/changeProof.js +2 -1
  6. package/dist/core/commitrepair.d.ts +9 -3
  7. package/dist/core/commitrepair.js +23 -6
  8. package/dist/core/correction.d.ts +1 -1
  9. package/dist/core/correction.js +4 -3
  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/escalations.d.ts +20 -0
  17. package/dist/core/escalations.js +47 -0
  18. package/dist/core/hookText.js +1 -1
  19. package/dist/core/procstart.d.ts +15 -4
  20. package/dist/core/procstart.js +56 -18
  21. package/dist/core/runbookDraft.d.ts +7 -0
  22. package/dist/core/runbookDraft.js +43 -0
  23. package/dist/core/taskTouched.d.ts +2 -0
  24. package/dist/core/taskTouched.js +1 -1
  25. package/dist/extractors/git.d.ts +36 -6
  26. package/dist/extractors/git.js +86 -9
  27. package/dist/extractors/indexer.js +53 -14
  28. package/dist/extractors/k8sManifest.d.ts +31 -1
  29. package/dist/extractors/k8sManifest.js +133 -8
  30. package/dist/integrations/claudemd.d.ts +2 -0
  31. package/dist/integrations/claudemd.js +5 -3
  32. package/dist/integrations/gitignore.d.ts +23 -0
  33. package/dist/integrations/gitignore.js +48 -0
  34. package/dist/integrations/hooks.d.ts +15 -0
  35. package/dist/integrations/hooks.js +49 -1
  36. package/dist/integrations/mergeDriver.d.ts +2 -0
  37. package/dist/integrations/mergeDriver.js +2 -2
  38. package/dist/integrations/providers.d.ts +16 -0
  39. package/dist/integrations/providers.js +35 -6
  40. package/dist/integrations/scaffold.d.ts +5 -0
  41. package/dist/integrations/scaffold.js +13 -8
  42. package/dist/integrations/uninstall.d.ts +40 -0
  43. package/dist/integrations/uninstall.js +435 -0
  44. package/dist/mcp/leanToolList.d.ts +9 -0
  45. package/dist/mcp/leanToolList.js +89 -0
  46. package/dist/mcp/server.d.ts +2 -1
  47. package/dist/mcp/server.js +62 -6
  48. package/dist/serve/mcpHttp.js +2 -0
  49. package/dist/serve/writelock.d.ts +7 -0
  50. package/dist/serve/writelock.js +30 -3
  51. package/dist/store/changeLedger.d.ts +5 -2
  52. package/dist/store/changeLedger.js +88 -37
  53. package/dist/store/hunchStore.js +4 -1
  54. package/package.json +3 -6
  55. package/server.json +2 -2
  56. package/tooling/md1-benchmark.mjs +1 -1
  57. package/tooling/competitive-watch.mjs +0 -142
@@ -16,6 +16,7 @@ import { z } from "zod";
16
16
  import { hunchPaths, findRoot, toPosixTarget, repoRelativeTarget } from "../core/paths.js";
17
17
  import { matchSymbolsTiered } from "../core/glob.js";
18
18
  import { resolveMcpToolset } from "./toolset.js";
19
+ import { installLeanToolList } from "./leanToolList.js";
19
20
  import { PolicyRepository } from "../constitution/repository.js";
20
21
  import { readConfig } from "../core/config.js";
21
22
  import { canonicalRootPath, resolveActiveRoot } from "./roots.js";
@@ -32,7 +33,7 @@ import { knownRepoDeps } from "../synthesis/tripwires.js";
32
33
  import { refreshExistingGrounding } from "../integrations/providers.js";
33
34
  import { workspaceLedgerView, renderWorktreeTable, renderBranchTable, workspaceSummaryLine, snapshotHasHome, recordWorkspaceSnapshot, branchRows, worktreeRows } from "../integrations/workspaceLedger.js";
34
35
  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";
36
+ 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
37
  import { flushCapture, flushMemoryHome, pinSharedRemote } from "../integrations/sync.js";
37
38
  import { withWriteLock } from "../serve/writelock.js";
38
39
  import { advertisedTeamRemoteContract, ensureTeamOverlay, isTeamStoreTrusted, overlayMatchesTeamRemote, teamWiringConsented, untrustedTeamStoreMessage, readTeamConfig, teamRemoteContract, teamSharedRef } from "../integrations/team.js";
@@ -940,6 +941,16 @@ function unconfirmedReason(c) {
940
941
  : "this client could not ask the human to confirm it (no MCP form elicitation, or the request failed)";
941
942
  }
942
943
  const clipForPrompt = (s, max = 400) => (s.length > max ? `${s.slice(0, max - 1)}…` : s);
944
+ /** Whether a re-record changes what a human signed: every field except bookkeeping
945
+ * (provenance, date, valid_from). Fails closed: a field added to Decision later counts
946
+ * as substance until it is named here. Absent, null and empty-array values are equal. */
947
+ const DECISION_BOOKKEEPING = new Set(["provenance", "date", "valid_from"]);
948
+ export function decisionSubstanceChanged(before, after) {
949
+ const norm = (v) => (v === undefined || (Array.isArray(v) && v.length === 0) ? null : v);
950
+ const keys = [...new Set([...Object.keys(before), ...Object.keys(after)])].filter((k) => !DECISION_BOOKKEEPING.has(k)).sort();
951
+ const substance = (d) => JSON.stringify(keys.map((k) => [k, norm(d[k])]));
952
+ return substance(before) !== substance(after);
953
+ }
943
954
  /** The interrogation protocol returned by hunch_capture_decision. With `deciding`,
944
955
  * the choice is NOT yet made: the verdict loop runs first so the record's
945
956
  * alternatives_rejected are attacks that actually ran — not post-hoc fiction. */
@@ -1018,7 +1029,7 @@ function pullBackoff(status, finishedAt, failures) {
1018
1029
  if (status === "busy") {
1019
1030
  return { consecutivePullFailures: failures, nextRemotePullAt: finishedAt + 100 };
1020
1031
  }
1021
- if (status === "unconfigured") {
1032
+ if (status === "unconfigured" || status === "empty") {
1022
1033
  return { consecutivePullFailures: 0, nextRemotePullAt: finishedAt + 30_000 };
1023
1034
  }
1024
1035
  const consecutivePullFailures = Math.min(failures + 1, 6);
@@ -1119,7 +1130,7 @@ function prepareRoot(root, explicitOverlay, requireIndex) {
1119
1130
  export const MCP_INSTRUCTIONS = [
1120
1131
  "Hunch is this repository's engineering memory: decisions, bug history, invariants, components, with provenance.",
1121
1132
  "Per user task: (1) hunch_task(action:\"start\", title) once — skip it when the host's prompt hook already opened the task and printed its verify command, and reuse that task_id; (2) hunch_context(target, task_id) FIRST, before reading or editing, for the file, symbol, or task phrase; (3) hunch_check_constraints(scope) before editing shared code; (4) hunch_task(action:\"finish\", task_id) before the final response, showing its contribution card — required, with one exception: the host's prompt hook opened the task AND its instruction said that host closes the task AND the task used no Hunch (no hunch_* call on this task_id, no task verify check, no hook context you acted on, nothing to claim). A task you started with hunch_task is always finished by you.",
1122
- "Then by moment: hunch_why(target) for rationale and rejected alternatives, hunch_bug_lineage before fixing a failure, hunch_record_decision after a non-trivial choice, hunch_record_correction when a human corrects you.",
1133
+ "Then by moment: hunch_why(target) for rationale and rejected alternatives, hunch_bug_lineage before fixing a failure, hunch_record_correction when a human corrects you, hunch_record_decision for a decision the human makes; record only what the human decides or corrects.",
1123
1134
  "Hosts without lifecycle hooks (Windsurf, Cursor, or Codex before its hooks are trusted) receive no automatic grounding: call these tools yourself.",
1124
1135
  ].join("\n");
1125
1136
  export function buildServerWithRootControl(initialRoot, options = {}) {
@@ -1197,6 +1208,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
1197
1208
  if (toolset.hidden.length)
1198
1209
  process.stderr.write(`[hunch-mcp] tool groups: ${toolset.groups.length ? toolset.groups.join(", ") : "core only"} (${toolset.source}); ${toolset.hidden.length} specialist tool(s) hidden — HUNCH_MCP_TOOLS=all or .hunch/config.json mcp_tools to expose\n`);
1199
1210
  const server = new McpServer({ name: "hunch", version: HUNCH_VERSION }, { instructions: MCP_INSTRUCTIONS });
1211
+ installLeanToolList(server);
1200
1212
  let activeRequests = 0;
1201
1213
  let pendingRoot = null;
1202
1214
  let pendingScheduled = false;
@@ -1954,6 +1966,24 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
1954
1966
  const decision = store.advisoryRecs("decisions").find((candidate) => candidate.id === decision_id);
1955
1967
  if (!decision)
1956
1968
  return invalid(`Imported ADR ${decision_id} is not present in the current advisory memory home.`);
1969
+ // Approval grants human authority, so the agent's report that the human approved
1970
+ // is not enough: the human confirms in the client prompt, a channel the agent does
1971
+ // not control, or approves with the CLI. A decline keeps the ADR advisory.
1972
+ if (disposition === "approve") {
1973
+ const confirmation = await askHumanToConfirm(server, [
1974
+ "Hunch: an agent reports that you approved this imported ADR as your decision. Confirm only if YOU approve it.",
1975
+ "",
1976
+ `Title: ${clipForPrompt(decision.title, 200)}`,
1977
+ `Decision: ${clipForPrompt(decision.decision || "(none)")}`,
1978
+ `Source hash: ${expected_source_hash}`,
1979
+ "",
1980
+ "Confirmed, it carries your authority (human_confirmed). Unconfirmed, it stays advisory.",
1981
+ ].join("\n"));
1982
+ if (confirmation !== "confirmed") {
1983
+ return refused(`Not approved: ${unconfirmedReason(confirmation)}, so ${decision_id} stays advisory and nothing was written. ` +
1984
+ `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>`);
1985
+ }
1986
+ }
1957
1987
  const reviewed = applyImportedAdrReview(decision, {
1958
1988
  disposition,
1959
1989
  expectedSourceHash: expected_source_hash,
@@ -2046,7 +2076,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
2046
2076
  // bounded convergence. An unavailable remote produces an explicit abstention.
2047
2077
  if (live.length === 0 && store.mode === "shared" && store.privateDir) {
2048
2078
  const status = pullTeamMemory(true);
2049
- if (status !== "updated" && status !== "current") {
2079
+ if (status === "cooldown" || status === "not_shared" || !teamPullIsFresh(status)) {
2050
2080
  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
2081
  }
2052
2082
  try {
@@ -2075,7 +2105,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
2075
2105
  // -- hunch_record_decision (write-back) -----------------------------------
2076
2106
  server.registerTool("hunch_record_decision", {
2077
2107
  title: "Record a decision (write-back)",
2078
- description: "Persist a Decision (ADR) with provenance after a non-trivial design choice. private:true keeps it in the HUNCH_PRIVATE_DIR overlay, never committed here. Returns id, home, status. Not for an agent-obeyed rule (hunch_record_correction) or an observation (hunch_record_finding). Errors: 'Refused:' a gate held (don't retry); 'Invalid:' fix arguments; 'Failed to' internal.",
2108
+ description: "Persist a Decision (ADR) with provenance when the human makes or confirms one in conversation; routine choices are captured from commits. private:true keeps it in the HUNCH_PRIVATE_DIR overlay, never committed here. Returns id, home, status. Not for an agent-obeyed rule (hunch_record_correction) or an observation (hunch_record_finding). Errors: 'Refused:' a gate held (don't retry); 'Invalid:' fix arguments; 'Failed to' internal.",
2079
2109
  inputSchema: {
2080
2110
  decision: z.object({
2081
2111
  title: z.string(),
@@ -2212,6 +2242,8 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
2212
2242
  valid_from: existing?.valid_from ?? now,
2213
2243
  valid_to: existing?.valid_to ?? null,
2214
2244
  retired: existing?.retired ?? { symbols: [], deps: [] },
2245
+ ...(existing?.conformance ? { conformance: existing.conformance } : {}),
2246
+ ...(existing?.visibility ? { visibility: existing.visibility } : {}),
2215
2247
  provenance: { source: "agent_recorded", confidence: 0.75, evidence: (decision.related_files ?? existing?.provenance.evidence ?? []).map(toPosixTarget) },
2216
2248
  date: now,
2217
2249
  };
@@ -2261,6 +2293,23 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
2261
2293
  const humanSigned = confirmation === "confirmed";
2262
2294
  if (slotConflict && !humanSigned)
2263
2295
  return slotRefusal();
2296
+ // An inherited signature covers only the text the human signed. Changed substance
2297
+ // without a fresh confirmation would keep human_confirmed on words nobody vouched for.
2298
+ if (existingIsHuman && !humanSigned) {
2299
+ if (decisionSubstanceChanged(existing, rec)) {
2300
+ 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. ` +
2301
+ "Record the change through /capture so the human confirms it in the client prompt.");
2302
+ }
2303
+ // Same substance: the human's record already says this. Rewriting it would only
2304
+ // replace the human's provenance with the agent's, so it is a no-op.
2305
+ return ok(`Decision ${id} is human-confirmed and already says this; nothing was written.`);
2306
+ }
2307
+ // Closing a human-confirmed decision is as much a change to it as rewriting it.
2308
+ const supersededIncumbent = decision.supersedes ? store.recs("decisions").find((d) => d.id === decision.supersedes) : undefined;
2309
+ if (supersededIncumbent && !humanSigned && supersededIncumbent.provenance.source.split("+").includes("human_confirmed")) {
2310
+ return refused(`Decision ${supersededIncumbent.id} is human-confirmed; an unconfirmed write cannot supersede it, so nothing was written. ` +
2311
+ "Record the change through /capture so the human confirms it in the client prompt.");
2312
+ }
2264
2313
  const tier = humanSigned || existingIsHuman ? "human_confirmed" : "agent_recorded";
2265
2314
  const source = existing && existing.provenance.source.includes("llm_draft")
2266
2315
  ? `llm_draft+${tier}`
@@ -2385,6 +2434,12 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
2385
2434
  }
2386
2435
  const rec = buildCorrectionConstraint({ ...input, knownDeps, root, vouched: false }, now);
2387
2436
  const existing = home === "private" ? store.getPrivateRec("constraints", rec.id) : store.json.get("constraints", rec.id);
2437
+ // A human-confirmed rule is changed only by the same human act that confirmed it.
2438
+ // Writing `rec` here would replace the signature with testimony and cap its severity.
2439
+ if (existing && existing.provenance.source.split("+").includes("human_confirmed")) {
2440
+ return refused(`Constraint ${rec.id} is already human-confirmed; an agent write cannot replace or downgrade it, so nothing was written. ` +
2441
+ `A change needs the human, outside the agent channel: ${confirmCommand(rec.id, { private: home === "private", severity: requested.severity })}`);
2442
+ }
2388
2443
  // Same cross-home twin guard as the decision path above.
2389
2444
  const stored = store.putCapture("constraints", rec, !!input.private);
2390
2445
  const observed = observeReportCapture(root, input.task_id, "constraints", stored, home, !!existing, home === "private" ? store.privateDir ?? undefined : hunchPaths(root).hunch);
@@ -2812,7 +2867,8 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
2812
2867
  if (!files.length)
2813
2868
  return ok(`VERDICT: ✅ PASS — no changed files in ${scope}.`);
2814
2869
  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 });
2870
+ const freshAt = checkFreshnessRevision({ base, commit }, root);
2871
+ const report = store.buildCheckReport(files, gate.diff, { strict: true, lastChange: (f) => lastChangeDate(f, root, freshAt), diffStatus: gate });
2816
2872
  const v = verdict(report);
2817
2873
  const head = v === "block"
2818
2874
  ? "VERDICT: ⛔ BLOCK — a recorded guard requires review; inspect the cited scope and evidence below before merge."
@@ -1,4 +1,5 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { installLeanToolList } from "../mcp/leanToolList.js";
2
3
  // The web-standard transport, not the Node one: the Node wrapper imports @hono/node-server,
3
4
  // which the production dependency audit keeps unreachable (tooling/production-dependency-audit.mjs).
4
5
  // Bridging IncomingMessage → Request → ServerResponse here is a few lines and keeps that boundary.
@@ -51,6 +52,7 @@ const TOOLS = [
51
52
  ];
52
53
  function buildServer(dispatch, problemOf, version) {
53
54
  const server = new McpServer({ name: "hunch-serve", version });
55
+ installLeanToolList(server);
54
56
  for (const tool of TOOLS) {
55
57
  server.registerTool(tool.name, { title: tool.title, description: tool.description, inputSchema: tool.input }, async (input) => {
56
58
  try {
@@ -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.43.0",
3
+ "version": "1.45.0",
4
4
  "mcpName": "io.github.davesheffer/hunch",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Dave Sheffer <dave.sheffer1@gmail.com>",
@@ -44,7 +44,6 @@
44
44
  "contracts/change-proof/*.json",
45
45
  "server.json",
46
46
  "bench/constitution-exp03-v1.json",
47
- "tooling/competitive-watch.mjs",
48
47
  "tooling/md1-benchmark.mjs",
49
48
  "LICENSE",
50
49
  "NOTICE"
@@ -91,16 +90,14 @@
91
90
  "site:proof": "npm run build && node tooling/generate-public-proof.mjs",
92
91
  "site:sitemap": "node tooling/generate-sitemap.mjs",
93
92
  "bench:md1": "node tooling/md1-benchmark.mjs",
94
- "research:competitors": "node tooling/competitive-watch.mjs",
95
- "outreach": "node tooling/outreach-pipeline.mjs",
96
93
  "prepublishOnly": "npm run build"
97
94
  },
98
95
  "dependencies": {
99
- "@modelcontextprotocol/sdk": "^1.30.0",
96
+ "@modelcontextprotocol/sdk": "^1.32.1",
100
97
  "@tree-sitter-grammars/tree-sitter-yaml": "^0.6.1",
101
98
  "commander": "^15.0.0",
102
99
  "cross-spawn": "^7.0.6",
103
- "smol-toml": "1.8.0",
100
+ "smol-toml": "1.9.0",
104
101
  "tree-sitter": "0.21.1",
105
102
  "tree-sitter-go": "^0.23.4",
106
103
  "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.45.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.45.0",
17
17
  "runtimeHint": "npx",
18
18
  "packageArguments": [
19
19
  {
@@ -18,7 +18,7 @@ import { dirname, join, resolve } from "node:path";
18
18
  import process from "node:process";
19
19
  import { fileURLToPath } from "node:url";
20
20
 
21
- export const BENCHMARK_BASELINE_REF = "610cd2e5673bf3c69ac984b4737e2d4a749ed374";
21
+ export const BENCHMARK_BASELINE_REF = "a1ad6d89631cce7c194f3f679abf7ed4e56d13e3";
22
22
  export const BENCHMARK_SCHEMA = "hunch.md1a.performance.v1";
23
23
 
24
24
  const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");