@davesheffer/hunch 1.45.1 → 1.46.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/dist/cli/index.js +165 -33
- package/dist/cli/invocation.d.ts +14 -0
- package/dist/cli/invocation.js +23 -0
- package/dist/cli/serve.d.ts +2 -0
- package/dist/cli/serve.js +2 -1
- package/dist/cli/verify.d.ts +28 -0
- package/dist/cli/verify.js +265 -0
- package/dist/constitution/experiment.d.ts +1 -1
- package/dist/core/agenthook.d.ts +1 -0
- package/dist/core/agenthook.js +82 -26
- package/dist/core/boundedSpawn.d.ts +57 -0
- package/dist/core/boundedSpawn.js +321 -0
- package/dist/core/config.d.ts +6 -0
- package/dist/core/config.js +1 -0
- package/dist/core/delivery.d.ts +10 -0
- package/dist/core/delivery.js +117 -9
- package/dist/core/migrate.d.ts +10 -1
- package/dist/core/migrate.js +17 -6
- 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/projectDna.js +11 -1
- package/dist/core/relativeImports.js +6 -2
- package/dist/core/taskSelection.d.ts +1 -0
- package/dist/core/taskSelection.js +132 -11
- package/dist/core/testSelection.d.ts +122 -0
- package/dist/core/testSelection.js +588 -0
- 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 +206 -94
- package/dist/extractors/indexer.js +42 -6
- package/dist/extractors/languages.d.ts +9 -0
- package/dist/extractors/languages.js +28 -0
- package/dist/extractors/parse.d.ts +20 -0
- package/dist/extractors/parse.js +81 -5
- package/dist/extractors/workspaces.js +3 -1
- package/dist/integrations/health.d.ts +6 -0
- package/dist/integrations/health.js +33 -12
- package/dist/integrations/hooks.d.ts +4 -2
- package/dist/integrations/hooks.js +12 -2
- package/dist/integrations/mergeDriver.js +5 -3
- package/dist/integrations/optout.d.ts +3 -0
- package/dist/integrations/optout.js +17 -0
- package/dist/integrations/providers.d.ts +6 -2
- package/dist/integrations/providers.js +19 -2
- package/dist/integrations/team.js +6 -4
- package/dist/integrations/uninstall.d.ts +17 -4
- package/dist/integrations/uninstall.js +35 -13
- package/dist/mcp/server.js +179 -30
- package/dist/serve/app.js +9 -1
- package/dist/serve/config.js +5 -0
- package/dist/serve/writelock.d.ts +6 -5
- package/dist/serve/writelock.js +26 -21
- package/dist/store/hunchStore.d.ts +12 -1
- package/dist/store/hunchStore.js +86 -4
- package/dist/store/jsonStore.d.ts +24 -4
- package/dist/store/jsonStore.js +75 -26
- package/dist/store/stateBinding.js +17 -4
- package/dist/synthesis/provider.js +26 -78
- package/dist/synthesis/synthesize.js +1 -1
- package/package.json +2 -2
- package/server.json +2 -2
|
@@ -12,9 +12,9 @@
|
|
|
12
12
|
*
|
|
13
13
|
* `.hunch/` holds the engineering memory, so it stays unless `--purge`.
|
|
14
14
|
*/
|
|
15
|
-
import { existsSync,
|
|
15
|
+
import { existsSync, readFileSync, readdirSync, rmdirSync, rmSync, statSync, writeFileSync, chmodSync } from "node:fs";
|
|
16
16
|
import { spawnSync } from "node:child_process";
|
|
17
|
-
import { basename, join, relative, resolve } 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
20
|
import { currentBranch, gitCommonDir, isGitRepo, isLinkedWorktree, separateGitDirLink } from "../extractors/git.js";
|
|
@@ -26,18 +26,39 @@ 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";
|
|
29
|
-
|
|
30
|
-
export
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
29
|
+
import { isOptedOut, OPT_OUT_FILE } from "./optout.js";
|
|
30
|
+
export { isOptedOut, OPT_OUT_FILE };
|
|
31
|
+
/** Whether `start`, or the repository containing it, opted out: walks up through
|
|
32
|
+
* `start`'s ancestors (which need not exist yet), stopping after the first one holding
|
|
33
|
+
* `.git` or at the filesystem root, so a path inside an opted-out repository (`serve
|
|
34
|
+
* init --root ./partitions/x`) is gated like the repository itself (#458). A `.hunch`
|
|
35
|
+
* store is deliberately not a boundary: a partition store nested in an opted-out
|
|
36
|
+
* repository stays gated (fail closed). */
|
|
37
|
+
export function optedOutAt(start) {
|
|
38
|
+
return optOutMarkerAt(start) !== null;
|
|
39
|
+
}
|
|
40
|
+
/** The opt-out marker that gates `start` (walked as in `optedOutAt`), or null. With
|
|
41
|
+
* `withinRepo`, a marker counts only when the walk reaches a repository: outside one,
|
|
42
|
+
* an ancestor's marker gates nothing. */
|
|
43
|
+
export function optOutMarkerAt(start, opts = {}) {
|
|
44
|
+
let found = null;
|
|
45
|
+
for (let dir = resolve(start);;) {
|
|
46
|
+
if (!found && isOptedOut(dir)) {
|
|
47
|
+
found = join(dir, OPT_OUT_FILE);
|
|
48
|
+
if (!opts.withinRepo)
|
|
49
|
+
return found;
|
|
50
|
+
}
|
|
51
|
+
if (existsSync(join(dir, ".git")))
|
|
52
|
+
return found;
|
|
53
|
+
const parent = dirname(dir);
|
|
54
|
+
if (parent === dir)
|
|
55
|
+
return opts.withinRepo ? null : found;
|
|
56
|
+
dir = parent;
|
|
37
57
|
}
|
|
38
58
|
}
|
|
39
|
-
|
|
40
|
-
|
|
59
|
+
/** The refusal, naming the marker of the repository actually checked (#488). */
|
|
60
|
+
export function optOutMessage(marker = OPT_OUT_FILE) {
|
|
61
|
+
return `Hunch was removed from this repository on purpose (${marker}), so it will not set itself up here again. To reinstall, delete ${marker} and run \`hunch init\`.`;
|
|
41
62
|
}
|
|
42
63
|
function markerText() {
|
|
43
64
|
return [
|
|
@@ -292,6 +313,7 @@ export function planUninstall(root, opts = {}) {
|
|
|
292
313
|
p.hookConfig(".codex/hooks.json", providerEntry);
|
|
293
314
|
p.hookConfig(".github/hooks/hunch.json", providerEntry);
|
|
294
315
|
p.hookConfig(".windsurf/hooks.json", providerEntry);
|
|
316
|
+
p.hookConfig(".devin/hooks.json", providerEntry);
|
|
295
317
|
p.json(".agents/hooks.json", "removed the hunch hook group", (json) => {
|
|
296
318
|
if (!("hunch" in json))
|
|
297
319
|
return false;
|
|
@@ -441,7 +463,7 @@ export function planUninstall(root, opts = {}) {
|
|
|
441
463
|
}
|
|
442
464
|
/** Directories Hunch's writers create; removed only when left empty. Deepest first. */
|
|
443
465
|
const PRUNE_DIRS = [
|
|
444
|
-
".claude/commands", ".claude", ".cursor/rules", ".cursor", ".windsurf/rules", ".windsurf",
|
|
466
|
+
".claude/commands", ".claude", ".cursor/rules", ".cursor", ".windsurf/rules", ".windsurf", ".devin",
|
|
445
467
|
".vscode", ".github/hooks", ".github/workflows", ".github", ".codex", ".agents",
|
|
446
468
|
];
|
|
447
469
|
export function applyUninstall(plan) {
|
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, WHY_RECORD_ID } 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,74 @@ 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
|
+
// Exact-id record reads (findings, corrections, imported ADRs, why-by-id): the
|
|
1257
|
+
// same confirmation, gated to shared mode. Null means "re-read; absence is
|
|
1258
|
+
// confirmed or the store is not shared", else the abstention to return instead.
|
|
1259
|
+
const confirmSharedRecord = (subject) => store.mode === "shared" && store.privateDir ? confirmSharedMiss(`${subject} is absent from shared team memory`, subject) : null;
|
|
1260
|
+
// Policy reads: confirm a local policy (or its referenced proof) miss against
|
|
1261
|
+
// fresh shared memory before the service reports "not found". Public-only reads
|
|
1262
|
+
// and non-shared stores never consult the team remote.
|
|
1263
|
+
const confirmSharedPolicy = (policyId, publicOnly) => {
|
|
1264
|
+
if (publicOnly || store.mode !== "shared" || !store.privateDir)
|
|
1265
|
+
return null;
|
|
1266
|
+
const repository = new PolicyRepository(root, store);
|
|
1267
|
+
const policy = repository.getPolicy(policyId);
|
|
1268
|
+
if (policy && (!policy.proof || repository.getProof(policy.proof)))
|
|
1269
|
+
return null;
|
|
1270
|
+
const subject = policy ? `proof ${policy.proof} of policy ${policyId}` : `policy ${policyId}`;
|
|
1271
|
+
return confirmSharedMiss(`${subject} is absent from shared team memory`, subject);
|
|
1272
|
+
};
|
|
1194
1273
|
// Resolve the embedder ONCE for this long-lived process (never throws; null when
|
|
1195
1274
|
// the optional model isn't installed). The model then loads lazily on the first
|
|
1196
1275
|
// hunch_query and stays warm — and hybridSearch degrades to FTS until then.
|
|
@@ -1231,6 +1310,8 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
1231
1310
|
nextRemotePullAt = prepared.nextRemotePullAt;
|
|
1232
1311
|
consecutivePullFailures = prepared.consecutivePullFailures;
|
|
1233
1312
|
indexedSourceStamp = prepared.indexedSourceStamp;
|
|
1313
|
+
lastForcedFailure = null;
|
|
1314
|
+
forcedFailureStreak = 0;
|
|
1234
1315
|
previous.close();
|
|
1235
1316
|
console.error(`[hunch-mcp] serving Hunch at ${root} (client root)`);
|
|
1236
1317
|
};
|
|
@@ -1339,8 +1420,10 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
1339
1420
|
|| !overlayMatchesTeamRemote(root, join(store.privateDir, "..")))) {
|
|
1340
1421
|
return refused("The committed team memory destination is invalid or no longer matches this process. Refusing the stale graph; reconnect Hunch first.");
|
|
1341
1422
|
}
|
|
1423
|
+
let pulledFresh = false;
|
|
1342
1424
|
if (store.mode === "shared" && store.privateDir) {
|
|
1343
|
-
pullTeamMemory();
|
|
1425
|
+
const pulled = pullTeamMemory();
|
|
1426
|
+
pulledFresh = pulled !== "cooldown" && pulled !== "not_shared" && teamPullIsFresh(pulled);
|
|
1344
1427
|
// Recompute the full semantic + physical snapshot after the synchronous
|
|
1345
1428
|
// network seam. A paired team.json/origin change can occur while fetch is
|
|
1346
1429
|
// blocked; serving after that race would attach the old checkout to a new
|
|
@@ -1360,7 +1443,17 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
1360
1443
|
refreshIndex();
|
|
1361
1444
|
}
|
|
1362
1445
|
catch { /* corrupt/churning local source — serve the last durable indexed view */ }
|
|
1363
|
-
|
|
1446
|
+
// A shared-miss check in the callback's synchronous prefix may reuse this
|
|
1447
|
+
// request's fresh pull; any later (post-await) check falls back to a forced pull.
|
|
1448
|
+
const result = await withInitiator(initiatorFromClient(server.server.getClientVersion()?.name), () => {
|
|
1449
|
+
requestPulledFresh = pulledFresh;
|
|
1450
|
+
try {
|
|
1451
|
+
return callback(...args);
|
|
1452
|
+
}
|
|
1453
|
+
finally {
|
|
1454
|
+
requestPulledFresh = false;
|
|
1455
|
+
}
|
|
1456
|
+
});
|
|
1364
1457
|
if (teamAdvertised && !matchesStartupTeamRoute()) {
|
|
1365
1458
|
return refused("The team-memory route changed while the tool was running. Its startup destination was not published; reconnect Hunch before retrying.");
|
|
1366
1459
|
}
|
|
@@ -1409,16 +1502,35 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
1409
1502
|
// -- hunch_why ------------------------------------------------------------
|
|
1410
1503
|
server.registerTool("hunch_why", {
|
|
1411
1504
|
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).",
|
|
1505
|
+
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
1506
|
inputSchema: {
|
|
1414
|
-
target: z.string().describe("A file path (e.g. src/auth/session.ts) or
|
|
1507
|
+
target: z.string().describe("A file path (e.g. src/auth/session.ts), symbol name, or record id (e.g. dec_0b5d2be516)."),
|
|
1415
1508
|
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
1509
|
},
|
|
1417
1510
|
}, async ({ target, as_of }) => {
|
|
1418
1511
|
const asOf = as_of ? asOfDate(as_of, root) : undefined;
|
|
1419
1512
|
if (as_of && !asOf)
|
|
1420
1513
|
return invalid(`Could not resolve as_of "${as_of}" to a commit.`);
|
|
1421
|
-
|
|
1514
|
+
let w = store.why(target, { asOf });
|
|
1515
|
+
// An id-shaped target is an id miss when the store reported one (generated
|
|
1516
|
+
// shape) or, for a hand-written id, when nothing else matched it either.
|
|
1517
|
+
const idMiss = (r) => !r.byId?.record && (r.byId !== undefined || (WHY_RECORD_ID.test(target)
|
|
1518
|
+
&& !r.decisions.length && !r.constraints.length && !r.bugs.length && !r.components.length && !r.symbols.length));
|
|
1519
|
+
// "No record <id>" is a team-absence claim in shared mode: confirm the local
|
|
1520
|
+
// miss against a fresh pull first, like hunch_current_decision. A record held
|
|
1521
|
+
// locally but outside its as_of window is not a team-absence claim: no pull.
|
|
1522
|
+
const heldNow = () => !!(asOf && store.why(target).byId?.record);
|
|
1523
|
+
if (idMiss(w) && !heldNow()) {
|
|
1524
|
+
const abstention = confirmSharedRecord(`record ${w.byId?.id ?? target}`);
|
|
1525
|
+
if (abstention)
|
|
1526
|
+
return err(abstention);
|
|
1527
|
+
w = store.why(target, { asOf });
|
|
1528
|
+
}
|
|
1529
|
+
// A hand-written id held now but not in force at as_of: say so, like a generated id.
|
|
1530
|
+
if (asOf && !w.byId && idMiss(w) && heldNow())
|
|
1531
|
+
w = { ...w, byId: { id: target, record: null, asOf } };
|
|
1532
|
+
if (w.byId?.record)
|
|
1533
|
+
return ok(formatWhyRecord(w.byId).join("\n"));
|
|
1422
1534
|
// Highest-signal first, then cap: invariants by severity, decisions by
|
|
1423
1535
|
// confidence, bugs by severity — so a hot file's trim drops the tail, not
|
|
1424
1536
|
// the records that matter most.
|
|
@@ -1426,6 +1538,8 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
1426
1538
|
const constraints = [...w.constraints].sort((a, b) => (SEV_CONSTRAINT[b.severity] ?? 0) - (SEV_CONSTRAINT[a.severity] ?? 0));
|
|
1427
1539
|
const bugs = [...w.bugs].sort((a, b) => (SEV_BUG[b.severity] ?? 0) - (SEV_BUG[a.severity] ?? 0));
|
|
1428
1540
|
const parts = [`Why for "${target}":`];
|
|
1541
|
+
if (w.byId)
|
|
1542
|
+
parts.push(`\n${formatWhyRecord(w.byId).join("\n")}`);
|
|
1429
1543
|
if (decisions.length)
|
|
1430
1544
|
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
1545
|
if (constraints.length)
|
|
@@ -1963,7 +2077,14 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
1963
2077
|
},
|
|
1964
2078
|
}, async ({ decision_id, expected_source_hash, expected_review_hash, disposition, reviewed_by }) => {
|
|
1965
2079
|
try {
|
|
1966
|
-
const
|
|
2080
|
+
const find = () => store.advisoryRecs("decisions").find((candidate) => candidate.id === decision_id);
|
|
2081
|
+
let decision = find();
|
|
2082
|
+
if (!decision) {
|
|
2083
|
+
const abstention = confirmSharedRecord(`imported ADR ${decision_id}`);
|
|
2084
|
+
if (abstention)
|
|
2085
|
+
return err(abstention);
|
|
2086
|
+
decision = find();
|
|
2087
|
+
}
|
|
1967
2088
|
if (!decision)
|
|
1968
2089
|
return invalid(`Imported ADR ${decision_id} is not present in the current advisory memory home.`);
|
|
1969
2090
|
// Approval grants human authority, so the agent's report that the human approved
|
|
@@ -2071,21 +2192,11 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
2071
2192
|
let live = liveForTopic(decs, topic);
|
|
2072
2193
|
// A prior transient fetch failure may have placed ordinary tool traffic in
|
|
2073
2194
|
// 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.
|
|
2195
|
+
// miss must be confirmed against fresh team memory (confirmSharedMiss).
|
|
2077
2196
|
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
|
-
}
|
|
2197
|
+
const abstention = confirmSharedMiss(`topic "${topic}" has no current decision`, `topic "${topic}"`);
|
|
2198
|
+
if (abstention)
|
|
2199
|
+
return err(abstention);
|
|
2089
2200
|
decs = store.recs("decisions");
|
|
2090
2201
|
live = liveForTopic(decs, topic);
|
|
2091
2202
|
}
|
|
@@ -2588,7 +2699,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
2588
2699
|
});
|
|
2589
2700
|
server.registerTool("nuryel_read", {
|
|
2590
2701
|
title: "nuryel.state/1 read — the system-of-record answer for a subject",
|
|
2591
|
-
description: "Read organizational state under a delivery receipt. Pass the principal (id, kind, grants) and the scope; optionally a subject (an entity id, a decision topic, an external `object_type:object_key`) to get state_of_record — what is current, in force, done, what it depends on and what invalidates it — plus a task phrase for the ranked delivery envelope. Scopes the principal is not granted are named in denied_scopes, never silently dropped. To read observations beyond the default 64, use observed_page:{} with one scope and a subject, then pass state_of_record.observed_page.next_cursor as observed_page.cursor until null. A conflict means the observations changed: restart from the first page. Never claim complete coverage while a next cursor remains.",
|
|
2702
|
+
description: "Read organizational state under a delivery receipt. Pass the principal (id, kind, grants) and the scope; optionally a subject (an entity id, a decision topic, an external `object_type:object_key`) to get state_of_record — what is current, in force, done, what it depends on and what invalidates it — plus a task phrase for the ranked delivery envelope. Scopes the principal is not granted are named in denied_scopes, never silently dropped. To read observations beyond the default 64, use observed_page:{} with one scope and a subject, then pass state_of_record.observed_page.next_cursor as observed_page.cursor until null. A conflict means the observations changed: restart from the first page. Never claim complete coverage while a next cursor remains. facets filters state_of_record and conventions only; the delivery envelope is ranked independently and keeps the constraints in force.",
|
|
2592
2703
|
inputSchema: ReadRequestSchema.omit({ schema: true }).shape,
|
|
2593
2704
|
outputSchema: ReadResponseSchema.shape,
|
|
2594
2705
|
}, async (input) => {
|
|
@@ -2652,7 +2763,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
2652
2763
|
});
|
|
2653
2764
|
server.registerTool("nuryel_write", {
|
|
2654
2765
|
title: "nuryel.state/1 write — provenance + idempotency in, durability out",
|
|
2655
|
-
description: "Write one record into a facet (receipts, commitments, derived, entities, relationships, or the legacy decisions/constraints/bugs/findings). The record must carry provenance; the request must carry an idempotency_key — a replay returns the original, a reused key with a different payload is refused. Ids are derived from the record's facts, never chosen. A second live decision on a topic is refused with the incumbent named; pass supersedes to replace it explicitly. organization/team/user partitions never ride a repository: they require an overlay. To show an existing captured observation under another subject without copying it, write a relationship type observation_about with from=observation id, to=subject, observation_hash, lifecycle=active, reason and hashed external evidence of the explicit association. Retire the relationship to unlink; reactivation requires expected_version. With capability nuryel.field-provenance/1, a derived record may carry field_provenance: [{selector:{kind:json_pointer,path:/field} or {kind:text,start:0,end:10}, value_hash:stateHash(selected scalar or text), dependency_hashes:[stateHash(existing dependency)]}]. Text offsets count Unicode code points, end exclusive. Citations are writer-supplied traceability, not verified support; negotiate support in every shared reader before writing them. With nuryel.record-visibility/1, records may carry visibility:{owner:principal-id,readers:[ids],writers:[ids]}; the owner is implicit in both lists, and writers must be readers. Owner-only audience changes require expected_version, including supersession. Restricted records require a dedicated partition; local stdio principal assertions assume trusted callers.",
|
|
2766
|
+
description: "Write one record into a facet (receipts, commitments, derived, entities, relationships, or the legacy decisions/constraints/bugs/findings). The record must carry provenance; the request must carry an idempotency_key — a replay returns the original, a reused key with a different payload is refused. Ids are derived from the record's facts, never chosen. A second live decision on a topic is refused with the incumbent named; pass supersedes to replace it explicitly. organization/team/user partitions never ride a repository: they require an overlay. To show an existing captured observation under another subject without copying it, write a relationship type observation_about with from=observation id, to=subject, observation_hash, lifecycle=active, reason and hashed external evidence of the explicit association. Retire the relationship to unlink; reactivation requires expected_version. With capability nuryel.field-provenance/1, a derived record may carry field_provenance: [{selector:{kind:json_pointer,path:/field} or {kind:text,start:0,end:10}, value_hash:stateHash(selected scalar or text), dependency_hashes:[stateHash(existing dependency)]}]. Text offsets count Unicode code points, end exclusive. Citations are writer-supplied traceability, not verified support; negotiate support in every shared reader before writing them. With nuryel.record-visibility/1, records may carry visibility:{owner:principal-id,readers:[ids],writers:[ids]}; the owner is implicit in both lists, and writers must be readers. Owner-only audience changes require expected_version, including supersession. Restricted records require a dedicated partition; local stdio principal assertions assume trusted callers. expected_version is compare-and-swap against the incumbent, or with supersedes against the predecessor (its record hash or ledger seq); for a record not on file pass null, or 0 to assert the id was never written — any other value is refused as a conflict.",
|
|
2656
2767
|
inputSchema: { ...WriteRequestSchema.omit({ schema: true }).shape, cwd: cwdHintField },
|
|
2657
2768
|
outputSchema: WriteResultSchema.shape,
|
|
2658
2769
|
}, async ({ cwd: _cwd, ...input }) => {
|
|
@@ -2757,7 +2868,14 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
2757
2868
|
const concerns = (f) => [...f.affected_files, ...f.affected_symbols].join(", ") || "(unscoped)";
|
|
2758
2869
|
const links = (f) => [f.violates_constraint ? `violates ${f.violates_constraint}` : "", f.method ? `re-verify via ${f.method}` : "", f.resolved_commit ? `fixed in ${f.resolved_commit.slice(0, 9)}` : ""].filter(Boolean).join("; ");
|
|
2759
2870
|
if (id) {
|
|
2760
|
-
const
|
|
2871
|
+
const find = () => store.recs("findings").find((r) => r.id === id);
|
|
2872
|
+
let f = find();
|
|
2873
|
+
if (!f) {
|
|
2874
|
+
const abstention = confirmSharedRecord(`finding ${id}`);
|
|
2875
|
+
if (abstention)
|
|
2876
|
+
return err(abstention);
|
|
2877
|
+
f = find();
|
|
2878
|
+
}
|
|
2761
2879
|
if (!f)
|
|
2762
2880
|
return ok(`No finding "${id}".`);
|
|
2763
2881
|
return ok(`[${f.triage}/${f.severity}] ${f.title} (${f.id}, observed ${f.observed_at.slice(0, 10)})\n${f.observation}\nconcerns: ${concerns(f)}${links(f) ? `\n${links(f)}` : ""}`);
|
|
@@ -2790,6 +2908,16 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
2790
2908
|
try {
|
|
2791
2909
|
if (public_only && private_only)
|
|
2792
2910
|
return invalid("Choose only one of public_only or private_only.");
|
|
2911
|
+
// A correction missing from every home this call may read is confirmed against
|
|
2912
|
+
// fresh shared memory before the upgrade reports it "not found". Public-only
|
|
2913
|
+
// reads never consult the team remote.
|
|
2914
|
+
const heldCorrection = () => !!(store.getPrivateRec("constraints", constraint_id)
|
|
2915
|
+
|| (!private_only && store.json.get("constraints", constraint_id)));
|
|
2916
|
+
if (!public_only && !heldCorrection()) {
|
|
2917
|
+
const abstention = confirmSharedRecord(`correction ${constraint_id}`);
|
|
2918
|
+
if (abstention)
|
|
2919
|
+
return err(abstention);
|
|
2920
|
+
}
|
|
2793
2921
|
// Resolve the correction's exact home before any writes. Overlay-first is
|
|
2794
2922
|
// the same selection contract as ConstitutionService.upgradeCorrection;
|
|
2795
2923
|
// deriving this later from a policy id is unsafe when legacy public and
|
|
@@ -3002,6 +3130,9 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
3002
3130
|
},
|
|
3003
3131
|
}, async ({ policy_id, public_only }) => {
|
|
3004
3132
|
try {
|
|
3133
|
+
const abstention = confirmSharedPolicy(policy_id, public_only);
|
|
3134
|
+
if (abstention)
|
|
3135
|
+
return err(abstention);
|
|
3005
3136
|
const service = new ConstitutionService(store, root);
|
|
3006
3137
|
const plan = service.plan(policy_id, { publicOnly: public_only });
|
|
3007
3138
|
const home = public_only ? "public" : service.repository.homeOfPolicy(policy_id);
|
|
@@ -3024,6 +3155,9 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
3024
3155
|
},
|
|
3025
3156
|
}, async ({ policy_id, public_only }) => {
|
|
3026
3157
|
try {
|
|
3158
|
+
const abstention = confirmSharedPolicy(policy_id, public_only);
|
|
3159
|
+
if (abstention)
|
|
3160
|
+
return err(abstention);
|
|
3027
3161
|
return ok(JSON.stringify(new ConstitutionService(store, root).card(policy_id, { publicOnly: public_only }), null, 2));
|
|
3028
3162
|
}
|
|
3029
3163
|
catch (e) {
|
|
@@ -3039,6 +3173,9 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
3039
3173
|
},
|
|
3040
3174
|
}, async ({ policy_id, public_only }) => {
|
|
3041
3175
|
try {
|
|
3176
|
+
const abstention = confirmSharedPolicy(policy_id, public_only);
|
|
3177
|
+
if (abstention)
|
|
3178
|
+
return err(abstention);
|
|
3042
3179
|
return ok(JSON.stringify(new ConstitutionService(store, root).shadowReport(policy_id, {}, { publicOnly: public_only }), null, 2));
|
|
3043
3180
|
}
|
|
3044
3181
|
catch (e) {
|
|
@@ -3054,6 +3191,9 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
3054
3191
|
},
|
|
3055
3192
|
}, async ({ policy_id, public_only }) => {
|
|
3056
3193
|
try {
|
|
3194
|
+
const abstention = confirmSharedPolicy(policy_id, public_only);
|
|
3195
|
+
if (abstention)
|
|
3196
|
+
return err(abstention);
|
|
3057
3197
|
const service = new ConstitutionService(store, root);
|
|
3058
3198
|
const policy = service.get(policy_id, { publicOnly: public_only });
|
|
3059
3199
|
if (!policy.proof)
|
|
@@ -3080,6 +3220,11 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
3080
3220
|
throw new Error("choose either workspace or commit for executable-behavior evaluation");
|
|
3081
3221
|
if (commit && !revExists(commit, root))
|
|
3082
3222
|
throw new Error(`commit ref ${JSON.stringify(commit)} does not resolve`);
|
|
3223
|
+
if (policy_id) {
|
|
3224
|
+
const abstention = confirmSharedPolicy(policy_id, public_only);
|
|
3225
|
+
if (abstention)
|
|
3226
|
+
return err(abstention);
|
|
3227
|
+
}
|
|
3083
3228
|
const exactCommit = commit ? revParse(`${commit}^{commit}`, root) : undefined;
|
|
3084
3229
|
const behavior = workspace ? { workspace }
|
|
3085
3230
|
: exactCommit ? { commit: exactCommit }
|
|
@@ -3369,6 +3514,10 @@ export async function startServer(cwd = process.cwd(), options = {}) {
|
|
|
3369
3514
|
wireClientRoots(control, fallback);
|
|
3370
3515
|
const transport = new StdioServerTransport();
|
|
3371
3516
|
await control.server.connect(transport);
|
|
3517
|
+
// Exit through process.exit on a termination signal (same conventional codes as
|
|
3518
|
+
// a signal death) so NODE_V8_COVERAGE is flushed for `hunch verify --refresh-map`.
|
|
3519
|
+
process.once("SIGTERM", () => process.exit(143));
|
|
3520
|
+
process.once("SIGINT", () => process.exit(130));
|
|
3372
3521
|
console.error(control.pinned
|
|
3373
3522
|
? `[hunch-mcp] serving Hunch over stdio (pinned root ${control.getRoot()}; client roots ignored)`
|
|
3374
3523
|
: `[hunch-mcp] serving Hunch over stdio (spawn root ${control.getRoot()}; resolving client roots…)`);
|
package/dist/serve/app.js
CHANGED
|
@@ -35,6 +35,8 @@ import { operatorHtml, operatorCss, operatorJs } from "./operator.js";
|
|
|
35
35
|
import { MCP_PATH, handleMcpRequest } from "./mcpHttp.js";
|
|
36
36
|
export const BODY_LIMIT_BYTES = 1024 * 1024;
|
|
37
37
|
const POST_ROUTES = new Map([["/nuryel/v1/read", "read"], ["/nuryel/v1/write", "write"], ["/nuryel/v1/capture", "capture"], ["/nuryel/v1/capture-batch", "capture-batch"], ["/nuryel/v1/subscribe", "subscribe"], ["/nuryel/v1/records", "records"]]);
|
|
38
|
+
/** GET-only routes: any other method is 405 with `Allow: GET`, not a POST-table 404. */
|
|
39
|
+
const GET_ROUTES = new Set(["/nuryel/v1/health", "/nuryel/v1/capabilities"]);
|
|
38
40
|
export const PROBLEM_TYPE = "https://www.hunchmemory.com/problems/nuryel.state/1/";
|
|
39
41
|
export class HttpProblem extends Error {
|
|
40
42
|
status;
|
|
@@ -272,6 +274,10 @@ export function createServeApp(config, opts = {}) {
|
|
|
272
274
|
return send(res, 200, { ...liveness, partitions: activeConfig.partitions.map((p) => scopePath(p.scope)) });
|
|
273
275
|
}
|
|
274
276
|
const principal = await authenticate(req, res, url, activeConfig);
|
|
277
|
+
if (GET_ROUTES.has(url.pathname) && req.method !== "GET") {
|
|
278
|
+
res.setHeader("allow", "GET");
|
|
279
|
+
throw problem(405, "method-not-allowed", `${req.method} is not allowed on ${url.pathname}`);
|
|
280
|
+
}
|
|
275
281
|
if (url.pathname === "/nuryel/v1/capabilities" && req.method === "GET") {
|
|
276
282
|
const scope = parseScopeParam(url.searchParams.get("scope"));
|
|
277
283
|
const { status, payload } = await dispatch("capabilities", principal, scope ? { scope } : {}, activeConfig);
|
|
@@ -286,8 +292,10 @@ export function createServeApp(config, opts = {}) {
|
|
|
286
292
|
const message = await readBody(req);
|
|
287
293
|
return await handleMcpRequest(req, res, message, (route, body) => dispatch(route, principal, body, activeConfig), (error) => problemBody(problemOf(error)), version);
|
|
288
294
|
}
|
|
289
|
-
if (req.method !== "POST")
|
|
295
|
+
if (req.method !== "POST") {
|
|
296
|
+
res.setHeader("allow", "POST");
|
|
290
297
|
throw problem(405, "method-not-allowed", `${req.method} is not allowed on ${url.pathname}`);
|
|
298
|
+
}
|
|
291
299
|
const route = POST_ROUTES.get(url.pathname);
|
|
292
300
|
if (!route)
|
|
293
301
|
throw problem(404, "not-found", `${url.pathname} is not a nuryel.state/1 route`);
|
package/dist/serve/config.js
CHANGED
|
@@ -114,6 +114,11 @@ export function initServeConfig(opts) {
|
|
|
114
114
|
if (!publicOrigin)
|
|
115
115
|
throw new Error("key-bound principals require an HTTPS public origin");
|
|
116
116
|
}
|
|
117
|
+
// Refuse before writing anything: a grant must name a partition this config will serve.
|
|
118
|
+
const served = new Set([...(existing?.partitions ?? []).map((p) => scopePath(p.scope)), scopePath(opts.scope)]);
|
|
119
|
+
for (const g of opts.principal?.grants ?? [])
|
|
120
|
+
if (!served.has(scopePath(g)))
|
|
121
|
+
throw new Error(`cannot grant ${scopePath(g)}: this server does not serve it`);
|
|
117
122
|
const root = resolve(opts.root);
|
|
118
123
|
const hunchDir = resolve(root, ".hunch");
|
|
119
124
|
mkdirSync(hunchDir, { recursive: true });
|
|
@@ -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,14 +10,12 @@ 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
|
-
/**
|
|
11
|
-
*
|
|
12
|
-
* started BEFORE it took the lock; the slack only absorbs clock granularity. */
|
|
13
|
-
export declare const CREATED_AFTER_SLACK_MS = 5000;
|
|
13
|
+
/** `nonce` is optional on READ only: this lock always writes one, but a legacy or
|
|
14
|
+
* foreign writer's record may lack it (issue #491). */
|
|
14
15
|
export interface LockOwner {
|
|
15
16
|
pid: number;
|
|
16
17
|
host: string;
|
|
17
|
-
nonce
|
|
18
|
+
nonce?: string;
|
|
18
19
|
at: string;
|
|
19
20
|
start?: string;
|
|
20
21
|
}
|