@davesheffer/hunch 1.29.0 → 1.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -0
- package/dist/cli/automaticReviewMemory.js +124 -0
- package/dist/cli/index.js +80 -10
- package/dist/cli/invocation.js +9 -0
- package/dist/cli/reviewMemory.js +34 -0
- package/dist/cli/reviewMemoryProvider.js +40 -0
- package/dist/cli/serve.js +44 -0
- package/dist/constitution/experimentRunner.js +3 -1
- package/dist/core/automaticReviewMemory.js +141 -0
- package/dist/core/reviewMemory.js +100 -0
- package/dist/core/stateContract.js +9 -0
- package/dist/core/stateRecords.js +30 -0
- package/dist/extractors/diff.js +26 -21
- package/dist/extractors/git.js +7 -1
- package/dist/extractors/languages.js +8 -0
- package/dist/mcp/server.js +53 -44
- package/dist/store/replay.js +153 -0
- package/dist/store/stateBinding.js +160 -12
- package/dist/synthesis/cliAdapter.js +168 -0
- package/dist/synthesis/initiator.js +58 -0
- package/dist/synthesis/provider.js +84 -48
- package/dist/synthesis/synthesize.js +37 -16
- package/package.json +3 -1
- package/server.json +2 -2
package/dist/mcp/server.js
CHANGED
|
@@ -57,8 +57,17 @@ import { issueCaptureToken as issueToken, consumeCaptureToken as consumeToken }
|
|
|
57
57
|
import { randomUUID } from "node:crypto";
|
|
58
58
|
import { existsSync } from "node:fs";
|
|
59
59
|
import { join } from "node:path";
|
|
60
|
+
import { initiatorFromClient, withInitiator } from "../synthesis/initiator.js";
|
|
60
61
|
const ok = (text) => ({ content: [{ type: "text", text }] });
|
|
61
62
|
const err = (text) => ({ content: [{ type: "text", text }], isError: true });
|
|
63
|
+
/** Error classes as text prefixes — client-agnostic, no schema change, so any MCP client
|
|
64
|
+
* can pick its next move from the first word:
|
|
65
|
+
* Refused: … a gate held. Do not retry the same call; resolve the named conflict or ask a human.
|
|
66
|
+
* Invalid: … the arguments are wrong. Fix them and call again.
|
|
67
|
+
* Failed to … internal or environmental. One retry is reasonable.
|
|
68
|
+
* Transient states that say "retry" in their own words stay unprefixed. */
|
|
69
|
+
const refused = (text) => err(`Refused: ${text}`);
|
|
70
|
+
const invalid = (text) => err(`Invalid: ${text}`);
|
|
62
71
|
/** Shared by every auto-committing write tool (issue #20): the MCP `roots` protocol
|
|
63
72
|
* cannot see an agent-driven `cd`/EnterWorktree, so a stdio server's cached root
|
|
64
73
|
* never moves on its own — this is the client-agnostic fallback, resolved fresh on
|
|
@@ -700,17 +709,17 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
700
709
|
// root/store/route epoch for its complete execution.
|
|
701
710
|
const teamFileNow = !explicitOverlay && existsSync(teamFile);
|
|
702
711
|
if (teamFileNow !== teamAdvertised) {
|
|
703
|
-
return
|
|
712
|
+
return refused("The committed team-memory routing changed after this MCP process started. Reconnect Hunch before reading or writing memory.");
|
|
704
713
|
}
|
|
705
714
|
const currentTeamConfig = teamFileNow ? readTeamConfig(root) : null;
|
|
706
715
|
if (teamAdvertised && !matchesStartupTeamRoute()) {
|
|
707
|
-
return
|
|
716
|
+
return refused("The team-memory URL or branch changed after this MCP process started. Refusing the old graph; reconnect Hunch first.");
|
|
708
717
|
}
|
|
709
718
|
if (teamFileNow && (!currentTeamConfig
|
|
710
719
|
|| store.mode !== "shared"
|
|
711
720
|
|| !store.privateDir
|
|
712
721
|
|| !overlayMatchesTeamRemote(root, join(store.privateDir, "..")))) {
|
|
713
|
-
return
|
|
722
|
+
return refused("The committed team memory destination is invalid or no longer matches this process. Refusing the stale graph; reconnect Hunch first.");
|
|
714
723
|
}
|
|
715
724
|
if (store.mode === "shared" && store.privateDir) {
|
|
716
725
|
pullTeamMemory();
|
|
@@ -719,7 +728,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
719
728
|
// blocked; serving after that race would attach the old checkout to a new
|
|
720
729
|
// destination even though the pull itself correctly refused.
|
|
721
730
|
if (teamAdvertised && !matchesStartupTeamRoute()) {
|
|
722
|
-
return
|
|
731
|
+
return refused("The team-memory route changed during refresh. Refusing to serve a stale or redirected graph; reconnect Hunch first.");
|
|
723
732
|
}
|
|
724
733
|
}
|
|
725
734
|
// Stamp check in EVERY mode, not only shared: a CLI capture or post-commit
|
|
@@ -733,9 +742,9 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
733
742
|
refreshIndex();
|
|
734
743
|
}
|
|
735
744
|
catch { /* corrupt/churning local source — serve the last durable indexed view */ }
|
|
736
|
-
const result = await callback(...args);
|
|
745
|
+
const result = await withInitiator(initiatorFromClient(server.server.getClientVersion()?.name), () => callback(...args));
|
|
737
746
|
if (teamAdvertised && !matchesStartupTeamRoute()) {
|
|
738
|
-
return
|
|
747
|
+
return refused("The team-memory route changed while the tool was running. Its startup destination was not published; reconnect Hunch before retrying.");
|
|
739
748
|
}
|
|
740
749
|
return result;
|
|
741
750
|
}
|
|
@@ -747,7 +756,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
747
756
|
// -- hunch_query ----------------------------------------------------------
|
|
748
757
|
server.registerTool("hunch_query", {
|
|
749
758
|
title: "Query Hunch",
|
|
750
|
-
description: "Full-text + graph search across the engineering memory (decisions, bugs, constraints, components, symbols). Returns ranked records with provenance. Use this to ask 'why' questions about the codebase.",
|
|
759
|
+
description: "Full-text + graph search across the engineering memory (decisions, bugs, constraints, components, symbols). Returns ranked records with provenance. Use this to ask 'why' questions about the codebase. Not for orienting on a known file or symbol (hunch_context / hunch_why give the curated slice with its blast radius) or for finding where code lives (hunch_structure).",
|
|
751
760
|
inputSchema: { query: z.string().describe("A natural-language question or keywords.") },
|
|
752
761
|
}, async ({ query }) => {
|
|
753
762
|
const hits = await store.hybridSearch(query, QUERY_HITS, { embedder: await embedderReady });
|
|
@@ -762,7 +771,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
762
771
|
// -- hunch_runbook --------------------------------------------------------
|
|
763
772
|
server.registerTool("hunch_runbook", {
|
|
764
773
|
title: "Find a runbook for a task",
|
|
765
|
-
description: "Look up the proven 'how-to' (ordered steps + files) for a recurring task — runbook-SCOPED retrieval (searches within runbooks, not the whole graph). Use at the START of a task to reuse a known procedure instead of re-deriving it. Advisory.",
|
|
774
|
+
description: "Look up the proven 'how-to' (ordered steps + files) for a recurring task — runbook-SCOPED retrieval (searches within runbooks, not the whole graph). Use at the START of a task to reuse a known procedure instead of re-deriving it. Advisory. Not for design rationale (hunch_why) or free-text memory search (hunch_query).",
|
|
766
775
|
inputSchema: { task: z.string().describe("The task/intent, e.g. 'add an MCP tool' or 'cut a release'.") },
|
|
767
776
|
}, async ({ task }) => {
|
|
768
777
|
const hits = await store.searchRunbooks(task, 5, { embedder: await embedderReady });
|
|
@@ -781,7 +790,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
781
790
|
// -- hunch_why ------------------------------------------------------------
|
|
782
791
|
server.registerTool("hunch_why", {
|
|
783
792
|
title: "Explain why a file/symbol is the way it is",
|
|
784
|
-
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.",
|
|
793
|
+
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).",
|
|
785
794
|
inputSchema: {
|
|
786
795
|
target: z.string().describe("A file path (e.g. src/auth/session.ts) or symbol name."),
|
|
787
796
|
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."),
|
|
@@ -789,7 +798,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
789
798
|
}, async ({ target, as_of }) => {
|
|
790
799
|
const asOf = as_of ? asOfDate(as_of, root) : undefined;
|
|
791
800
|
if (as_of && !asOf)
|
|
792
|
-
return
|
|
801
|
+
return invalid(`Could not resolve as_of "${as_of}" to a commit.`);
|
|
793
802
|
const w = store.why(target, { asOf });
|
|
794
803
|
// Highest-signal first, then cap: invariants by severity, decisions by
|
|
795
804
|
// confidence, bugs by severity — so a hot file's trim drops the tail, not
|
|
@@ -830,7 +839,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
830
839
|
// -- hunch_check_constraints ---------------------------------------------
|
|
831
840
|
server.registerTool("hunch_check_constraints", {
|
|
832
841
|
title: "Check invariants in scope",
|
|
833
|
-
description: "Return constraints whose scope matches a glob/path, sorted by severity. Call this BEFORE editing code to avoid breaking intentional invariants.",
|
|
842
|
+
description: "Return constraints whose scope matches a glob/path, sorted by severity. Call this BEFORE editing code to avoid breaking intentional invariants. Returns each constraint's id, severity, enforcement, statement, and rationale. Not for who-depends-on-this (hunch_get_dependents) or invariants reachable only through dependents (hunch_blast_radius).",
|
|
834
843
|
inputSchema: { scope: z.string().describe("A path or glob, e.g. src/auth/** or src/auth/session.ts") },
|
|
835
844
|
}, async ({ scope }) => {
|
|
836
845
|
const cons = store.checkConstraints(scope);
|
|
@@ -842,7 +851,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
842
851
|
// -- hunch_get_dependents -------------------------------------------------
|
|
843
852
|
server.registerTool("hunch_get_dependents", {
|
|
844
853
|
title: "Blast radius (transitive dependents)",
|
|
845
|
-
description: "Return everything that transitively depends on a symbol/component (callers + dependent components) so a change's blast radius is known before editing.",
|
|
854
|
+
description: "Return everything that transitively depends on a symbol/component (callers + dependent components) so a change's blast radius is known before editing. Returns dependents nearest first with depth and edge kind. Not for the invariants those dependents carry (hunch_blast_radius) or constraints on the target itself (hunch_check_constraints).",
|
|
846
855
|
inputSchema: { symbol: z.string().describe("A symbol id, symbol name, or file path.") },
|
|
847
856
|
}, async ({ symbol }) => {
|
|
848
857
|
const matches = resolveSymbols(store, symbol);
|
|
@@ -863,7 +872,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
863
872
|
// -- hunch_blast_radius (dependents + near-violations) --------------------
|
|
864
873
|
server.registerTool("hunch_blast_radius", {
|
|
865
874
|
title: "Blast radius + near-violations for a file",
|
|
866
|
-
description: "Given a file you're about to change, return its dependency blast radius (files whose code depends on it) AND any invariants reached THROUGH that radius — 'near-violations' you could break indirectly without touching their own scope. Call before editing a widely-depended-on file. Mirrors `hunch check --blast`.",
|
|
875
|
+
description: "Given a file you're about to change, return its dependency blast radius (files whose code depends on it) AND any invariants reached THROUGH that radius — 'near-violations' you could break indirectly without touching their own scope. Call before editing a widely-depended-on file. Mirrors `hunch check --blast`. Not for a bare dependent list (hunch_get_dependents) or constraints scoped to the target alone (hunch_check_constraints).",
|
|
867
876
|
inputSchema: { target: z.string().describe("A file path (e.g. src/auth/jwt.ts) or symbol.") },
|
|
868
877
|
}, async ({ target }) => {
|
|
869
878
|
const parts = [];
|
|
@@ -917,7 +926,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
917
926
|
// -- hunch_change_proof (exact-revision semantic evidence) ----------------
|
|
918
927
|
server.registerTool("hunch_change_proof", {
|
|
919
928
|
title: "Derive a sealed semantic proof for an exact change",
|
|
920
|
-
description: "Bind an exact committed Git transition to its change identity, Project DNA, base/result semantic graphs, current decisions and constraints, blast radius, conformance, guard verdict, and explicit gaps. Read-only and deterministic; grants no execution, CI, deployment, merge, ranking, promotion, or policy authority.",
|
|
929
|
+
description: "Bind an exact committed Git transition to its change identity, Project DNA, base/result semantic graphs, current decisions and constraints, blast radius, conformance, guard verdict, and explicit gaps. Read-only and deterministic; grants no execution, CI, deployment, merge, ranking, promotion, or policy authority. Needs two committed refs. Not for a verdict on staged work (hunch_merge_verdict) or an impact map (hunch_pr_impact).",
|
|
921
930
|
inputSchema: {
|
|
922
931
|
base_ref: z.string().min(1).max(1_024).describe("Base commit or ref for the exact tree transition."),
|
|
923
932
|
result_ref: z.string().min(1).max(1_024).optional().describe("Result commit or ref (default HEAD)."),
|
|
@@ -1015,7 +1024,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
1015
1024
|
// -- hunch_context (surgical retrieval) -----------------------------------
|
|
1016
1025
|
server.registerTool("hunch_context", {
|
|
1017
1026
|
title: "Assemble the minimal relevant Hunch slice for a task",
|
|
1018
|
-
description: "Given a file, symbol, or task phrase you're about to work on, return the MINIMAL relevant memory — invariants to preserve, decisions explaining the design, bug history not to reintroduce, and the blast radius — as a compact brief. Call this FIRST when starting work on something. A task phrase that resolves to no file/symbol falls back to the closest graph matches.",
|
|
1027
|
+
description: "Given a file, symbol, or task phrase you're about to work on, return the MINIMAL relevant memory — invariants to preserve, decisions explaining the design, bug history not to reintroduce, and the blast radius — as a compact brief. Call this FIRST when starting work on something. A task phrase that resolves to no file/symbol falls back to the closest graph matches. Returns a budgeted brief plus a delivery receipt. Not for exhaustive rationale on one file (hunch_why) or keyword search (hunch_query).",
|
|
1019
1028
|
inputSchema: {
|
|
1020
1029
|
target: z.string().describe("A file path, symbol, or task phrase you're about to work on."),
|
|
1021
1030
|
budget_tokens: z.number().optional().describe("Rough token budget for the brief (default 1500)."),
|
|
@@ -1026,7 +1035,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
1026
1035
|
}, async ({ target, budget_tokens, profile, as_of }, extra) => {
|
|
1027
1036
|
const asOf = as_of ? asOfDate(as_of, root) : undefined;
|
|
1028
1037
|
if (as_of && !asOf)
|
|
1029
|
-
return
|
|
1038
|
+
return invalid(`Could not resolve as_of "${as_of}" to a commit.`);
|
|
1030
1039
|
const ctx = store.assembleContext(target, budget_tokens ?? 1500, { asOf });
|
|
1031
1040
|
let dnaSupplement = null;
|
|
1032
1041
|
try {
|
|
@@ -1207,7 +1216,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
1207
1216
|
try {
|
|
1208
1217
|
const decision = store.advisoryRecs("decisions").find((candidate) => candidate.id === decision_id);
|
|
1209
1218
|
if (!decision)
|
|
1210
|
-
return
|
|
1219
|
+
return invalid(`Imported ADR ${decision_id} is not present in the current advisory memory home.`);
|
|
1211
1220
|
const reviewed = applyImportedAdrReview(decision, {
|
|
1212
1221
|
disposition,
|
|
1213
1222
|
expectedSourceHash: expected_source_hash,
|
|
@@ -1275,7 +1284,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
1275
1284
|
// -- hunch_capture_decision (decision-grounding: the grilling front door) --
|
|
1276
1285
|
server.registerTool("hunch_capture_decision", {
|
|
1277
1286
|
title: "Capture a decision (grilling interview)",
|
|
1278
|
-
description: "Start a decision-capture interview: returns the grilling protocol (interrogate ONE question at a time until the decision tree is resolved) plus a capture-session token. Grill the human, then commit via hunch_record_decision with the token + confirmed topic. Use for '/capture', 'record this decision', 'grill me on this'. The token proves the write is the tail of an interview, not a silent guess.",
|
|
1287
|
+
description: "Start a decision-capture interview: returns the grilling protocol (interrogate ONE question at a time until the decision tree is resolved) plus a capture-session token. Grill the human, then commit via hunch_record_decision with the token + confirmed topic. Use for '/capture', 'record this decision', 'grill me on this'. The token proves the write is the tail of an interview, not a silent guess. Returns the protocol text and the token; it writes nothing. Not for corrections (hunch_record_correction) or observations (hunch_record_finding).",
|
|
1279
1288
|
inputSchema: {
|
|
1280
1289
|
topic: z.string().optional().describe("proposed topic anchor (confirm with the human before committing)"),
|
|
1281
1290
|
seed: z.string().optional().describe("what the decision is about, to focus the first question"),
|
|
@@ -1329,7 +1338,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
1329
1338
|
// -- hunch_record_decision (write-back) -----------------------------------
|
|
1330
1339
|
server.registerTool("hunch_record_decision", {
|
|
1331
1340
|
title: "Record a decision (write-back)",
|
|
1332
|
-
description: "Persist a new Decision (ADR) into Hunch with provenance. Use after making a non-trivial design choice so future sessions are grounded in it. Set private:true to keep a SENSITIVE decision out of a (possibly public) repo — it is written to the HUNCH_PRIVATE_DIR overlay store and stays queryable locally, never committed here.",
|
|
1341
|
+
description: "Persist a new Decision (ADR) into Hunch with provenance. Use after making a non-trivial design choice so future sessions are grounded in it. Set private:true to keep a SENSITIVE decision out of a (possibly public) repo — it is written to the HUNCH_PRIVATE_DIR overlay store and stays queryable locally, never committed here. Returns the stored id, home, and status. Not for a rule the agent must obey (hunch_record_correction) or an observation with no choice made (hunch_record_finding). Errors are classed by prefix: 'Refused:' means a gate held (resolve it, do not retry), 'Invalid:' means fix the arguments, 'Failed to' means internal.",
|
|
1333
1342
|
inputSchema: {
|
|
1334
1343
|
decision: z.object({
|
|
1335
1344
|
title: z.string(),
|
|
@@ -1412,7 +1421,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
1412
1421
|
// displaced by a differently-identified record, vouched or not.
|
|
1413
1422
|
const conflictsWithHuman = curated && !sameHumanIdentity && !(gated && !existingIsHuman);
|
|
1414
1423
|
if (conflictsWithHuman) {
|
|
1415
|
-
return
|
|
1424
|
+
return refused(`Decision id ${id} already identifies a different curated decision: ` +
|
|
1416
1425
|
`"${existing.title}"${existing.topic ? ` (topic "${existing.topic}")` : ""}. ` +
|
|
1417
1426
|
`Refusing to overwrite it with "${decision.title}"${decision.topic ? ` (topic "${decision.topic}")` : ""}. ` +
|
|
1418
1427
|
"Record the additional decision without commit, or reuse the incumbent topic/title when refining the same decision.");
|
|
@@ -1484,7 +1493,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
1484
1493
|
const crossStore = decision.supersedes && !willClose
|
|
1485
1494
|
? ` (note: supersedes:"${decision.supersedes}" is not in the ${home} store this write lands in, so it can't be closed from here)`
|
|
1486
1495
|
: "";
|
|
1487
|
-
return
|
|
1496
|
+
return refused(`Topic "${rec.topic}" already has a live decision: ${list}.${crossStore} ` +
|
|
1488
1497
|
`Hunch will not create a second current decision for one topic. Resolve it: ` +
|
|
1489
1498
|
`re-record with supersedes:<id> to replace it (linked, same store), pick a distinct topic to split, or discard this capture.`);
|
|
1490
1499
|
}
|
|
@@ -1549,7 +1558,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
1549
1558
|
// -- hunch_record_correction (write-back: "Never Twice") ------------------
|
|
1550
1559
|
server.registerTool("hunch_record_correction", {
|
|
1551
1560
|
title: "Capture a correction as an enforced constraint (Never Twice)",
|
|
1552
|
-
description: "When a human corrects the agent ('no, do it this way' / 'never call X here'), persist that correction as a first-class, SCOPED Constraint with provenance — so the pre-edit hook and the CI Constraint Guard hold EVERY assistant to it from now on, instead of it being forgotten next session. Writes to the shared .hunch/ graph (client-agnostic). Set severity:'blocking' only when the human said never/must; set applies_to_all:true only when the rule is genuinely repo-wide (otherwise it is scoped to scope_hint_file).",
|
|
1561
|
+
description: "When a human corrects the agent ('no, do it this way' / 'never call X here'), persist that correction as a first-class, SCOPED Constraint with provenance — so the pre-edit hook and the CI Constraint Guard hold EVERY assistant to it from now on, instead of it being forgotten next session. Writes to the shared .hunch/ graph (client-agnostic). Set severity:'blocking' only when the human said never/must; set applies_to_all:true only when the rule is genuinely repo-wide (otherwise it is scoped to scope_hint_file). Returns the constraint id, scope, and what it now enforces. Not for a design choice with alternatives (hunch_record_decision) or an observed gap with no rule yet (hunch_record_finding).",
|
|
1553
1562
|
inputSchema: {
|
|
1554
1563
|
rule: z.string().describe("The invariant in the human's words, e.g. \"never call the pay-per-token API here\"."),
|
|
1555
1564
|
scope_hint_file: z.string().optional().describe("A file the correction was about; scopes the constraint to it (the conservative default). Prefer a REPO-RELATIVE path (src/foo.ts); an absolute path is relativized against the repo root, and one outside the repo is discarded rather than scoped to a path that could never match."),
|
|
@@ -1565,7 +1574,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
1565
1574
|
}, async (input) => {
|
|
1566
1575
|
try {
|
|
1567
1576
|
if (!input.rule || !input.rule.trim())
|
|
1568
|
-
return
|
|
1577
|
+
return invalid("rule is required — state the invariant in plain words.");
|
|
1569
1578
|
// root: relativizes an ABSOLUTE scope_hint_file. Agents naturally send absolute
|
|
1570
1579
|
// paths (edit-tool payloads and MCP roots are absolute) and every consumer matches
|
|
1571
1580
|
// repo-relative — without this the rule would be blocking-but-inert and would leak
|
|
@@ -1581,7 +1590,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
1581
1590
|
const home = store.captureHome(!!input.private);
|
|
1582
1591
|
if (home === "public" && rec.source_decision && !store.json.get("decisions", rec.source_decision)) {
|
|
1583
1592
|
const location = store.getPrivateRec("decisions", rec.source_decision) ? "exists only in the private overlay" : "does not exist in the public home";
|
|
1584
|
-
return
|
|
1593
|
+
return refused(`source decision ${rec.source_decision} ${location}; refusing to record public correction ${rec.id}.`);
|
|
1585
1594
|
}
|
|
1586
1595
|
const existing = home === "private" ? store.getPrivateRec("constraints", rec.id) : store.json.get("constraints", rec.id);
|
|
1587
1596
|
// Same cross-home twin guard as the decision path above.
|
|
@@ -1625,7 +1634,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
1625
1634
|
// -- hunch_record_finding (write-back: observations, no diff) ---------------
|
|
1626
1635
|
server.registerTool("hunch_record_finding", {
|
|
1627
1636
|
title: "Record a finding (an observation with no code change)",
|
|
1628
|
-
description: "Persist an OBSERVATION into Hunch — audited knowledge with no diff: an audit that surfaced a gap (e.g. queries missing tenant scoping), a measured number, a vendor/platform fact, an incident with no code fix. The anchor is a date + evidence, not a commit. Advisory: it grounds future edits to the affected files/symbols (pre-edit hook + hunch_context) and is listed by hunch_findings; it never blocks. Re-record the SAME title to update triage (e.g. triage:'resolved' + resolved_commit once fixed). If the finding is a violation of a rule that ISN'T recorded yet, record the rule first (hunch_record_correction) and link it via violates_constraint.",
|
|
1637
|
+
description: "Persist an OBSERVATION into Hunch — audited knowledge with no diff: an audit that surfaced a gap (e.g. queries missing tenant scoping), a measured number, a vendor/platform fact, an incident with no code fix. The anchor is a date + evidence, not a commit. Advisory: it grounds future edits to the affected files/symbols (pre-edit hook + hunch_context) and is listed by hunch_findings; it never blocks. Re-record the SAME title to update triage (e.g. triage:'resolved' + resolved_commit once fixed). If the finding is a violation of a rule that ISN'T recorded yet, record the rule first (hunch_record_correction) and link it via violates_constraint. Returns the finding id, triage, and the files it now grounds. Not for a rule to enforce (hunch_record_correction) or a choice between alternatives (hunch_record_decision).",
|
|
1629
1638
|
inputSchema: {
|
|
1630
1639
|
finding: z.object({
|
|
1631
1640
|
title: z.string().describe("stable one-line name — re-recording the same title updates the finding"),
|
|
@@ -1646,16 +1655,16 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
1646
1655
|
}, async ({ finding }) => {
|
|
1647
1656
|
try {
|
|
1648
1657
|
if (!finding.title.trim())
|
|
1649
|
-
return
|
|
1658
|
+
return invalid("title is required.");
|
|
1650
1659
|
if (!finding.observation.trim())
|
|
1651
|
-
return
|
|
1660
|
+
return invalid("observation is required — state what you saw.");
|
|
1652
1661
|
const id = findingId(finding.title);
|
|
1653
1662
|
const home = store.captureHome(!!finding.private);
|
|
1654
1663
|
const existing = home === "private" ? store.getPrivateRec("findings", id) : store.json.get("findings", id);
|
|
1655
1664
|
const now = new Date().toISOString();
|
|
1656
1665
|
const triage = finding.triage ?? existing?.triage ?? "open";
|
|
1657
1666
|
if (triage === "resolved" && !(finding.resolved_commit ?? existing?.resolved_commit)) {
|
|
1658
|
-
return
|
|
1667
|
+
return refused(`refusing to mark ${id} resolved without resolved_commit — a resolution claim needs the fixing commit (or use triage:'stale' if it no longer applies).`);
|
|
1659
1668
|
}
|
|
1660
1669
|
const rec = {
|
|
1661
1670
|
id,
|
|
@@ -1825,7 +1834,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
1825
1834
|
// -- hunch_findings (read: the open-observations ledger) --------------------
|
|
1826
1835
|
server.registerTool("hunch_findings", {
|
|
1827
1836
|
title: "Open findings for a scope",
|
|
1828
|
-
description: "List LIVE findings (observed gaps/debt with no fix yet — triage open/accepted-risk/scheduled) concerning a file, glob, or symbol; omit scope for the whole ledger. Call before planning work in an area to inherit past audits instead of re-discovering them. Advisory; resolved/stale findings are excluded unless all:true.",
|
|
1837
|
+
description: "List LIVE findings (observed gaps/debt with no fix yet — triage open/accepted-risk/scheduled) concerning a file, glob, or symbol; omit scope for the whole ledger. Call before planning work in an area to inherit past audits instead of re-discovering them. Advisory; resolved/stale findings are excluded unless all:true. Not for invariants (hunch_check_constraints) or bug history (hunch_bug_lineage): findings are observations, never rules.",
|
|
1829
1838
|
inputSchema: {
|
|
1830
1839
|
scope: z.string().optional().describe("a path, glob, or symbol (e.g. src/procs/** or dbo.GetOrders); omit for all"),
|
|
1831
1840
|
all: z.boolean().optional().describe("include resolved/stale findings (the full history)"),
|
|
@@ -1856,7 +1865,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
1856
1865
|
}, async ({ constraint_id, public_only, private_only, include_artifacts }) => {
|
|
1857
1866
|
try {
|
|
1858
1867
|
if (public_only && private_only)
|
|
1859
|
-
return
|
|
1868
|
+
return invalid("Choose only one of public_only or private_only.");
|
|
1860
1869
|
// Resolve the correction's exact home before any writes. Overlay-first is
|
|
1861
1870
|
// the same selection contract as ConstitutionService.upgradeCorrection;
|
|
1862
1871
|
// deriving this later from a policy id is unsafe when legacy public and
|
|
@@ -1915,7 +1924,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
1915
1924
|
// -- hunch_merge_verdict (Causal Merge Verdict — read-only, client-agnostic) --
|
|
1916
1925
|
server.registerTool("hunch_merge_verdict", {
|
|
1917
1926
|
title: "Causal merge verdict: is this change safe against the recorded WHY?",
|
|
1918
|
-
description: "Before opening or merging a PR, replay a diff against engineering memory and return ONE verdict — BLOCK / WARN / PASS. For each invariant DIRECTLY in scope it cites WHY the guard exists (the decision that motivated it + the bug whose root cause spawned it); it also lists invariants reached via blast radius (near, advisory), any deliberately-retired code the diff re-introduces, and symbols the diff adds that are already defined elsewhere in the graph (possible re-implementation/sprawl, advisory). Deterministic, no LLM. Omit base, commit, and working to check STAGED changes; pass working:true for all local changes, base (e.g. origin/main) for a PR range, or commit for a single commit. Call this before merging a widely-scoped change.",
|
|
1927
|
+
description: "Before opening or merging a PR, replay a diff against engineering memory and return ONE verdict — BLOCK / WARN / PASS. For each invariant DIRECTLY in scope it cites WHY the guard exists (the decision that motivated it + the bug whose root cause spawned it); it also lists invariants reached via blast radius (near, advisory), any deliberately-retired code the diff re-introduces, and symbols the diff adds that are already defined elsewhere in the graph (possible re-implementation/sprawl, advisory). Deterministic, no LLM. Omit base, commit, and working to check STAGED changes; pass working:true for all local changes, base (e.g. origin/main) for a PR range, or commit for a single commit. Call this before merging a widely-scoped change. Not for an advisory impact map (hunch_pr_impact), intent erosion with no diff (hunch_conformance), or a sealed proof of one committed transition (hunch_change_proof).",
|
|
1919
1928
|
inputSchema: {
|
|
1920
1929
|
base: z.string().optional().describe("Diff against this base ref (e.g. origin/main) — for a PR/branch."),
|
|
1921
1930
|
commit: z.string().optional().describe("Diff a single commit (sha/ref). Omit base AND commit to check staged changes."),
|
|
@@ -1924,11 +1933,11 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
1924
1933
|
}, async ({ base, commit, working }) => {
|
|
1925
1934
|
try {
|
|
1926
1935
|
if ([base, commit, working].filter(Boolean).length > 1)
|
|
1927
|
-
return
|
|
1936
|
+
return invalid("Pass at most one of base/commit/working (omit all to check staged changes).");
|
|
1928
1937
|
if (base && !revExists(base, root))
|
|
1929
|
-
return
|
|
1938
|
+
return invalid(`base ref "${base}" does not resolve (in CI, fetch the base branch first).`);
|
|
1930
1939
|
if (commit && !revExists(commit, root))
|
|
1931
|
-
return
|
|
1940
|
+
return invalid(`commit "${commit}" does not resolve.`);
|
|
1932
1941
|
const files = commit ? commitFiles(commit, root) : base ? rangeFiles(base, root) : working ? workingFiles(root) : stagedFiles(root);
|
|
1933
1942
|
const scope = commit ? `commit ${commit}` : base ? `${base}..HEAD` : working ? "working changes" : "staged changes";
|
|
1934
1943
|
if (!files.length)
|
|
@@ -1950,7 +1959,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
1950
1959
|
// -- hunch_structure (graph-served orientation — the anti-grep) ------------
|
|
1951
1960
|
server.registerTool("hunch_structure", {
|
|
1952
1961
|
title: "The indexed shape of the repo / a dir / a file / a symbol",
|
|
1953
|
-
description: "Orient WITHOUT grep/glob rounds: the graph already holds the repo's structure. No target → repo map (components + directories by symbol weight). A directory → its files with their symbols. A file → its outline (symbols, fan-in/out, callers). An exact symbol name → its definition site(s) with one-hop neighbors. Call this FIRST when exploring unfamiliar code — it tells you exactly which file to read, instead of searching for it.",
|
|
1962
|
+
description: "Orient WITHOUT grep/glob rounds: the graph already holds the repo's structure. No target → repo map (components + directories by symbol weight). A directory → its files with their symbols. A file → its outline (symbols, fan-in/out, callers). An exact symbol name → its definition site(s) with one-hop neighbors. Call this FIRST when exploring unfamiliar code — it tells you exactly which file to read, instead of searching for it. Returns shape only (files, symbols, one-hop neighbors), never why. Not for rationale (hunch_why) or memory search (hunch_query).",
|
|
1954
1963
|
inputSchema: {
|
|
1955
1964
|
target: z.string().optional().describe("A directory, file path, or exact symbol name. Omit for the repo map."),
|
|
1956
1965
|
},
|
|
@@ -1958,7 +1967,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
1958
1967
|
// -- hunch_pr_impact (read-only impact surface — advisory, never gates) ----
|
|
1959
1968
|
server.registerTool("hunch_pr_impact", {
|
|
1960
1969
|
title: "PR impact: the dependency + memory surface of a change",
|
|
1961
|
-
description: "Given a change (staged, working tree, a branch vs base, or a single commit), return its IMPACT SURFACE: the files whose code transitively depends on the changed files, the invariants directly in scope and those reached via blast radius, and the recorded decisions concerning the touched files. Read-only and advisory — use hunch_merge_verdict for the gate. Call before review to know what a PR can break and which recorded intent it touches. Omit base, commit, and working for staged changes.",
|
|
1970
|
+
description: "Given a change (staged, working tree, a branch vs base, or a single commit), return its IMPACT SURFACE: the files whose code transitively depends on the changed files, the invariants directly in scope and those reached via blast radius, and the recorded decisions concerning the touched files. Read-only and advisory — use hunch_merge_verdict for the gate. Call before review to know what a PR can break and which recorded intent it touches. Omit base, commit, and working for staged changes. Not for a verdict (hunch_merge_verdict) or a sealed proof (hunch_change_proof).",
|
|
1962
1971
|
inputSchema: {
|
|
1963
1972
|
base: z.string().optional().describe("Diff against this base ref (e.g. origin/main) — for a PR/branch."),
|
|
1964
1973
|
commit: z.string().optional().describe("Impact of a single commit (sha/ref). Omit base AND commit for staged changes."),
|
|
@@ -1967,11 +1976,11 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
1967
1976
|
}, async ({ base, commit, working }) => {
|
|
1968
1977
|
try {
|
|
1969
1978
|
if ([base, commit, working].filter(Boolean).length > 1)
|
|
1970
|
-
return
|
|
1979
|
+
return invalid("Pass at most one of base/commit/working (omit all for staged changes).");
|
|
1971
1980
|
if (base && !revExists(base, root))
|
|
1972
|
-
return
|
|
1981
|
+
return invalid(`base ref "${base}" does not resolve (in CI, fetch the base branch first).`);
|
|
1973
1982
|
if (commit && !revExists(commit, root))
|
|
1974
|
-
return
|
|
1983
|
+
return invalid(`commit "${commit}" does not resolve.`);
|
|
1975
1984
|
const files = commit ? commitFiles(commit, root) : base ? rangeFiles(base, root) : working ? workingFiles(root) : stagedFiles(root);
|
|
1976
1985
|
const scope = commit ? `commit ${commit}` : base ? `${base}..HEAD` : working ? "working changes" : "staged changes";
|
|
1977
1986
|
if (!files.length)
|
|
@@ -1996,9 +2005,9 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
1996
2005
|
const A = store.resolveNodeIds(from);
|
|
1997
2006
|
const B = store.resolveNodeIds(to);
|
|
1998
2007
|
if (!A.length)
|
|
1999
|
-
return
|
|
2008
|
+
return invalid(`"${from}" resolves to no indexed symbol/component (is the repo indexed?).`);
|
|
2000
2009
|
if (!B.length)
|
|
2001
|
-
return
|
|
2010
|
+
return invalid(`"${to}" resolves to no indexed symbol/component.`);
|
|
2002
2011
|
let best = null;
|
|
2003
2012
|
for (const a of A.slice(0, 4)) {
|
|
2004
2013
|
for (const b of B.slice(0, 4)) {
|
|
@@ -2024,9 +2033,9 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
2024
2033
|
try {
|
|
2025
2034
|
const b = base ?? "main";
|
|
2026
2035
|
if (!candidates.length)
|
|
2027
|
-
return
|
|
2036
|
+
return invalid("Pass at least one candidate ref.");
|
|
2028
2037
|
if (!revExists(b, root))
|
|
2029
|
-
return
|
|
2038
|
+
return invalid(`base ref "${b}" does not resolve (in CI, fetch it first).`);
|
|
2030
2039
|
const ranked = compareCandidates(store, root, b, candidates);
|
|
2031
2040
|
const icon = (v) => (v === "pass" ? "✅" : v === "warn" ? "⚠" : "⛔");
|
|
2032
2041
|
const lines = ranked.map((c, i) => c.error
|
|
@@ -2244,7 +2253,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
2244
2253
|
// -- hunch_conformance ----------------------------------------------------
|
|
2245
2254
|
server.registerTool("hunch_conformance", {
|
|
2246
2255
|
title: "Does the code still satisfy the recorded intent?",
|
|
2247
|
-
description: "Intent-conformance (the inversion of a normal guard): for every in-force decision carrying a conformance predicate, deterministically verify the CODE still satisfies its intent over the dependency graph — e.g. 'pay still reaches verifySession'. Returns the violations: intent the code has silently drifted away from, with NO diff required. Run before a refactor or merge to catch intent erosion a diff-only check can't see.",
|
|
2256
|
+
description: "Intent-conformance (the inversion of a normal guard): for every in-force decision carrying a conformance predicate, deterministically verify the CODE still satisfies its intent over the dependency graph — e.g. 'pay still reaches verifySession'. Returns the violations: intent the code has silently drifted away from, with NO diff required. Run before a refactor or merge to catch intent erosion a diff-only check can't see. Returns the violation list. Not for a diff-scoped verdict (hunch_merge_verdict) or an impact map (hunch_pr_impact).",
|
|
2248
2257
|
inputSchema: {},
|
|
2249
2258
|
}, async () => {
|
|
2250
2259
|
try {
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Replay determinism — nuryel.replay/1.
|
|
3
|
+
*
|
|
4
|
+
* The property: a partition's current state is a pure function of its change ledger. The
|
|
5
|
+
* git-tracked JSON records stay the source of truth (the ledger proves them, it never replaces
|
|
6
|
+
* them); this module FOLDS the ledger into the state it implies — for every record the ledger
|
|
7
|
+
* names, the hash of the record on file after its last event — and compares that, hash for hash,
|
|
8
|
+
* to the records actually stored. `stateHash` is sha256 over the canonical form, so equal hashes
|
|
9
|
+
* are byte-equal canonical records: "same ledger, same state" is a check, not a claim.
|
|
10
|
+
*
|
|
11
|
+
* What counts as a divergence:
|
|
12
|
+
* missing-record the ledger says the record exists, no file holds it
|
|
13
|
+
* hash-drift the record on file is not the record the ledger's last event wrote
|
|
14
|
+
* orphan-record a state record in the partition that the ledger never saw (a write that
|
|
15
|
+
* bypassed the contract, or a crash between "record written" and "event
|
|
16
|
+
* appended" — the failure changeLedger promised the next writer could detect)
|
|
17
|
+
* idempotency-drift an idempotency entry whose hash disagrees with the ledger at its seq
|
|
18
|
+
* Legacy facets (decisions, constraints, bugs, findings) are also written by paths older than the
|
|
19
|
+
* contract (captures, supersede, adopt-drafts), so their drift is reported as `legacy-drift` —
|
|
20
|
+
* visible, never a failure — and their orphans are not sought.
|
|
21
|
+
*
|
|
22
|
+
* Compaction keeps replay equivalence: events below the floor are gone, but the idempotency table
|
|
23
|
+
* is kept whole, so a record whose last event was compacted is still verified against its newest
|
|
24
|
+
* idempotency entry; a record with neither is `unverifiable`, counted, never a failure.
|
|
25
|
+
*/
|
|
26
|
+
import { ENTITY_KINDS } from "../core/types.js";
|
|
27
|
+
import { ScopeSchema, scopePath, stateHash } from "../core/stateContract.js";
|
|
28
|
+
import { readLedger } from "./changeLedger.js";
|
|
29
|
+
import { partitionOf, stateHomeFor } from "./stateBinding.js";
|
|
30
|
+
export const REPLAY_SCHEMA_VERSION = "nuryel.replay/1";
|
|
31
|
+
/** The facets the contract is the ONLY writer of; a record here without a ledger event is an orphan. */
|
|
32
|
+
export const STATE_ONLY_FACETS = ["receipts", "commitments", "derived", "entities", "relationships"];
|
|
33
|
+
const LEGACY_FACETS = new Set(["decisions", "constraints", "bugs", "findings"]);
|
|
34
|
+
/** Fold a ledger into the state it implies: the last event per record, in seq order. */
|
|
35
|
+
export function foldLedger(ledger) {
|
|
36
|
+
const out = new Map();
|
|
37
|
+
for (const e of ledger.events) {
|
|
38
|
+
out.set(e.record_id, { facet: e.facet, record_id: e.record_id, record_hash: e.record_hash, change: e.change, seq: e.seq });
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
const snapshotHash = (entries) => stateHash([...entries].sort((a, b) => (a.record_id < b.record_id ? -1 : a.record_id > b.record_id ? 1 : 0)).map((e) => [e.facet, e.record_id, e.record_hash]));
|
|
43
|
+
const recordScope = (record, own) => {
|
|
44
|
+
const parsed = ScopeSchema.safeParse(record.scope);
|
|
45
|
+
return parsed.success ? parsed.data : own;
|
|
46
|
+
};
|
|
47
|
+
const windowClosed = (facet, record) => (facet === "derived" && record.state !== "current") || ("valid_to" in record && record.valid_to !== null) || record.lifecycle === "retired";
|
|
48
|
+
/** Verify that a partition's stored records are exactly what its ledger implies. Read-only. */
|
|
49
|
+
export function verifyReplay(store, scope) {
|
|
50
|
+
const own = partitionOf(store);
|
|
51
|
+
const { home, hunchDir } = stateHomeFor(store, scope);
|
|
52
|
+
const ledger = readLedger(hunchDir, scope);
|
|
53
|
+
const fold = foldLedger(ledger);
|
|
54
|
+
const divergences = [];
|
|
55
|
+
const stored = [];
|
|
56
|
+
let verified = 0;
|
|
57
|
+
let verifiedByIdempotency = 0;
|
|
58
|
+
let unverifiable = 0;
|
|
59
|
+
let legacyChecked = 0;
|
|
60
|
+
const onFile = (facet, id) => ENTITY_KINDS.includes(facet)
|
|
61
|
+
? store.recsInHome(facet, home).find((r) => r.id === id)
|
|
62
|
+
: undefined;
|
|
63
|
+
// 1. Every record the ledger names must be on file with the hash its last event wrote.
|
|
64
|
+
for (const entry of fold.values()) {
|
|
65
|
+
const record = onFile(entry.facet, entry.record_id);
|
|
66
|
+
const legacy = LEGACY_FACETS.has(entry.facet);
|
|
67
|
+
if (legacy)
|
|
68
|
+
legacyChecked++;
|
|
69
|
+
if (!record) {
|
|
70
|
+
stored.push({ ...entry, record_hash: "" });
|
|
71
|
+
divergences.push({ kind: legacy ? "legacy-drift" : "missing-record", facet: entry.facet, record_id: entry.record_id, seq: entry.seq, expected_hash: entry.record_hash, actual_hash: null, detail: `ledger seq ${entry.seq} ${entry.change} ${entry.record_id}; no ${entry.facet} record on file in ${scopePath(scope)}` });
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
const actual = stateHash(record);
|
|
75
|
+
stored.push({ ...entry, record_hash: actual });
|
|
76
|
+
if (actual === entry.record_hash) {
|
|
77
|
+
verified++;
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
divergences.push({ kind: legacy ? "legacy-drift" : "hash-drift", facet: entry.facet, record_id: entry.record_id, seq: entry.seq, expected_hash: entry.record_hash, actual_hash: actual, detail: `${entry.record_id} on file hashes ${actual}; the ledger's last event (seq ${entry.seq}, ${entry.change}) wrote ${entry.record_hash}` });
|
|
81
|
+
}
|
|
82
|
+
// 2. The idempotency table agrees with the ledger at each entry's seq (what a replay returns
|
|
83
|
+
// is what the ledger said was on file then). Entries below the floor are checked against
|
|
84
|
+
// the file directly when they are the record's newest entry — that is how a compacted
|
|
85
|
+
// record stays verifiable.
|
|
86
|
+
const newestEntryFor = new Map();
|
|
87
|
+
for (const entry of Object.values(ledger.idempotency)) {
|
|
88
|
+
const prev = newestEntryFor.get(entry.record_id);
|
|
89
|
+
if (!prev || entry.seq > prev.seq)
|
|
90
|
+
newestEntryFor.set(entry.record_id, entry);
|
|
91
|
+
const eventsUpTo = ledger.events.filter((e) => e.record_id === entry.record_id && e.seq <= entry.seq);
|
|
92
|
+
const at = eventsUpTo[eventsUpTo.length - 1];
|
|
93
|
+
if (at && at.record_hash !== entry.record_hash && (entry.payload_hash === undefined || at.record_hash !== entry.payload_hash)) {
|
|
94
|
+
divergences.push({ kind: "idempotency-drift", facet: entry.facet, record_id: entry.record_id, seq: entry.seq, expected_hash: at.record_hash, actual_hash: entry.record_hash, detail: `idempotency entry at seq ${entry.seq} holds ${entry.record_hash}; the ledger event at seq ${at.seq} wrote ${at.record_hash}` });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
// 3. Every state record in the partition must be one the ledger saw — or, after compaction,
|
|
98
|
+
// one the idempotency table still names with the hash on file.
|
|
99
|
+
for (const facet of STATE_ONLY_FACETS) {
|
|
100
|
+
for (const record of store.recsInHome(facet, home)) {
|
|
101
|
+
if (scopePath(recordScope(record, own)) !== scopePath(scope))
|
|
102
|
+
continue;
|
|
103
|
+
const id = String(record.id);
|
|
104
|
+
if (fold.has(id))
|
|
105
|
+
continue;
|
|
106
|
+
const actual = stateHash(record);
|
|
107
|
+
const entry = newestEntryFor.get(id);
|
|
108
|
+
if (entry) {
|
|
109
|
+
stored.push({ facet, record_id: id, record_hash: actual, change: "updated", seq: entry.seq });
|
|
110
|
+
if (entry.record_hash === actual) {
|
|
111
|
+
verifiedByIdempotency++;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
// The one change the contract makes WITHOUT an idempotency entry is closing a window on
|
|
115
|
+
// supersession (the `superseded` event carries the closed hash). With that event compacted
|
|
116
|
+
// away, a closed record is unverifiable; an OPEN record that differs is drift.
|
|
117
|
+
if (entry.seq <= ledger.floor_seq && windowClosed(facet, record)) {
|
|
118
|
+
unverifiable++;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
divergences.push({ kind: "hash-drift", facet, record_id: id, seq: entry.seq, expected_hash: entry.record_hash, actual_hash: actual, detail: `${id} on file hashes ${actual}; its newest idempotency entry (seq ${entry.seq}) holds ${entry.record_hash} and no event above the floor explains the change` });
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
divergences.push({ kind: "orphan-record", facet, record_id: id, seq: 0, expected_hash: null, actual_hash: actual, detail: `${facet} record ${id} is on file in ${scopePath(scope)} but the ledger never saw it (no event, no idempotency entry)` });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
// The two fingerprints cover the facets the contract owns; legacy facets are advisory.
|
|
128
|
+
const replayHash = snapshotHash([...fold.values()].filter((e) => !LEGACY_FACETS.has(e.facet)));
|
|
129
|
+
const storedHash = snapshotHash(stored.filter((s) => fold.has(s.record_id) && !LEGACY_FACETS.has(s.facet)));
|
|
130
|
+
const failing = divergences.some((d) => d.kind !== "legacy-drift");
|
|
131
|
+
return {
|
|
132
|
+
schema: REPLAY_SCHEMA_VERSION,
|
|
133
|
+
scope,
|
|
134
|
+
ledger: { head_seq: ledger.head_seq, floor_seq: ledger.floor_seq, events: ledger.events.length, idempotency_entries: Object.keys(ledger.idempotency).length },
|
|
135
|
+
replay_hash: replayHash,
|
|
136
|
+
stored_hash: storedHash,
|
|
137
|
+
records: { named_by_ledger: fold.size, verified, verified_by_idempotency: verifiedByIdempotency, unverifiable, legacy_checked: legacyChecked },
|
|
138
|
+
divergences,
|
|
139
|
+
ok: !failing && replayHash === storedHash,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
export function formatReplayReport(r) {
|
|
143
|
+
const lines = [
|
|
144
|
+
`${scopePath(r.scope)}: ${r.ok ? "replay OK" : "REPLAY DIVERGED"} — ledger head ${r.ledger.head_seq}, floor ${r.ledger.floor_seq}, ${r.ledger.events} event(s), ${r.ledger.idempotency_entries} idempotency entr${r.ledger.idempotency_entries === 1 ? "y" : "ies"}`,
|
|
145
|
+
` replay ${r.replay_hash}`,
|
|
146
|
+
` stored ${r.stored_hash}${r.replay_hash === r.stored_hash ? " (equal)" : " (DIFFERENT)"}`,
|
|
147
|
+
` records: ${r.records.named_by_ledger} named by the ledger, ${r.records.verified} verified hash for hash, ${r.records.verified_by_idempotency} verified through the idempotency table, ${r.records.unverifiable} unverifiable below the floor, ${r.records.legacy_checked} legacy`,
|
|
148
|
+
];
|
|
149
|
+
for (const d of r.divergences)
|
|
150
|
+
lines.push(` ${d.kind === "legacy-drift" ? "·" : "✗"} ${d.kind} ${d.facet}/${d.record_id}: ${d.detail}`);
|
|
151
|
+
return lines.join("\n");
|
|
152
|
+
}
|
|
153
|
+
//# sourceMappingURL=replay.js.map
|