@davesheffer/hunch 1.45.0 → 1.45.2
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/dist/cli/index.js +41 -16
- package/dist/cli/serve.d.ts +2 -0
- package/dist/cli/serve.js +2 -1
- package/dist/core/agenthook.d.ts +1 -0
- package/dist/core/agenthook.js +56 -20
- package/dist/core/delivery.d.ts +5 -0
- package/dist/core/delivery.js +46 -4
- package/dist/core/pipeline.d.ts +9 -2
- package/dist/core/pipeline.js +13 -3
- package/dist/core/procstart.d.ts +17 -3
- package/dist/core/procstart.js +26 -5
- package/dist/core/taskSelection.d.ts +1 -0
- package/dist/core/taskSelection.js +90 -11
- package/dist/core/whyRecord.d.ts +47 -0
- package/dist/core/whyRecord.js +105 -0
- package/dist/extractors/git.d.ts +32 -0
- package/dist/extractors/git.js +177 -66
- package/dist/integrations/hooks.d.ts +20 -0
- package/dist/integrations/hooks.js +13 -6
- package/dist/integrations/providers.js +42 -7
- package/dist/integrations/team.js +2 -2
- package/dist/integrations/uninstall.d.ts +9 -0
- package/dist/integrations/uninstall.js +90 -11
- package/dist/mcp/server.js +136 -26
- package/dist/serve/writelock.d.ts +3 -4
- package/dist/serve/writelock.js +9 -18
- package/dist/store/hunchStore.d.ts +10 -0
- package/dist/store/hunchStore.js +80 -1
- package/dist/store/jsonStore.d.ts +11 -1
- package/dist/store/jsonStore.js +38 -13
- package/package.json +1 -1
- package/server.json +2 -2
|
@@ -14,15 +14,15 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import { existsSync, lstatSync, readFileSync, readdirSync, rmdirSync, rmSync, statSync, writeFileSync, chmodSync } from "node:fs";
|
|
16
16
|
import { spawnSync } from "node:child_process";
|
|
17
|
-
import { basename, join, relative } from "node:path";
|
|
17
|
+
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
18
18
|
import { parse as parseToml } from "smol-toml";
|
|
19
19
|
import { writeFileAtomic } from "../core/io.js";
|
|
20
|
-
import { gitCommonDir, isGitRepo, separateGitDirLink } from "../extractors/git.js";
|
|
20
|
+
import { currentBranch, gitCommonDir, isGitRepo, isLinkedWorktree, separateGitDirLink } from "../extractors/git.js";
|
|
21
21
|
import { HUNCH_END, HUNCH_START } from "./claudemd.js";
|
|
22
22
|
import { ciWorkflowYaml } from "./ciAction.js";
|
|
23
23
|
import { assertSafeTopLevelConfigFile, stripManagedGitignore } from "./gitignore.js";
|
|
24
24
|
import { isHunchHookCommand } from "./hookmatch.js";
|
|
25
|
-
import { planHookRemoval } from "./hooks.js";
|
|
25
|
+
import { gitRun, isInside, planHookRemoval, worktreeTops } from "./hooks.js";
|
|
26
26
|
import { ATTR_LINES, targetRepositoryEnv } from "./mergeDriver.js";
|
|
27
27
|
import { antigravityMcpFile, isHunchProviderHook, readJsonObj, TOML_END, TOML_START, windsurfMcpFile } from "./providers.js";
|
|
28
28
|
import { SLASH_COMMAND_FILES } from "./scaffold.js";
|
|
@@ -36,6 +36,24 @@ export function isOptedOut(root) {
|
|
|
36
36
|
return false;
|
|
37
37
|
}
|
|
38
38
|
}
|
|
39
|
+
/** Whether `start`, or the repository containing it, opted out: walks up through
|
|
40
|
+
* `start`'s ancestors (which need not exist yet), stopping after the first one holding
|
|
41
|
+
* `.git` or at the filesystem root, so a path inside an opted-out repository (`serve
|
|
42
|
+
* init --root ./partitions/x`) is gated like the repository itself (#458). A `.hunch`
|
|
43
|
+
* store is deliberately not a boundary: a partition store nested in an opted-out
|
|
44
|
+
* repository stays gated (fail closed). */
|
|
45
|
+
export function optedOutAt(start) {
|
|
46
|
+
for (let dir = resolve(start);;) {
|
|
47
|
+
if (isOptedOut(dir))
|
|
48
|
+
return true;
|
|
49
|
+
if (existsSync(join(dir, ".git")))
|
|
50
|
+
return false;
|
|
51
|
+
const parent = dirname(dir);
|
|
52
|
+
if (parent === dir)
|
|
53
|
+
return false;
|
|
54
|
+
dir = parent;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
39
57
|
export function optOutMessage() {
|
|
40
58
|
return `Hunch was removed from this repository on purpose (${OPT_OUT_FILE}), so it will not set itself up here again. To reinstall, delete ${OPT_OUT_FILE} and run \`hunch init\`.`;
|
|
41
59
|
}
|
|
@@ -195,6 +213,31 @@ function gitConfigHasDrivers(root) {
|
|
|
195
213
|
const r = spawnSync("git", ["config", "--local", "--get-regexp", "^merge\\.hunch(-grounding)?\\."], { cwd: root, env: targetRepositoryEnv(), encoding: "utf8" });
|
|
196
214
|
return r.status === 0 && r.stdout.trim() !== "";
|
|
197
215
|
}
|
|
216
|
+
/** The branch the clone treats as default: origin's HEAD, else a local main, else
|
|
217
|
+
* master; "" when none can be named. */
|
|
218
|
+
function defaultBranch(root) {
|
|
219
|
+
const head = gitRun(["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"], root);
|
|
220
|
+
if (head.status === 0 && head.stdout)
|
|
221
|
+
return head.stdout.replace(/^origin\//, "");
|
|
222
|
+
for (const b of ["main", "master"]) {
|
|
223
|
+
if (gitRun(["show-ref", "--verify", "--quiet", `refs/heads/${b}`], root).status === 0)
|
|
224
|
+
return b;
|
|
225
|
+
}
|
|
226
|
+
return "";
|
|
227
|
+
}
|
|
228
|
+
/** The marker is committed per branch: off the default branch, say the rest of the clone still has Hunch. */
|
|
229
|
+
function branchWarning(root) {
|
|
230
|
+
try {
|
|
231
|
+
const branch = currentBranch(root);
|
|
232
|
+
const def = branch ? defaultBranch(root) : "";
|
|
233
|
+
if (!branch || !def || branch === def)
|
|
234
|
+
return null;
|
|
235
|
+
return `the ${OPT_OUT_FILE} marker is only on branch ${branch}; other branches of this clone still have Hunch's instruction blocks and config — commit and merge it, or run uninstall there too`;
|
|
236
|
+
}
|
|
237
|
+
catch {
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
198
241
|
/** The overlay clone a pointer names, for the "kept" note. Never deleted: it is
|
|
199
242
|
* its own repository holding memory that may not be pushed yet. */
|
|
200
243
|
function pointerOverlay(file) {
|
|
@@ -295,22 +338,49 @@ export function planUninstall(root, opts = {}) {
|
|
|
295
338
|
p.steps.push({ path: ciRel, kind: "keep", note: "it differs from the workflow Hunch generates (edited?); delete it yourself if you no longer want the Hunch Guard check" });
|
|
296
339
|
}
|
|
297
340
|
}
|
|
298
|
-
// Git hooks + merge driver.
|
|
341
|
+
// Git hooks + merge driver. Both live in the clone's common git dir, so they
|
|
342
|
+
// serve every worktree and every branch (#459): a linked worktree leaves them to
|
|
343
|
+
// the main checkout, and the main checkout says which worktrees lose them too.
|
|
344
|
+
// A bare common dir has no main checkout, so any worktree removes them and warns.
|
|
345
|
+
const warnings = [];
|
|
299
346
|
if (isGitRepo(root)) {
|
|
347
|
+
const commonDir = gitCommonDir(root);
|
|
348
|
+
const top = gitRun(["rev-parse", "--show-toplevel"], root).stdout;
|
|
349
|
+
const here = top || root;
|
|
350
|
+
const same = (a, b) => isInside(a, b) && isInside(b, a);
|
|
351
|
+
// The main checkout as git lists it, not the common dir's parent, which in a
|
|
352
|
+
// bare clone or a --separate-git-dir checkout is no checkout at all.
|
|
353
|
+
const tops = worktreeTops(root);
|
|
354
|
+
const mainCheckout = tops.main;
|
|
355
|
+
const linked = commonDir !== "" && mainCheckout !== null && !same(mainCheckout, here) && isLinkedWorktree(root);
|
|
356
|
+
// The same test `hunch init` uses: in the common dir, or outside this checkout.
|
|
357
|
+
const shared = (file) => (commonDir !== "" && isInside(commonDir, file)) || (top !== "" && !isInside(top, file));
|
|
358
|
+
const elsewhere = (what, it) => `${what} shared by every worktree of this clone — run \`hunch uninstall\` from the main checkout (${mainCheckout}) to remove ${it}`;
|
|
359
|
+
let removesShared = false;
|
|
300
360
|
for (const h of planHookRemoval(root)) {
|
|
301
361
|
const path = p.rel(h.path);
|
|
302
362
|
if (h.reason)
|
|
303
363
|
p.steps.push({ path, kind: "keep", note: h.reason });
|
|
304
|
-
else if (h.
|
|
305
|
-
p.steps.push({ path, kind: "
|
|
364
|
+
else if (linked && shared(h.path))
|
|
365
|
+
p.steps.push({ path, kind: "keep", note: elsewhere(`Hunch's ${h.hookName} git hook is`, "it") });
|
|
306
366
|
else {
|
|
307
|
-
|
|
308
|
-
|
|
367
|
+
if (shared(h.path))
|
|
368
|
+
removesShared = true;
|
|
369
|
+
if (h.next === null)
|
|
370
|
+
p.steps.push({ path, kind: "delete", note: `Hunch's ${h.hookName} git hook`, run: () => rmSync(h.path, { force: true }) });
|
|
371
|
+
else {
|
|
372
|
+
const next = h.next;
|
|
373
|
+
p.steps.push({ path, kind: "edit", note: `removed Hunch's block from the ${h.hookName} git hook (your own lines are kept)`, run: () => { writeFileSync(h.path, next); chmodSync(h.path, 0o755); } });
|
|
374
|
+
}
|
|
309
375
|
}
|
|
310
376
|
}
|
|
311
|
-
if (gitConfigHasDrivers(root)) {
|
|
377
|
+
if (gitConfigHasDrivers(root) && linked) {
|
|
378
|
+
p.steps.push({ path: p.rel(join(commonDir, "config")), kind: "keep", note: elsewhere("the merge.hunch and merge.hunch-grounding drivers are", "them") });
|
|
379
|
+
}
|
|
380
|
+
else if (gitConfigHasDrivers(root)) {
|
|
381
|
+
removesShared = true;
|
|
312
382
|
p.steps.push({
|
|
313
|
-
path: ".git/config", kind: "edit", note: "unregistered the merge.hunch and merge.hunch-grounding drivers",
|
|
383
|
+
path: commonDir ? p.rel(join(commonDir, "config")) : ".git/config", kind: "edit", note: "unregistered the merge.hunch and merge.hunch-grounding drivers",
|
|
314
384
|
run: () => {
|
|
315
385
|
for (const section of ["merge.hunch", "merge.hunch-grounding"]) {
|
|
316
386
|
spawnSync("git", ["config", "--local", "--remove-section", section], { cwd: root, env: targetRepositoryEnv(), stdio: "ignore" });
|
|
@@ -318,6 +388,13 @@ export function planUninstall(root, opts = {}) {
|
|
|
318
388
|
},
|
|
319
389
|
});
|
|
320
390
|
}
|
|
391
|
+
const others = linked ? [] : tops.linked.filter((w) => !same(w, here)).map((w) => resolve(w));
|
|
392
|
+
if (removesShared && others.length) {
|
|
393
|
+
warnings.push(`the git hooks and merge drivers are shared with ${others.length === 1 ? "the linked worktree" : `${others.length} linked worktrees`} ${others.join(", ")}, so they are removed there too, though ${others.length === 1 ? "it still has" : "those still have"} Hunch and no ${OPT_OUT_FILE} marker`);
|
|
394
|
+
}
|
|
395
|
+
const onBranch = branchWarning(root);
|
|
396
|
+
if (onBranch)
|
|
397
|
+
warnings.push(onBranch);
|
|
321
398
|
}
|
|
322
399
|
const attrs = p.text(".gitattributes");
|
|
323
400
|
if (attrs !== null) {
|
|
@@ -378,7 +455,7 @@ export function planUninstall(root, opts = {}) {
|
|
|
378
455
|
for (const dir of overlays) {
|
|
379
456
|
p.steps.push({ path: p.rel(dir), kind: "keep", note: "a private/shared overlay clone: its own repository with memory that may not be pushed; never deleted here" });
|
|
380
457
|
}
|
|
381
|
-
return { root, purge, steps: p.steps, refused: p.refused };
|
|
458
|
+
return { root, purge, steps: p.steps, refused: p.refused, warnings };
|
|
382
459
|
}
|
|
383
460
|
/** Directories Hunch's writers create; removed only when left empty. Deepest first. */
|
|
384
461
|
const PRUNE_DIRS = [
|
|
@@ -430,6 +507,8 @@ export function formatUninstallPlan(plan, dryRun) {
|
|
|
430
507
|
out.push(` ✗ ${r.path}: ${r.reason}`);
|
|
431
508
|
for (const s of plan.steps)
|
|
432
509
|
out.push(` ${icon(s.kind)} ${verb(s.kind)} ${s.path} — ${s.note}`);
|
|
510
|
+
for (const w of plan.warnings ?? [])
|
|
511
|
+
out.push(` ⚠ ${w}`);
|
|
433
512
|
return out;
|
|
434
513
|
}
|
|
435
514
|
//# sourceMappingURL=uninstall.js.map
|
package/dist/mcp/server.js
CHANGED
|
@@ -33,7 +33,7 @@ import { knownRepoDeps } from "../synthesis/tripwires.js";
|
|
|
33
33
|
import { refreshExistingGrounding } from "../integrations/providers.js";
|
|
34
34
|
import { workspaceLedgerView, renderWorktreeTable, renderBranchTable, workspaceSummaryLine, snapshotHasHome, recordWorkspaceSnapshot, branchRows, worktreeRows } from "../integrations/workspaceLedger.js";
|
|
35
35
|
import { workspacesConfig } from "../core/config.js";
|
|
36
|
-
import { revParse, asOfDate, revExists, lastChangeDate, checkFreshnessRevision, teamPullIsFresh, rangeFiles, rangeGateDiff, commitFiles, commitGateDiff, stagedFiles, stagedGateDiff, workingFiles, workingGateDiff,
|
|
36
|
+
import { revParse, asOfDate, revExists, lastChangeDate, checkFreshnessRevision, teamPullIsFresh, rangeFiles, rangeGateDiff, commitFiles, commitGateDiff, stagedFiles, stagedGateDiff, workingFiles, workingGateDiff, pullHunchStatusDetailed, describePullFailure, sameRemoteUrl, currentBranch, worktreePaths, pathKnownToHistory } from "../extractors/git.js";
|
|
37
37
|
import { flushCapture, flushMemoryHome, pinSharedRemote } from "../integrations/sync.js";
|
|
38
38
|
import { withWriteLock } from "../serve/writelock.js";
|
|
39
39
|
import { advertisedTeamRemoteContract, ensureTeamOverlay, isTeamStoreTrusted, overlayMatchesTeamRemote, teamWiringConsented, untrustedTeamStoreMessage, readTeamConfig, teamRemoteContract, teamSharedRef } from "../integrations/team.js";
|
|
@@ -69,6 +69,7 @@ import { nowData, wikiStatus, publicHome, readWikiManifestAt } from "../wiki/wik
|
|
|
69
69
|
import { HUNCH_VERSION } from "../core/version.js";
|
|
70
70
|
import { assertCompleteRepoScan, indexRepo, scanRepo } from "../extractors/indexer.js";
|
|
71
71
|
import { liveForTopic, historyForTopic, rejectedForTopic, captureConflicts } from "../core/topics.js";
|
|
72
|
+
import { formatWhyRecord, more, WHY_CAP } from "../core/whyRecord.js"; // WHY_CAP: per record-type cap in hunch_why
|
|
72
73
|
import { pendingEscalations, policyEscalations, commitRepairEscalations, actionableEscalations, escalationHeadline } from "../core/escalations.js";
|
|
73
74
|
import { readActivePendingRepairs, withheldRewrites } from "../core/repairqueue.js";
|
|
74
75
|
import { scanRecord, publicationWarning, loadVocabulary } from "../core/publication.js";
|
|
@@ -595,7 +596,6 @@ const resolveDestRoot = (home, store, root) => home === "private" && store.priva
|
|
|
595
596
|
// session, so an uncapped list pollutes the context window. Cap each list to its
|
|
596
597
|
// highest-signal head (records are pre-sorted by severity/confidence) and tell the
|
|
597
598
|
// caller what was withheld rather than truncating silently.
|
|
598
|
-
const WHY_CAP = 6; // per record-type in hunch_why
|
|
599
599
|
const DEP_CAP = 25; // dependents in hunch_get_dependents
|
|
600
600
|
const QUERY_HITS = 8; // hunch_query matches (was 12)
|
|
601
601
|
const FINDINGS_CAP = 12; // hunch_findings listing
|
|
@@ -624,7 +624,6 @@ export const firstSentence = (text) => {
|
|
|
624
624
|
};
|
|
625
625
|
const SEV_CONSTRAINT = { blocking: 3, warning: 2, advisory: 1 };
|
|
626
626
|
const SEV_BUG = { critical: 4, high: 3, medium: 2, low: 1 };
|
|
627
|
-
const more = (total, cap, hint = "") => total > cap ? `\n …(+${total - cap} more${hint ? ` — ${hint}` : ""})` : "";
|
|
628
627
|
const EXECUTION_OBLIGATION_SCHEMA = z.object({
|
|
629
628
|
id: z.string(),
|
|
630
629
|
origin: z.enum(["memory", "episode", "manual"]),
|
|
@@ -1088,10 +1087,11 @@ function prepareRoot(root, explicitOverlay, requireIndex) {
|
|
|
1088
1087
|
let consecutivePullFailures = 0;
|
|
1089
1088
|
if (store.privateDir) {
|
|
1090
1089
|
try {
|
|
1091
|
-
const status =
|
|
1092
|
-
timeoutMs: 5_000,
|
|
1090
|
+
const { status, detail } = pullHunchStatusDetailed(store.privateDir, {
|
|
1093
1091
|
remote: startupTeamRoute ?? advertisedTeamRemoteContract(root, join(store.privateDir, "..")),
|
|
1094
1092
|
});
|
|
1093
|
+
if (detail)
|
|
1094
|
+
console.error(`[hunch-mcp] team memory pull failed (${describePullFailure(detail)})`);
|
|
1095
1095
|
({ nextRemotePullAt, consecutivePullFailures } = pullBackoff(status, Date.now(), 0));
|
|
1096
1096
|
}
|
|
1097
1097
|
catch {
|
|
@@ -1145,6 +1145,11 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
1145
1145
|
let store = initial.store;
|
|
1146
1146
|
let nextRemotePullAt = initial.nextRemotePullAt;
|
|
1147
1147
|
let consecutivePullFailures = initial.consecutivePullFailures;
|
|
1148
|
+
// Consecutive FAILED forced pulls (any subject); reset by any fresh pull.
|
|
1149
|
+
let forcedFailureStreak = 0;
|
|
1150
|
+
// When the last fresh pull (ordinary or forced) finished; a forced failure
|
|
1151
|
+
// older than this is never reused, since team memory was refreshed since.
|
|
1152
|
+
let lastFreshPullAt = 0;
|
|
1148
1153
|
let indexedSourceStamp = initial.indexedSourceStamp;
|
|
1149
1154
|
const matchesStartupTeamRoute = () => {
|
|
1150
1155
|
if (!teamAdvertised || !store.privateDir || !startupTeamConfig || !startupTeamRoute)
|
|
@@ -1165,6 +1170,10 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
1165
1170
|
const notePull = (status, finishedAt) => {
|
|
1166
1171
|
({ nextRemotePullAt, consecutivePullFailures } =
|
|
1167
1172
|
pullBackoff(status, finishedAt, consecutivePullFailures));
|
|
1173
|
+
if (teamPullIsFresh(status)) {
|
|
1174
|
+
forcedFailureStreak = 0;
|
|
1175
|
+
lastFreshPullAt = finishedAt;
|
|
1176
|
+
}
|
|
1168
1177
|
};
|
|
1169
1178
|
const pullTeamMemory = (force = false) => {
|
|
1170
1179
|
if (!store.privateDir)
|
|
@@ -1174,10 +1183,12 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
1174
1183
|
return "cooldown";
|
|
1175
1184
|
let status;
|
|
1176
1185
|
try {
|
|
1177
|
-
|
|
1178
|
-
timeoutMs: 5_000,
|
|
1186
|
+
const pulled = pullHunchStatusDetailed(store.privateDir, {
|
|
1179
1187
|
remote: startupTeamRoute ?? advertisedTeamRemoteContract(root, join(store.privateDir, "..")),
|
|
1180
1188
|
});
|
|
1189
|
+
status = pulled.status;
|
|
1190
|
+
if (pulled.detail)
|
|
1191
|
+
console.error(`[hunch-mcp] team memory pull failed (${describePullFailure(pulled.detail)})`);
|
|
1181
1192
|
}
|
|
1182
1193
|
catch {
|
|
1183
1194
|
status = "failed";
|
|
@@ -1191,6 +1202,70 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
1191
1202
|
const refreshIndex = () => {
|
|
1192
1203
|
indexedSourceStamp = rebuildFreshIndex(store);
|
|
1193
1204
|
};
|
|
1205
|
+
const FORCED_PULL_RETRY_MS = 2_000;
|
|
1206
|
+
const FORCED_FAILURE_LIMIT = 3;
|
|
1207
|
+
let lastForcedFailure = null;
|
|
1208
|
+
// True only during the synchronous prefix of a tool callback whose request
|
|
1209
|
+
// wrapper just pulled shared memory fresh (set/cleared around the callback).
|
|
1210
|
+
let requestPulledFresh = false;
|
|
1211
|
+
// An exact local miss in shared mode is an authority claim: local absence must
|
|
1212
|
+
// never be presented as team absence. Ordinary request pulls honour cooldown and
|
|
1213
|
+
// failure backoff, so bypass it once. Returns null after a fresh convergence
|
|
1214
|
+
// (the caller re-reads), else the explicit abstention to return instead.
|
|
1215
|
+
// Two throttles keep the synchronous fetch off the hot path: a miss in a
|
|
1216
|
+
// request whose ordinary pull already converged needs no second fetch, and a
|
|
1217
|
+
// forced pull that failed within FORCED_PULL_RETRY_MS for the SAME subject is
|
|
1218
|
+
// reused, not retried, so a client looping on one miss against a dead remote
|
|
1219
|
+
// cannot block stdio on each call. A different subject still fetches: a remote
|
|
1220
|
+
// restored moments later must converge for the next distinct read — until
|
|
1221
|
+
// FORCED_FAILURE_LIMIT forced pulls have failed in a row (any subject); then
|
|
1222
|
+
// misses reuse the last failure until the ordinary backoff window has passed.
|
|
1223
|
+
// Any fresh pull resets the streak (notePull).
|
|
1224
|
+
const confirmSharedMiss = (absenceClaim, subject) => {
|
|
1225
|
+
let status;
|
|
1226
|
+
if (requestPulledFresh) {
|
|
1227
|
+
status = "current";
|
|
1228
|
+
}
|
|
1229
|
+
else if (lastForcedFailure && lastForcedFailure.at > lastFreshPullAt && ((lastForcedFailure.subject === subject
|
|
1230
|
+
&& Date.now() - lastForcedFailure.at < FORCED_PULL_RETRY_MS)
|
|
1231
|
+
|| (forcedFailureStreak >= FORCED_FAILURE_LIMIT && Date.now() < nextRemotePullAt))) {
|
|
1232
|
+
status = lastForcedFailure.status;
|
|
1233
|
+
}
|
|
1234
|
+
else {
|
|
1235
|
+
status = pullTeamMemory(true);
|
|
1236
|
+
if (status === "cooldown" || status === "not_shared" || !teamPullIsFresh(status)) {
|
|
1237
|
+
lastForcedFailure = { at: Date.now(), status, subject };
|
|
1238
|
+
forcedFailureStreak++;
|
|
1239
|
+
}
|
|
1240
|
+
else {
|
|
1241
|
+
lastForcedFailure = null;
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
if (status === "cooldown" || status === "not_shared" || !teamPullIsFresh(status)) {
|
|
1245
|
+
return `Shared team memory refresh is ${status}; Hunch cannot confirm that ${absenceClaim}. Retry when the shared store is available.`;
|
|
1246
|
+
}
|
|
1247
|
+
try {
|
|
1248
|
+
if (store.sourceStamp() !== indexedSourceStamp)
|
|
1249
|
+
refreshIndex();
|
|
1250
|
+
}
|
|
1251
|
+
catch {
|
|
1252
|
+
return `Shared team memory refreshed, but its derived index could not be rebuilt; Hunch cannot confirm that ${subject} is absent. Retry this read.`;
|
|
1253
|
+
}
|
|
1254
|
+
return null;
|
|
1255
|
+
};
|
|
1256
|
+
// Policy reads: confirm a local policy (or its referenced proof) miss against
|
|
1257
|
+
// fresh shared memory before the service reports "not found". Public-only reads
|
|
1258
|
+
// and non-shared stores never consult the team remote.
|
|
1259
|
+
const confirmSharedPolicy = (policyId, publicOnly) => {
|
|
1260
|
+
if (publicOnly || store.mode !== "shared" || !store.privateDir)
|
|
1261
|
+
return null;
|
|
1262
|
+
const repository = new PolicyRepository(root, store);
|
|
1263
|
+
const policy = repository.getPolicy(policyId);
|
|
1264
|
+
if (policy && (!policy.proof || repository.getProof(policy.proof)))
|
|
1265
|
+
return null;
|
|
1266
|
+
const subject = policy ? `proof ${policy.proof} of policy ${policyId}` : `policy ${policyId}`;
|
|
1267
|
+
return confirmSharedMiss(`${subject} is absent from shared team memory`, subject);
|
|
1268
|
+
};
|
|
1194
1269
|
// Resolve the embedder ONCE for this long-lived process (never throws; null when
|
|
1195
1270
|
// the optional model isn't installed). The model then loads lazily on the first
|
|
1196
1271
|
// hunch_query and stays warm — and hybridSearch degrades to FTS until then.
|
|
@@ -1231,6 +1306,8 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
1231
1306
|
nextRemotePullAt = prepared.nextRemotePullAt;
|
|
1232
1307
|
consecutivePullFailures = prepared.consecutivePullFailures;
|
|
1233
1308
|
indexedSourceStamp = prepared.indexedSourceStamp;
|
|
1309
|
+
lastForcedFailure = null;
|
|
1310
|
+
forcedFailureStreak = 0;
|
|
1234
1311
|
previous.close();
|
|
1235
1312
|
console.error(`[hunch-mcp] serving Hunch at ${root} (client root)`);
|
|
1236
1313
|
};
|
|
@@ -1339,8 +1416,10 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
1339
1416
|
|| !overlayMatchesTeamRemote(root, join(store.privateDir, "..")))) {
|
|
1340
1417
|
return refused("The committed team memory destination is invalid or no longer matches this process. Refusing the stale graph; reconnect Hunch first.");
|
|
1341
1418
|
}
|
|
1419
|
+
let pulledFresh = false;
|
|
1342
1420
|
if (store.mode === "shared" && store.privateDir) {
|
|
1343
|
-
pullTeamMemory();
|
|
1421
|
+
const pulled = pullTeamMemory();
|
|
1422
|
+
pulledFresh = pulled !== "cooldown" && pulled !== "not_shared" && teamPullIsFresh(pulled);
|
|
1344
1423
|
// Recompute the full semantic + physical snapshot after the synchronous
|
|
1345
1424
|
// network seam. A paired team.json/origin change can occur while fetch is
|
|
1346
1425
|
// blocked; serving after that race would attach the old checkout to a new
|
|
@@ -1360,7 +1439,17 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
1360
1439
|
refreshIndex();
|
|
1361
1440
|
}
|
|
1362
1441
|
catch { /* corrupt/churning local source — serve the last durable indexed view */ }
|
|
1363
|
-
|
|
1442
|
+
// A shared-miss check in the callback's synchronous prefix may reuse this
|
|
1443
|
+
// request's fresh pull; any later (post-await) check falls back to a forced pull.
|
|
1444
|
+
const result = await withInitiator(initiatorFromClient(server.server.getClientVersion()?.name), () => {
|
|
1445
|
+
requestPulledFresh = pulledFresh;
|
|
1446
|
+
try {
|
|
1447
|
+
return callback(...args);
|
|
1448
|
+
}
|
|
1449
|
+
finally {
|
|
1450
|
+
requestPulledFresh = false;
|
|
1451
|
+
}
|
|
1452
|
+
});
|
|
1364
1453
|
if (teamAdvertised && !matchesStartupTeamRoute()) {
|
|
1365
1454
|
return refused("The team-memory route changed while the tool was running. Its startup destination was not published; reconnect Hunch before retrying.");
|
|
1366
1455
|
}
|
|
@@ -1409,16 +1498,28 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
1409
1498
|
// -- hunch_why ------------------------------------------------------------
|
|
1410
1499
|
server.registerTool("hunch_why", {
|
|
1411
1500
|
title: "Explain why a file/symbol is the way it is",
|
|
1412
|
-
description: "Return the decisions, bugs, and constraints that explain a file path or symbol — the 'why' and the 'what must not break', with evidence. Pass `as_of` (a commit/tag/branch) to time-travel: see what was believed at that point in history. Use when you need the full rationale for ONE target. Not for a budgeted task brief (hunch_context), keyword search (hunch_query), or where-is-it questions (hunch_structure).",
|
|
1501
|
+
description: "Return the decisions, bugs, and constraints that explain a file path or symbol, or one record by id (dec_/con_/bug_/fnd_/cmp_) — the 'why' and the 'what must not break', with evidence. Pass `as_of` (a commit/tag/branch) to time-travel: see what was believed at that point in history. Use when you need the full rationale for ONE target. Not for a budgeted task brief (hunch_context), keyword search (hunch_query), or where-is-it questions (hunch_structure).",
|
|
1413
1502
|
inputSchema: {
|
|
1414
|
-
target: z.string().describe("A file path (e.g. src/auth/session.ts) or
|
|
1503
|
+
target: z.string().describe("A file path (e.g. src/auth/session.ts), symbol name, or record id (e.g. dec_0b5d2be516)."),
|
|
1415
1504
|
as_of: z.string().optional().describe("Time-travel ref: a commit sha, tag, or branch (e.g. v0.7.0). Omit for the current view."),
|
|
1416
1505
|
},
|
|
1417
1506
|
}, async ({ target, as_of }) => {
|
|
1418
1507
|
const asOf = as_of ? asOfDate(as_of, root) : undefined;
|
|
1419
1508
|
if (as_of && !asOf)
|
|
1420
1509
|
return invalid(`Could not resolve as_of "${as_of}" to a commit.`);
|
|
1421
|
-
|
|
1510
|
+
let w = store.why(target, { asOf });
|
|
1511
|
+
// "No record <id>" is a team-absence claim in shared mode: confirm the local
|
|
1512
|
+
// miss against a fresh pull first, like hunch_current_decision. A record held
|
|
1513
|
+
// locally but outside its as_of window is not a team-absence claim: no pull.
|
|
1514
|
+
if (w.byId && !w.byId.record && store.mode === "shared" && store.privateDir
|
|
1515
|
+
&& !(asOf && store.why(target).byId?.record)) {
|
|
1516
|
+
const abstention = confirmSharedMiss(`record ${w.byId.id} is absent from shared team memory`, `record ${w.byId.id}`);
|
|
1517
|
+
if (abstention)
|
|
1518
|
+
return err(abstention);
|
|
1519
|
+
w = store.why(target, { asOf });
|
|
1520
|
+
}
|
|
1521
|
+
if (w.byId?.record)
|
|
1522
|
+
return ok(formatWhyRecord(w.byId).join("\n"));
|
|
1422
1523
|
// Highest-signal first, then cap: invariants by severity, decisions by
|
|
1423
1524
|
// confidence, bugs by severity — so a hot file's trim drops the tail, not
|
|
1424
1525
|
// the records that matter most.
|
|
@@ -1426,6 +1527,8 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
1426
1527
|
const constraints = [...w.constraints].sort((a, b) => (SEV_CONSTRAINT[b.severity] ?? 0) - (SEV_CONSTRAINT[a.severity] ?? 0));
|
|
1427
1528
|
const bugs = [...w.bugs].sort((a, b) => (SEV_BUG[b.severity] ?? 0) - (SEV_BUG[a.severity] ?? 0));
|
|
1428
1529
|
const parts = [`Why for "${target}":`];
|
|
1530
|
+
if (w.byId)
|
|
1531
|
+
parts.push(`\n${formatWhyRecord(w.byId).join("\n")}`);
|
|
1429
1532
|
if (decisions.length)
|
|
1430
1533
|
parts.push(`\nDECISIONS:\n${decisions.slice(0, WHY_CAP).map((d) => ` • ${d.id} [${d.status}] ${d.title}\n ${d.decision}${provLine(d)}`).join("\n")}${more(decisions.length, WHY_CAP, "narrow the target")}`);
|
|
1431
1534
|
if (constraints.length)
|
|
@@ -2071,21 +2174,11 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
2071
2174
|
let live = liveForTopic(decs, topic);
|
|
2072
2175
|
// A prior transient fetch failure may have placed ordinary tool traffic in
|
|
2073
2176
|
// backoff. That is acceptable for cached positive reads, but an exact topic
|
|
2074
|
-
// miss
|
|
2075
|
-
// absence. Bypass the cooldown once, then reread only after a successful
|
|
2076
|
-
// bounded convergence. An unavailable remote produces an explicit abstention.
|
|
2177
|
+
// miss must be confirmed against fresh team memory (confirmSharedMiss).
|
|
2077
2178
|
if (live.length === 0 && store.mode === "shared" && store.privateDir) {
|
|
2078
|
-
const
|
|
2079
|
-
if (
|
|
2080
|
-
return err(
|
|
2081
|
-
}
|
|
2082
|
-
try {
|
|
2083
|
-
if (store.sourceStamp() !== indexedSourceStamp)
|
|
2084
|
-
refreshIndex();
|
|
2085
|
-
}
|
|
2086
|
-
catch {
|
|
2087
|
-
return err(`Shared team memory refreshed, but its derived index could not be rebuilt; Hunch cannot confirm that topic "${topic}" is absent. Retry this read.`);
|
|
2088
|
-
}
|
|
2179
|
+
const abstention = confirmSharedMiss(`topic "${topic}" has no current decision`, `topic "${topic}"`);
|
|
2180
|
+
if (abstention)
|
|
2181
|
+
return err(abstention);
|
|
2089
2182
|
decs = store.recs("decisions");
|
|
2090
2183
|
live = liveForTopic(decs, topic);
|
|
2091
2184
|
}
|
|
@@ -3002,6 +3095,9 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
3002
3095
|
},
|
|
3003
3096
|
}, async ({ policy_id, public_only }) => {
|
|
3004
3097
|
try {
|
|
3098
|
+
const abstention = confirmSharedPolicy(policy_id, public_only);
|
|
3099
|
+
if (abstention)
|
|
3100
|
+
return err(abstention);
|
|
3005
3101
|
const service = new ConstitutionService(store, root);
|
|
3006
3102
|
const plan = service.plan(policy_id, { publicOnly: public_only });
|
|
3007
3103
|
const home = public_only ? "public" : service.repository.homeOfPolicy(policy_id);
|
|
@@ -3024,6 +3120,9 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
3024
3120
|
},
|
|
3025
3121
|
}, async ({ policy_id, public_only }) => {
|
|
3026
3122
|
try {
|
|
3123
|
+
const abstention = confirmSharedPolicy(policy_id, public_only);
|
|
3124
|
+
if (abstention)
|
|
3125
|
+
return err(abstention);
|
|
3027
3126
|
return ok(JSON.stringify(new ConstitutionService(store, root).card(policy_id, { publicOnly: public_only }), null, 2));
|
|
3028
3127
|
}
|
|
3029
3128
|
catch (e) {
|
|
@@ -3039,6 +3138,9 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
3039
3138
|
},
|
|
3040
3139
|
}, async ({ policy_id, public_only }) => {
|
|
3041
3140
|
try {
|
|
3141
|
+
const abstention = confirmSharedPolicy(policy_id, public_only);
|
|
3142
|
+
if (abstention)
|
|
3143
|
+
return err(abstention);
|
|
3042
3144
|
return ok(JSON.stringify(new ConstitutionService(store, root).shadowReport(policy_id, {}, { publicOnly: public_only }), null, 2));
|
|
3043
3145
|
}
|
|
3044
3146
|
catch (e) {
|
|
@@ -3054,6 +3156,9 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
3054
3156
|
},
|
|
3055
3157
|
}, async ({ policy_id, public_only }) => {
|
|
3056
3158
|
try {
|
|
3159
|
+
const abstention = confirmSharedPolicy(policy_id, public_only);
|
|
3160
|
+
if (abstention)
|
|
3161
|
+
return err(abstention);
|
|
3057
3162
|
const service = new ConstitutionService(store, root);
|
|
3058
3163
|
const policy = service.get(policy_id, { publicOnly: public_only });
|
|
3059
3164
|
if (!policy.proof)
|
|
@@ -3080,6 +3185,11 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
3080
3185
|
throw new Error("choose either workspace or commit for executable-behavior evaluation");
|
|
3081
3186
|
if (commit && !revExists(commit, root))
|
|
3082
3187
|
throw new Error(`commit ref ${JSON.stringify(commit)} does not resolve`);
|
|
3188
|
+
if (policy_id) {
|
|
3189
|
+
const abstention = confirmSharedPolicy(policy_id, public_only);
|
|
3190
|
+
if (abstention)
|
|
3191
|
+
return err(abstention);
|
|
3192
|
+
}
|
|
3083
3193
|
const exactCommit = commit ? revParse(`${commit}^{commit}`, root) : undefined;
|
|
3084
3194
|
const behavior = workspace ? { workspace }
|
|
3085
3195
|
: exactCommit ? { commit: exactCommit }
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
/** Shared with jsonStore's `.rmw-lock` (core/procstart.ts); re-exported so this
|
|
2
|
+
* module's surface is unchanged. */
|
|
3
|
+
export { CREATED_AFTER_SLACK_MS } from "../core/procstart.js";
|
|
1
4
|
/** Longest a write may hold the lock before another process may steal it: a put plus a
|
|
2
5
|
* git commit and push — seconds, not minutes. */
|
|
3
6
|
export declare const STALE_AFTER_MS = 60000;
|
|
@@ -7,10 +10,6 @@ export declare const ACQUIRE_TIMEOUT_MS = 10000;
|
|
|
7
10
|
* claimed section is a few syscalls, so anything this old means its claimer
|
|
8
11
|
* crashed inside it. */
|
|
9
12
|
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;
|
|
14
13
|
export interface LockOwner {
|
|
15
14
|
pid: number;
|
|
16
15
|
host: string;
|
package/dist/serve/writelock.js
CHANGED
|
@@ -22,7 +22,10 @@ import { hostname } from "node:os";
|
|
|
22
22
|
import { randomBytes } from "node:crypto";
|
|
23
23
|
import { closeSync, mkdirSync, openSync, readFileSync, rmSync, rmdirSync, statSync, writeSync } from "node:fs";
|
|
24
24
|
import { join } from "node:path";
|
|
25
|
-
import { heldLockNonces, judgeSameHostOwner, processStartedAt, selfStartToken, startTokenOf } from "../core/procstart.js";
|
|
25
|
+
import { createdAfterLock, heldLockNonces, judgeSameHostOwner, processStartedAt, selfStartToken, startTokenOf } from "../core/procstart.js";
|
|
26
|
+
/** Shared with jsonStore's `.rmw-lock` (core/procstart.ts); re-exported so this
|
|
27
|
+
* module's surface is unchanged. */
|
|
28
|
+
export { CREATED_AFTER_SLACK_MS } from "../core/procstart.js";
|
|
26
29
|
/** Longest a write may hold the lock before another process may steal it: a put plus a
|
|
27
30
|
* git commit and push — seconds, not minutes. */
|
|
28
31
|
export const STALE_AFTER_MS = 60_000;
|
|
@@ -33,10 +36,6 @@ export const ACQUIRE_TIMEOUT_MS = 10_000;
|
|
|
33
36
|
* crashed inside it. */
|
|
34
37
|
export const CLAIM_STALE_MS = 10_000;
|
|
35
38
|
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;
|
|
40
39
|
export class WriteLockTimeout extends Error {
|
|
41
40
|
path;
|
|
42
41
|
heldBy;
|
|
@@ -104,9 +103,10 @@ function stealable(path, owner, now, token) {
|
|
|
104
103
|
// and any skew between them would "prove" a live owner recycled and steal its
|
|
105
104
|
// lock. Anything the token cannot settle stays "not proven".
|
|
106
105
|
//
|
|
107
|
-
// The whole table
|
|
108
|
-
// `.rmw-lock`
|
|
109
|
-
// fallback below governs, exactly as
|
|
106
|
+
// The whole table, and the creation-time check for an "unproven" pid, live in
|
|
107
|
+
// core/procstart.ts so this lock and jsonStore's `.rmw-lock` apply one rule;
|
|
108
|
+
// "age" means it proved nothing and the fallback below governs, exactly as
|
|
109
|
+
// for a foreign host.
|
|
110
110
|
if (owner && owner.host === hostname()) {
|
|
111
111
|
const verdict = judgeSameHostOwner(owner, {
|
|
112
112
|
alive: pidAlive,
|
|
@@ -117,21 +117,12 @@ function stealable(path, owner, now, token) {
|
|
|
117
117
|
heldNonces: heldLockNonces,
|
|
118
118
|
});
|
|
119
119
|
if (verdict === "unproven")
|
|
120
|
-
return ageMs > STALE_AFTER_MS && createdAfterLock(owner, token);
|
|
120
|
+
return ageMs > STALE_AFTER_MS && createdAfterLock(owner, token.startedAt);
|
|
121
121
|
if (verdict !== "age")
|
|
122
122
|
return verdict === "stale";
|
|
123
123
|
}
|
|
124
124
|
return ageMs > STALE_AFTER_MS;
|
|
125
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
|
-
}
|
|
135
126
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
136
127
|
/** The claim a contender must hold to REMOVE a stale lock. A sibling directory,
|
|
137
128
|
* created with the same exclusive `mkdir` the commit lock uses
|
|
@@ -4,6 +4,7 @@ import { type DB } from "./db.js";
|
|
|
4
4
|
import { type Embedder } from "./embedder.js";
|
|
5
5
|
import { JsonStore } from "./jsonStore.js";
|
|
6
6
|
import { type RankingContext, type RankingQuery, type RankingWeights, type SlotOptions, type TaskSelection } from "../core/taskRanking.js";
|
|
7
|
+
import { type WhyById } from "../core/whyRecord.js";
|
|
7
8
|
import { type VetoTier } from "../core/strictgate.js";
|
|
8
9
|
import { type DiffAnalysis, type DiffStatus } from "../extractors/diff.js";
|
|
9
10
|
import type { CheckReport, CausalWhy, ImpactReport } from "../core/checkreport.js";
|
|
@@ -43,6 +44,8 @@ export interface WhyResult {
|
|
|
43
44
|
constraints: Constraint[];
|
|
44
45
|
symbols: Symbol[];
|
|
45
46
|
components: Component[];
|
|
47
|
+
/** Set when the target is a record id (`dec_…`, `con_…`, …; issue #474). */
|
|
48
|
+
byId?: WhyById;
|
|
46
49
|
}
|
|
47
50
|
export interface FragileNode {
|
|
48
51
|
id: string;
|
|
@@ -391,6 +394,13 @@ export declare class HunchStore {
|
|
|
391
394
|
asOf?: string;
|
|
392
395
|
canRead?: (record: unknown) => boolean;
|
|
393
396
|
}): WhyResult;
|
|
397
|
+
/** Look a record up by id across the record kinds why() explains (public ∪ private
|
|
398
|
+
* via recs(), narrowed by `canRead`). A decision carries its supersession chain.
|
|
399
|
+
* With `asOf`, a record outside its valid window then is "no record", and only
|
|
400
|
+
* chain links/neighbours already in effect then are shown. Status is the STORED
|
|
401
|
+
* status, exactly as the path/symbol view prints it.
|
|
402
|
+
* Neighbour ids the caller may not read are never printed. */
|
|
403
|
+
private whyRecord;
|
|
394
404
|
/** Transitive blast radius: every symbol/component that (in)directly depends on
|
|
395
405
|
* `id`, via a recursive CTE over the edges graph (hunch_get_dependents). We
|
|
396
406
|
* walk edges BACKWARD (edges.to = current) following call/dep/import/contains. */
|