@davesheffer/hunch 1.45.1 → 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/team.js +2 -2
- package/dist/integrations/uninstall.d.ts +7 -0
- package/dist/integrations/uninstall.js +19 -1
- 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
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. */
|
package/dist/store/hunchStore.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*
|
|
5
5
|
* - reindex(): JSON -> SQLite (rebuild the derived index + FTS)
|
|
6
6
|
* - search(): FTS5 ranked query (hunch_query)
|
|
7
|
-
* - why(): decisions/bugs/constraints explaining a path/symbol
|
|
7
|
+
* - why(): decisions/bugs/constraints explaining a path/symbol, or one record by id
|
|
8
8
|
* - getDependents(): recursive-CTE blast radius over the call/dep graph
|
|
9
9
|
* - checkConstraints(): constraints whose scope matches a glob/path
|
|
10
10
|
* - bugLineage(): bugs matching a symptom/symbol + their lineage
|
|
@@ -26,6 +26,7 @@ import { normalizePath, rankTaskRecords, recordIdsOf, selectLatestTasks, selectT
|
|
|
26
26
|
import { resolveTaskRankingMode } from "../core/taskRankingMode.js";
|
|
27
27
|
import { currentForTopic, isInForce } from "../core/topics.js";
|
|
28
28
|
import { edgeId } from "../core/ids.js";
|
|
29
|
+
import { WHY_GENERATED_ID, WHY_RECORD_ID } from "../core/whyRecord.js";
|
|
29
30
|
import { isStrictBlocker, isVetoBlocker } from "../core/strictgate.js";
|
|
30
31
|
import { effectiveForbids, matchForbids } from "../core/constraintmatch.js";
|
|
31
32
|
import { analyzeDiff, diffContentGaps } from "../extractors/diff.js";
|
|
@@ -1343,6 +1344,14 @@ export class HunchStore {
|
|
|
1343
1344
|
// An absolute target (an agent's edit-payload path, verbatim) never matched
|
|
1344
1345
|
// the repo-relative stored file paths below (issue #299).
|
|
1345
1346
|
target = repoRelativeTarget(target, this.paths.root);
|
|
1347
|
+
// A record id (every delivered brief line ends in `hunch_why("dec_…")`) resolves
|
|
1348
|
+
// to that record directly (issue #474). With `asOf`, a record not in force then
|
|
1349
|
+
// is "no record"; status stays as stored, like the path view (see whyRecord).
|
|
1350
|
+
const lookup = WHY_RECORD_ID.test(target) ? this.whyRecord(target, opts.canRead, opts.asOf) : undefined;
|
|
1351
|
+
if (lookup?.record)
|
|
1352
|
+
return { target, decisions: [], bugs: [], constraints: [], symbols: [], components: [], byId: lookup };
|
|
1353
|
+
// A miss is reported only for a generated-shape id; anything else resolves as before.
|
|
1354
|
+
const byId = lookup && WHY_GENERATED_ID.test(target) ? lookup : undefined;
|
|
1346
1355
|
const decisions = this.recs("decisions").filter(opts.canRead ?? (() => true));
|
|
1347
1356
|
const bugs = this.recs("bugs").filter(opts.canRead ?? (() => true));
|
|
1348
1357
|
const constraints = this.recs("constraints").filter(opts.canRead ?? (() => true));
|
|
@@ -1379,8 +1388,62 @@ export class HunchStore {
|
|
|
1379
1388
|
&& inWindow(c.valid_from, c.valid_to, asOf)),
|
|
1380
1389
|
symbols: matchedSymbols,
|
|
1381
1390
|
components: components.filter((c) => c.paths.some((g) => pathMatchesGlob(target, g) || [...fileSet].some((f) => pathMatchesGlob(f, g)))),
|
|
1391
|
+
...(byId ? { byId } : {}),
|
|
1382
1392
|
};
|
|
1383
1393
|
}
|
|
1394
|
+
/** Look a record up by id across the record kinds why() explains (public ∪ private
|
|
1395
|
+
* via recs(), narrowed by `canRead`). A decision carries its supersession chain.
|
|
1396
|
+
* With `asOf`, a record outside its valid window then is "no record", and only
|
|
1397
|
+
* chain links/neighbours already in effect then are shown. Status is the STORED
|
|
1398
|
+
* status, exactly as the path/symbol view prints it.
|
|
1399
|
+
* Neighbour ids the caller may not read are never printed. */
|
|
1400
|
+
whyRecord(id, canRead = () => true, asOf) {
|
|
1401
|
+
const find = (kind) => this.recs(kind).find((r) => r.id === id && canRead(r));
|
|
1402
|
+
// Decisions the caller may read that had taken effect by `asOf` (all when no asOf).
|
|
1403
|
+
const decs = new Map(this.recs("decisions").filter((x) => canRead(x) && inWindow(x.valid_from, null, asOf)).map((x) => [x.id, x]));
|
|
1404
|
+
const visible = (ref) => (ref && decs.has(ref) ? ref : null);
|
|
1405
|
+
const existed = (instant) => !asOf || instant <= asOf;
|
|
1406
|
+
let record = null;
|
|
1407
|
+
switch (id.slice(0, id.indexOf("_"))) {
|
|
1408
|
+
case "dec": {
|
|
1409
|
+
const d = find("decisions");
|
|
1410
|
+
if (!d || !inWindow(d.valid_from, d.valid_to, asOf))
|
|
1411
|
+
break;
|
|
1412
|
+
// Stored status, as the path view prints it; asOf only hides neighbours not yet in effect.
|
|
1413
|
+
const shown = { ...d, supersedes: visible(d.supersedes), superseded_by: visible(d.superseded_by) };
|
|
1414
|
+
record = { kind: "decision", record: shown, chain: supersessionChain(shown, decs) };
|
|
1415
|
+
break;
|
|
1416
|
+
}
|
|
1417
|
+
case "con": {
|
|
1418
|
+
const c = find("constraints");
|
|
1419
|
+
if (!c || !inWindow(c.valid_from, c.valid_to, asOf))
|
|
1420
|
+
break;
|
|
1421
|
+
record = { kind: "constraint", record: { ...c, source_decision: visible(c.source_decision) } };
|
|
1422
|
+
break;
|
|
1423
|
+
}
|
|
1424
|
+
case "bug": {
|
|
1425
|
+
const b = find("bugs");
|
|
1426
|
+
if (b)
|
|
1427
|
+
record = { kind: "bug", record: b };
|
|
1428
|
+
break;
|
|
1429
|
+
}
|
|
1430
|
+
case "fnd": {
|
|
1431
|
+
const f = find("findings");
|
|
1432
|
+
if (f && existed(f.observed_at))
|
|
1433
|
+
record = { kind: "finding", record: f };
|
|
1434
|
+
break;
|
|
1435
|
+
}
|
|
1436
|
+
// Not gated by asOf: the path view lists components regardless of asOf too.
|
|
1437
|
+
// Findings stay gated by observed_at (the path view does not list findings).
|
|
1438
|
+
case "cmp": {
|
|
1439
|
+
const c = find("components");
|
|
1440
|
+
if (c)
|
|
1441
|
+
record = { kind: "component", record: c };
|
|
1442
|
+
break;
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
1445
|
+
return { id, record, ...(asOf ? { asOf } : {}) };
|
|
1446
|
+
}
|
|
1384
1447
|
/** Transitive blast radius: every symbol/component that (in)directly depends on
|
|
1385
1448
|
* `id`, via a recursive CTE over the edges graph (hunch_get_dependents). We
|
|
1386
1449
|
* walk edges BACKWARD (edges.to = current) following call/dep/import/contains. */
|
|
@@ -2334,6 +2397,22 @@ function inWindow(valid_from, valid_to, asOf) {
|
|
|
2334
2397
|
return false;
|
|
2335
2398
|
return true;
|
|
2336
2399
|
}
|
|
2400
|
+
/** A decision's supersedes/superseded_by chain over `decs` (the readable, already
|
|
2401
|
+
* in-effect decisions), oldest first, cycle-guarded. `d` is the record as shown. */
|
|
2402
|
+
function supersessionChain(d, decs) {
|
|
2403
|
+
const seen = new Set([d.id]);
|
|
2404
|
+
const back = [];
|
|
2405
|
+
for (let cur = d.supersedes ? decs.get(d.supersedes) : undefined; cur && !seen.has(cur.id); cur = cur.supersedes ? decs.get(cur.supersedes) : undefined) {
|
|
2406
|
+
seen.add(cur.id);
|
|
2407
|
+
back.unshift(cur);
|
|
2408
|
+
}
|
|
2409
|
+
const fwd = [];
|
|
2410
|
+
for (let cur = d.superseded_by ? decs.get(d.superseded_by) : undefined; cur && !seen.has(cur.id); cur = cur.superseded_by ? decs.get(cur.superseded_by) : undefined) {
|
|
2411
|
+
seen.add(cur.id);
|
|
2412
|
+
fwd.push(cur);
|
|
2413
|
+
}
|
|
2414
|
+
return [...back, d, ...fwd].map((x) => ({ id: x.id, status: x.status, title: x.title }));
|
|
2415
|
+
}
|
|
2337
2416
|
function round(n) {
|
|
2338
2417
|
return Math.round(n * 100) / 100;
|
|
2339
2418
|
}
|
|
@@ -6,8 +6,11 @@ export declare const MAX_JSON_RECORD_BYTES: number;
|
|
|
6
6
|
export declare const MAX_JSON_INDEX_BYTES: number;
|
|
7
7
|
export declare const MAX_JSON_MANIFEST_BYTES: number;
|
|
8
8
|
export declare const MAX_JSON_DIRECTORY_ENTRIES_PER_KIND = 100000;
|
|
9
|
+
/** When the process now holding `pid` was created (wall-clock ms), or null. */
|
|
10
|
+
type StartedAtProbe = (pid: number) => number | null;
|
|
9
11
|
export declare class JsonStore {
|
|
10
12
|
private readonly paths;
|
|
13
|
+
private readonly lockOpts;
|
|
11
14
|
private _warnedForward;
|
|
12
15
|
/** Memoized validated records per kind. loadAll() is on every read-path method
|
|
13
16
|
* (why/checkConstraints/fragility/assembleContext…), each of which previously
|
|
@@ -20,7 +23,13 @@ export declare class JsonStore {
|
|
|
20
23
|
private readonly lexicalRoot;
|
|
21
24
|
private readonly canonicalRoot;
|
|
22
25
|
private readonly lexicalHunch;
|
|
23
|
-
|
|
26
|
+
/** `lockOpts.startedAt` is an INTERNAL TEST SEAM, not API: it replaces the OS
|
|
27
|
+
* creation-time probe of the `.rmw-lock` "unproven" rule so the suite can
|
|
28
|
+
* drive it on linux, where the real probe reports nothing. Callers outside
|
|
29
|
+
* this repo must leave it unset — `processStartedAt` is the only sound probe. */
|
|
30
|
+
constructor(paths: HunchPaths, lockOpts?: {
|
|
31
|
+
startedAt?: StartedAtProbe;
|
|
32
|
+
});
|
|
24
33
|
private lstatOrMissing;
|
|
25
34
|
private expectedCanonical;
|
|
26
35
|
/** Require an ordinary, canonically-contained directory. Creation is one
|
|
@@ -125,3 +134,4 @@ export declare class JsonStore {
|
|
|
125
134
|
skipped: number;
|
|
126
135
|
};
|
|
127
136
|
}
|
|
137
|
+
export {};
|