@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.
- package/README.md +16 -16
- package/dist/cli/index.js +267 -89
- package/dist/core/agenthook.d.ts +1 -0
- package/dist/core/agenthook.js +11 -0
- 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/escalations.d.ts +20 -0
- package/dist/core/escalations.js +47 -0
- package/dist/core/hookText.js +1 -1
- 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 +5 -3
- package/dist/integrations/gitignore.d.ts +23 -0
- package/dist/integrations/gitignore.js +48 -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 +16 -0
- package/dist/integrations/providers.js +35 -6
- package/dist/integrations/scaffold.d.ts +5 -0
- package/dist/integrations/scaffold.js +13 -8
- package/dist/integrations/uninstall.d.ts +40 -0
- package/dist/integrations/uninstall.js +435 -0
- package/dist/mcp/leanToolList.d.ts +9 -0
- package/dist/mcp/leanToolList.js +89 -0
- package/dist/mcp/server.d.ts +2 -1
- package/dist/mcp/server.js +62 -6
- package/dist/serve/mcpHttp.js +2 -0
- 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 -6
- package/server.json +2 -2
- package/tooling/md1-benchmark.mjs +1 -1
- package/tooling/competitive-watch.mjs +0 -142
package/dist/mcp/server.js
CHANGED
|
@@ -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,
|
|
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
|
|
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
|
|
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
|
|
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."
|
package/dist/serve/mcpHttp.js
CHANGED
|
@@ -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;
|
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.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.
|
|
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.
|
|
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.
|
|
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.
|
|
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 = "
|
|
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)), "..");
|