@davesheffer/hunch 1.28.0 → 1.30.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 +29 -17
- package/dist/cli/index.js +122 -9
- package/dist/cli/reviewMemory.js +32 -0
- package/dist/cli/serve.js +44 -0
- package/dist/core/groundingLag.js +82 -0
- package/dist/core/reviewMemory.js +100 -0
- package/dist/core/stateContract.js +14 -0
- package/dist/core/stateRecords.js +42 -1
- package/dist/extractors/diff.js +26 -21
- package/dist/extractors/git.js +4 -0
- package/dist/extractors/languages.js +8 -0
- package/dist/integrations/hooks.js +41 -0
- package/dist/integrations/providers.js +8 -0
- package/dist/mcp/server.js +79 -49
- package/dist/store/changeLedger.js +5 -0
- package/dist/store/replay.js +153 -0
- package/dist/store/stateBinding.js +260 -18
- package/dist/synthesis/provider.js +6 -2
- package/dist/synthesis/synthesize.js +36 -15
- package/package.json +2 -2
- package/server.json +2 -2
- package/tooling/competitive-watch.mjs +1 -0
package/dist/mcp/server.js
CHANGED
|
@@ -13,8 +13,8 @@ import { z } from "zod";
|
|
|
13
13
|
import { hunchPaths, findRoot, toPosixTarget } from "../core/paths.js";
|
|
14
14
|
import { canonicalRootPath, resolveActiveRoot } from "./roots.js";
|
|
15
15
|
import { HunchStore } from "../store/hunchStore.js";
|
|
16
|
-
import { StateRefusal, SubscribeResponseSchema, capabilities, readState, recordsState, subscribeState, writeState } from "../store/stateBinding.js";
|
|
17
|
-
import { ReadRequestSchema, ReadResponseSchema, WriteRequestSchema, WriteResultSchema, SubscribeRequestSchema, RecordsRequestSchema, RecordsResponseSchema, STATE_READ_VERSION, STATE_WRITE_VERSION, STATE_SUBSCRIBE_VERSION, STATE_RECORDS_VERSION } from "../core/stateContract.js";
|
|
16
|
+
import { StateRefusal, SubscribeResponseSchema, capabilities, partitionOf, readState, recordsState, subscribeState, writeState } from "../store/stateBinding.js";
|
|
17
|
+
import { ReadRequestSchema, ReadResponseSchema, WriteRequestSchema, WriteResultSchema, SubscribeRequestSchema, RecordsRequestSchema, RecordsResponseSchema, STATE_READ_VERSION, STATE_WRITE_VERSION, STATE_SUBSCRIBE_VERSION, STATE_RECORDS_VERSION, stateHash } from "../core/stateContract.js";
|
|
18
18
|
import { selectEmbedder } from "../store/embedder.js";
|
|
19
19
|
import { decisionId, findingId } from "../core/ids.js";
|
|
20
20
|
import { buildCorrectionConstraint } from "../core/correction.js";
|
|
@@ -59,6 +59,14 @@ import { existsSync } from "node:fs";
|
|
|
59
59
|
import { join } from "node:path";
|
|
60
60
|
const ok = (text) => ({ content: [{ type: "text", text }] });
|
|
61
61
|
const err = (text) => ({ content: [{ type: "text", text }], isError: true });
|
|
62
|
+
/** Error classes as text prefixes — client-agnostic, no schema change, so any MCP client
|
|
63
|
+
* can pick its next move from the first word:
|
|
64
|
+
* Refused: … a gate held. Do not retry the same call; resolve the named conflict or ask a human.
|
|
65
|
+
* Invalid: … the arguments are wrong. Fix them and call again.
|
|
66
|
+
* Failed to … internal or environmental. One retry is reasonable.
|
|
67
|
+
* Transient states that say "retry" in their own words stay unprefixed. */
|
|
68
|
+
const refused = (text) => err(`Refused: ${text}`);
|
|
69
|
+
const invalid = (text) => err(`Invalid: ${text}`);
|
|
62
70
|
/** Shared by every auto-committing write tool (issue #20): the MCP `roots` protocol
|
|
63
71
|
* cannot see an agent-driven `cd`/EnterWorktree, so a stdio server's cached root
|
|
64
72
|
* never moves on its own — this is the client-agnostic fallback, resolved fresh on
|
|
@@ -700,17 +708,17 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
700
708
|
// root/store/route epoch for its complete execution.
|
|
701
709
|
const teamFileNow = !explicitOverlay && existsSync(teamFile);
|
|
702
710
|
if (teamFileNow !== teamAdvertised) {
|
|
703
|
-
return
|
|
711
|
+
return refused("The committed team-memory routing changed after this MCP process started. Reconnect Hunch before reading or writing memory.");
|
|
704
712
|
}
|
|
705
713
|
const currentTeamConfig = teamFileNow ? readTeamConfig(root) : null;
|
|
706
714
|
if (teamAdvertised && !matchesStartupTeamRoute()) {
|
|
707
|
-
return
|
|
715
|
+
return refused("The team-memory URL or branch changed after this MCP process started. Refusing the old graph; reconnect Hunch first.");
|
|
708
716
|
}
|
|
709
717
|
if (teamFileNow && (!currentTeamConfig
|
|
710
718
|
|| store.mode !== "shared"
|
|
711
719
|
|| !store.privateDir
|
|
712
720
|
|| !overlayMatchesTeamRemote(root, join(store.privateDir, "..")))) {
|
|
713
|
-
return
|
|
721
|
+
return refused("The committed team memory destination is invalid or no longer matches this process. Refusing the stale graph; reconnect Hunch first.");
|
|
714
722
|
}
|
|
715
723
|
if (store.mode === "shared" && store.privateDir) {
|
|
716
724
|
pullTeamMemory();
|
|
@@ -719,7 +727,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
719
727
|
// blocked; serving after that race would attach the old checkout to a new
|
|
720
728
|
// destination even though the pull itself correctly refused.
|
|
721
729
|
if (teamAdvertised && !matchesStartupTeamRoute()) {
|
|
722
|
-
return
|
|
730
|
+
return refused("The team-memory route changed during refresh. Refusing to serve a stale or redirected graph; reconnect Hunch first.");
|
|
723
731
|
}
|
|
724
732
|
}
|
|
725
733
|
// Stamp check in EVERY mode, not only shared: a CLI capture or post-commit
|
|
@@ -735,7 +743,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
735
743
|
catch { /* corrupt/churning local source — serve the last durable indexed view */ }
|
|
736
744
|
const result = await callback(...args);
|
|
737
745
|
if (teamAdvertised && !matchesStartupTeamRoute()) {
|
|
738
|
-
return
|
|
746
|
+
return refused("The team-memory route changed while the tool was running. Its startup destination was not published; reconnect Hunch before retrying.");
|
|
739
747
|
}
|
|
740
748
|
return result;
|
|
741
749
|
}
|
|
@@ -747,7 +755,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
747
755
|
// -- hunch_query ----------------------------------------------------------
|
|
748
756
|
server.registerTool("hunch_query", {
|
|
749
757
|
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.",
|
|
758
|
+
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
759
|
inputSchema: { query: z.string().describe("A natural-language question or keywords.") },
|
|
752
760
|
}, async ({ query }) => {
|
|
753
761
|
const hits = await store.hybridSearch(query, QUERY_HITS, { embedder: await embedderReady });
|
|
@@ -762,7 +770,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
762
770
|
// -- hunch_runbook --------------------------------------------------------
|
|
763
771
|
server.registerTool("hunch_runbook", {
|
|
764
772
|
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.",
|
|
773
|
+
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
774
|
inputSchema: { task: z.string().describe("The task/intent, e.g. 'add an MCP tool' or 'cut a release'.") },
|
|
767
775
|
}, async ({ task }) => {
|
|
768
776
|
const hits = await store.searchRunbooks(task, 5, { embedder: await embedderReady });
|
|
@@ -781,7 +789,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
781
789
|
// -- hunch_why ------------------------------------------------------------
|
|
782
790
|
server.registerTool("hunch_why", {
|
|
783
791
|
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.",
|
|
792
|
+
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
793
|
inputSchema: {
|
|
786
794
|
target: z.string().describe("A file path (e.g. src/auth/session.ts) or symbol name."),
|
|
787
795
|
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 +797,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
789
797
|
}, async ({ target, as_of }) => {
|
|
790
798
|
const asOf = as_of ? asOfDate(as_of, root) : undefined;
|
|
791
799
|
if (as_of && !asOf)
|
|
792
|
-
return
|
|
800
|
+
return invalid(`Could not resolve as_of "${as_of}" to a commit.`);
|
|
793
801
|
const w = store.why(target, { asOf });
|
|
794
802
|
// Highest-signal first, then cap: invariants by severity, decisions by
|
|
795
803
|
// confidence, bugs by severity — so a hot file's trim drops the tail, not
|
|
@@ -830,7 +838,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
830
838
|
// -- hunch_check_constraints ---------------------------------------------
|
|
831
839
|
server.registerTool("hunch_check_constraints", {
|
|
832
840
|
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.",
|
|
841
|
+
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
842
|
inputSchema: { scope: z.string().describe("A path or glob, e.g. src/auth/** or src/auth/session.ts") },
|
|
835
843
|
}, async ({ scope }) => {
|
|
836
844
|
const cons = store.checkConstraints(scope);
|
|
@@ -842,7 +850,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
842
850
|
// -- hunch_get_dependents -------------------------------------------------
|
|
843
851
|
server.registerTool("hunch_get_dependents", {
|
|
844
852
|
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.",
|
|
853
|
+
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
854
|
inputSchema: { symbol: z.string().describe("A symbol id, symbol name, or file path.") },
|
|
847
855
|
}, async ({ symbol }) => {
|
|
848
856
|
const matches = resolveSymbols(store, symbol);
|
|
@@ -863,7 +871,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
863
871
|
// -- hunch_blast_radius (dependents + near-violations) --------------------
|
|
864
872
|
server.registerTool("hunch_blast_radius", {
|
|
865
873
|
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`.",
|
|
874
|
+
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
875
|
inputSchema: { target: z.string().describe("A file path (e.g. src/auth/jwt.ts) or symbol.") },
|
|
868
876
|
}, async ({ target }) => {
|
|
869
877
|
const parts = [];
|
|
@@ -917,7 +925,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
917
925
|
// -- hunch_change_proof (exact-revision semantic evidence) ----------------
|
|
918
926
|
server.registerTool("hunch_change_proof", {
|
|
919
927
|
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.",
|
|
928
|
+
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
929
|
inputSchema: {
|
|
922
930
|
base_ref: z.string().min(1).max(1_024).describe("Base commit or ref for the exact tree transition."),
|
|
923
931
|
result_ref: z.string().min(1).max(1_024).optional().describe("Result commit or ref (default HEAD)."),
|
|
@@ -931,7 +939,9 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
931
939
|
return {
|
|
932
940
|
content: [{
|
|
933
941
|
type: "text",
|
|
934
|
-
text: `${proof.proof_id} — ${proof.verdict.toUpperCase()}; ${proof.changed_file_count} exact file delta(s), ${proof.blast_radius_count} dependent path(s), ${proof.omissions.length + proof.unknowns.length} explicit gap(s); sealed ${proof.content_hash}. Evidence only; no execution or merge authority
|
|
942
|
+
text: `${proof.proof_id} — ${proof.verdict.toUpperCase()}; ${proof.changed_file_count} exact file delta(s), ${proof.blast_radius_count} dependent path(s), ${proof.omissions.length + proof.unknowns.length} explicit gap(s); sealed ${proof.content_hash}. Evidence only; no execution or merge authority.`
|
|
943
|
+
// The chain: a `shipped` receipt rests on this proof as a credential-free pointer.
|
|
944
|
+
+ `\n\nrests_on ref (for a nuryel receipt that shipped this change): ${JSON.stringify({ kind: "external", ref: { system: "hunch", object_type: "change_proof", object_key: proof.proof_id, content_hash: proof.content_hash, observed_at: new Date().toISOString().replace(/\.\d{3}Z$/, "Z") } })}`,
|
|
935
945
|
}],
|
|
936
946
|
structuredContent: proof,
|
|
937
947
|
};
|
|
@@ -1013,7 +1023,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
1013
1023
|
// -- hunch_context (surgical retrieval) -----------------------------------
|
|
1014
1024
|
server.registerTool("hunch_context", {
|
|
1015
1025
|
title: "Assemble the minimal relevant Hunch slice for a task",
|
|
1016
|
-
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.",
|
|
1026
|
+
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).",
|
|
1017
1027
|
inputSchema: {
|
|
1018
1028
|
target: z.string().describe("A file path, symbol, or task phrase you're about to work on."),
|
|
1019
1029
|
budget_tokens: z.number().optional().describe("Rough token budget for the brief (default 1500)."),
|
|
@@ -1024,7 +1034,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
1024
1034
|
}, async ({ target, budget_tokens, profile, as_of }, extra) => {
|
|
1025
1035
|
const asOf = as_of ? asOfDate(as_of, root) : undefined;
|
|
1026
1036
|
if (as_of && !asOf)
|
|
1027
|
-
return
|
|
1037
|
+
return invalid(`Could not resolve as_of "${as_of}" to a commit.`);
|
|
1028
1038
|
const ctx = store.assembleContext(target, budget_tokens ?? 1500, { asOf });
|
|
1029
1039
|
let dnaSupplement = null;
|
|
1030
1040
|
try {
|
|
@@ -1205,7 +1215,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
1205
1215
|
try {
|
|
1206
1216
|
const decision = store.advisoryRecs("decisions").find((candidate) => candidate.id === decision_id);
|
|
1207
1217
|
if (!decision)
|
|
1208
|
-
return
|
|
1218
|
+
return invalid(`Imported ADR ${decision_id} is not present in the current advisory memory home.`);
|
|
1209
1219
|
const reviewed = applyImportedAdrReview(decision, {
|
|
1210
1220
|
disposition,
|
|
1211
1221
|
expectedSourceHash: expected_source_hash,
|
|
@@ -1273,7 +1283,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
1273
1283
|
// -- hunch_capture_decision (decision-grounding: the grilling front door) --
|
|
1274
1284
|
server.registerTool("hunch_capture_decision", {
|
|
1275
1285
|
title: "Capture a decision (grilling interview)",
|
|
1276
|
-
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.",
|
|
1286
|
+
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).",
|
|
1277
1287
|
inputSchema: {
|
|
1278
1288
|
topic: z.string().optional().describe("proposed topic anchor (confirm with the human before committing)"),
|
|
1279
1289
|
seed: z.string().optional().describe("what the decision is about, to focus the first question"),
|
|
@@ -1327,7 +1337,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
1327
1337
|
// -- hunch_record_decision (write-back) -----------------------------------
|
|
1328
1338
|
server.registerTool("hunch_record_decision", {
|
|
1329
1339
|
title: "Record a decision (write-back)",
|
|
1330
|
-
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.",
|
|
1340
|
+
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.",
|
|
1331
1341
|
inputSchema: {
|
|
1332
1342
|
decision: z.object({
|
|
1333
1343
|
title: z.string(),
|
|
@@ -1410,7 +1420,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
1410
1420
|
// displaced by a differently-identified record, vouched or not.
|
|
1411
1421
|
const conflictsWithHuman = curated && !sameHumanIdentity && !(gated && !existingIsHuman);
|
|
1412
1422
|
if (conflictsWithHuman) {
|
|
1413
|
-
return
|
|
1423
|
+
return refused(`Decision id ${id} already identifies a different curated decision: ` +
|
|
1414
1424
|
`"${existing.title}"${existing.topic ? ` (topic "${existing.topic}")` : ""}. ` +
|
|
1415
1425
|
`Refusing to overwrite it with "${decision.title}"${decision.topic ? ` (topic "${decision.topic}")` : ""}. ` +
|
|
1416
1426
|
"Record the additional decision without commit, or reuse the incumbent topic/title when refining the same decision.");
|
|
@@ -1482,7 +1492,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
1482
1492
|
const crossStore = decision.supersedes && !willClose
|
|
1483
1493
|
? ` (note: supersedes:"${decision.supersedes}" is not in the ${home} store this write lands in, so it can't be closed from here)`
|
|
1484
1494
|
: "";
|
|
1485
|
-
return
|
|
1495
|
+
return refused(`Topic "${rec.topic}" already has a live decision: ${list}.${crossStore} ` +
|
|
1486
1496
|
`Hunch will not create a second current decision for one topic. Resolve it: ` +
|
|
1487
1497
|
`re-record with supersedes:<id> to replace it (linked, same store), pick a distinct topic to split, or discard this capture.`);
|
|
1488
1498
|
}
|
|
@@ -1532,7 +1542,13 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
1532
1542
|
? ` [PRIVATE overlay — not committed to this repo]${flushed}`
|
|
1533
1543
|
: home === "private" ? ` [SHARED store — one source of truth for the whole team]${flushed}` : flushed;
|
|
1534
1544
|
const dest = destinationNote(resolveDestRoot(home, store, root));
|
|
1535
|
-
|
|
1545
|
+
// The chain (nuryel.state/1): a `shipped` receipt in an organization drawer rests on
|
|
1546
|
+
// this decision by id + the hash ON FILE + this repository's partition. Hand the ref
|
|
1547
|
+
// over now so the agent never rests on a pre-store hash or re-derives the scope.
|
|
1548
|
+
const onFile = store.getRec("decisions", id) ?? rec;
|
|
1549
|
+
const restsOn = JSON.stringify({ kind: "record", id, record_hash: stateHash(onFile), scope: partitionOf(store) });
|
|
1550
|
+
const chainNote = `\n\nrests_on ref (for a nuryel receipt that implements this decision): ${restsOn}`;
|
|
1551
|
+
return ok(`Recorded decision ${id}: "${rec.title}" (status ${rec.status}, ${source}).${where}${dest}${supNote}${note}${chainNote}${captureNote}${quality}`);
|
|
1536
1552
|
}
|
|
1537
1553
|
catch (e) {
|
|
1538
1554
|
return err(`Failed to record decision: ${e.message}`);
|
|
@@ -1541,7 +1557,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
1541
1557
|
// -- hunch_record_correction (write-back: "Never Twice") ------------------
|
|
1542
1558
|
server.registerTool("hunch_record_correction", {
|
|
1543
1559
|
title: "Capture a correction as an enforced constraint (Never Twice)",
|
|
1544
|
-
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).",
|
|
1560
|
+
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).",
|
|
1545
1561
|
inputSchema: {
|
|
1546
1562
|
rule: z.string().describe("The invariant in the human's words, e.g. \"never call the pay-per-token API here\"."),
|
|
1547
1563
|
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."),
|
|
@@ -1557,7 +1573,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
1557
1573
|
}, async (input) => {
|
|
1558
1574
|
try {
|
|
1559
1575
|
if (!input.rule || !input.rule.trim())
|
|
1560
|
-
return
|
|
1576
|
+
return invalid("rule is required — state the invariant in plain words.");
|
|
1561
1577
|
// root: relativizes an ABSOLUTE scope_hint_file. Agents naturally send absolute
|
|
1562
1578
|
// paths (edit-tool payloads and MCP roots are absolute) and every consumer matches
|
|
1563
1579
|
// repo-relative — without this the rule would be blocking-but-inert and would leak
|
|
@@ -1573,7 +1589,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
1573
1589
|
const home = store.captureHome(!!input.private);
|
|
1574
1590
|
if (home === "public" && rec.source_decision && !store.json.get("decisions", rec.source_decision)) {
|
|
1575
1591
|
const location = store.getPrivateRec("decisions", rec.source_decision) ? "exists only in the private overlay" : "does not exist in the public home";
|
|
1576
|
-
return
|
|
1592
|
+
return refused(`source decision ${rec.source_decision} ${location}; refusing to record public correction ${rec.id}.`);
|
|
1577
1593
|
}
|
|
1578
1594
|
const existing = home === "private" ? store.getPrivateRec("constraints", rec.id) : store.json.get("constraints", rec.id);
|
|
1579
1595
|
// Same cross-home twin guard as the decision path above.
|
|
@@ -1617,7 +1633,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
1617
1633
|
// -- hunch_record_finding (write-back: observations, no diff) ---------------
|
|
1618
1634
|
server.registerTool("hunch_record_finding", {
|
|
1619
1635
|
title: "Record a finding (an observation with no code change)",
|
|
1620
|
-
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.",
|
|
1636
|
+
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).",
|
|
1621
1637
|
inputSchema: {
|
|
1622
1638
|
finding: z.object({
|
|
1623
1639
|
title: z.string().describe("stable one-line name — re-recording the same title updates the finding"),
|
|
@@ -1638,16 +1654,16 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
1638
1654
|
}, async ({ finding }) => {
|
|
1639
1655
|
try {
|
|
1640
1656
|
if (!finding.title.trim())
|
|
1641
|
-
return
|
|
1657
|
+
return invalid("title is required.");
|
|
1642
1658
|
if (!finding.observation.trim())
|
|
1643
|
-
return
|
|
1659
|
+
return invalid("observation is required — state what you saw.");
|
|
1644
1660
|
const id = findingId(finding.title);
|
|
1645
1661
|
const home = store.captureHome(!!finding.private);
|
|
1646
1662
|
const existing = home === "private" ? store.getPrivateRec("findings", id) : store.json.get("findings", id);
|
|
1647
1663
|
const now = new Date().toISOString();
|
|
1648
1664
|
const triage = finding.triage ?? existing?.triage ?? "open";
|
|
1649
1665
|
if (triage === "resolved" && !(finding.resolved_commit ?? existing?.resolved_commit)) {
|
|
1650
|
-
return
|
|
1666
|
+
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).`);
|
|
1651
1667
|
}
|
|
1652
1668
|
const rec = {
|
|
1653
1669
|
id,
|
|
@@ -1728,10 +1744,24 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
1728
1744
|
if (ref.facet === "derived")
|
|
1729
1745
|
return `- ${label} derived ${ref.id} · computed ${g("computed_at")} · ${r.dependencies?.length ?? 0} dependencies\n ${g("content").slice(0, 1200)}`;
|
|
1730
1746
|
if (ref.facet === "commitments")
|
|
1731
|
-
return `- ${label} commitment ${ref.id} · ${g("status")} · due ${g("due")} · owner ${g("owner")}: ${g("title")}`;
|
|
1747
|
+
return `- ${label} commitment ${ref.id} · ${g("status")} · due ${g("due")} · owner ${g("owner")}: ${g("title")}${r.closed_by ? ` · closed by ${g("closed_by")}` : ""}`;
|
|
1732
1748
|
if (ref.facet === "receipts") {
|
|
1733
1749
|
const t = (r.target ?? {});
|
|
1734
|
-
|
|
1750
|
+
// The chain: what the action rested on, one pointer per line, so a reader follows
|
|
1751
|
+
// incident → decision → change proof → closure without a second call.
|
|
1752
|
+
const rests = (Array.isArray(r.rests_on) ? r.rests_on : []);
|
|
1753
|
+
const restLines = rests.map((d) => {
|
|
1754
|
+
if (d.kind === "record") {
|
|
1755
|
+
const sc = d.scope;
|
|
1756
|
+
return `\n rests on record ${String(d.id)}${sc ? ` in ${String(sc.kind)}/${String(sc.id)}` : ""}`;
|
|
1757
|
+
}
|
|
1758
|
+
if (d.kind === "external") {
|
|
1759
|
+
const x = (d.ref ?? {});
|
|
1760
|
+
return `\n rests on ${String(x.system ?? "")} ${String(x.object_type ?? "")}:${String(x.object_key ?? "")}`;
|
|
1761
|
+
}
|
|
1762
|
+
return `\n rests on ${String(d.kind)} ${String(d.name ?? "")}`;
|
|
1763
|
+
}).join("");
|
|
1764
|
+
return `- ${label} receipt ${ref.id} · ${g("action_kind")} on ${String(t.system ?? "")} ${String(t.object_type ?? "")}:${String(t.object_key ?? "")} · ${g("state")} at ${g("occurred_at")} by ${g("actor")}${restLines}`;
|
|
1735
1765
|
}
|
|
1736
1766
|
if (ref.facet === "decisions")
|
|
1737
1767
|
return `- ${label} decision ${ref.id} · ${g("status")}: ${g("title")}`;
|
|
@@ -1803,7 +1833,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
1803
1833
|
// -- hunch_findings (read: the open-observations ledger) --------------------
|
|
1804
1834
|
server.registerTool("hunch_findings", {
|
|
1805
1835
|
title: "Open findings for a scope",
|
|
1806
|
-
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.",
|
|
1836
|
+
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.",
|
|
1807
1837
|
inputSchema: {
|
|
1808
1838
|
scope: z.string().optional().describe("a path, glob, or symbol (e.g. src/procs/** or dbo.GetOrders); omit for all"),
|
|
1809
1839
|
all: z.boolean().optional().describe("include resolved/stale findings (the full history)"),
|
|
@@ -1834,7 +1864,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
1834
1864
|
}, async ({ constraint_id, public_only, private_only, include_artifacts }) => {
|
|
1835
1865
|
try {
|
|
1836
1866
|
if (public_only && private_only)
|
|
1837
|
-
return
|
|
1867
|
+
return invalid("Choose only one of public_only or private_only.");
|
|
1838
1868
|
// Resolve the correction's exact home before any writes. Overlay-first is
|
|
1839
1869
|
// the same selection contract as ConstitutionService.upgradeCorrection;
|
|
1840
1870
|
// deriving this later from a policy id is unsafe when legacy public and
|
|
@@ -1893,7 +1923,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
1893
1923
|
// -- hunch_merge_verdict (Causal Merge Verdict — read-only, client-agnostic) --
|
|
1894
1924
|
server.registerTool("hunch_merge_verdict", {
|
|
1895
1925
|
title: "Causal merge verdict: is this change safe against the recorded WHY?",
|
|
1896
|
-
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.",
|
|
1926
|
+
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).",
|
|
1897
1927
|
inputSchema: {
|
|
1898
1928
|
base: z.string().optional().describe("Diff against this base ref (e.g. origin/main) — for a PR/branch."),
|
|
1899
1929
|
commit: z.string().optional().describe("Diff a single commit (sha/ref). Omit base AND commit to check staged changes."),
|
|
@@ -1902,11 +1932,11 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
1902
1932
|
}, async ({ base, commit, working }) => {
|
|
1903
1933
|
try {
|
|
1904
1934
|
if ([base, commit, working].filter(Boolean).length > 1)
|
|
1905
|
-
return
|
|
1935
|
+
return invalid("Pass at most one of base/commit/working (omit all to check staged changes).");
|
|
1906
1936
|
if (base && !revExists(base, root))
|
|
1907
|
-
return
|
|
1937
|
+
return invalid(`base ref "${base}" does not resolve (in CI, fetch the base branch first).`);
|
|
1908
1938
|
if (commit && !revExists(commit, root))
|
|
1909
|
-
return
|
|
1939
|
+
return invalid(`commit "${commit}" does not resolve.`);
|
|
1910
1940
|
const files = commit ? commitFiles(commit, root) : base ? rangeFiles(base, root) : working ? workingFiles(root) : stagedFiles(root);
|
|
1911
1941
|
const scope = commit ? `commit ${commit}` : base ? `${base}..HEAD` : working ? "working changes" : "staged changes";
|
|
1912
1942
|
if (!files.length)
|
|
@@ -1928,7 +1958,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
1928
1958
|
// -- hunch_structure (graph-served orientation — the anti-grep) ------------
|
|
1929
1959
|
server.registerTool("hunch_structure", {
|
|
1930
1960
|
title: "The indexed shape of the repo / a dir / a file / a symbol",
|
|
1931
|
-
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.",
|
|
1961
|
+
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).",
|
|
1932
1962
|
inputSchema: {
|
|
1933
1963
|
target: z.string().optional().describe("A directory, file path, or exact symbol name. Omit for the repo map."),
|
|
1934
1964
|
},
|
|
@@ -1936,7 +1966,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
1936
1966
|
// -- hunch_pr_impact (read-only impact surface — advisory, never gates) ----
|
|
1937
1967
|
server.registerTool("hunch_pr_impact", {
|
|
1938
1968
|
title: "PR impact: the dependency + memory surface of a change",
|
|
1939
|
-
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.",
|
|
1969
|
+
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).",
|
|
1940
1970
|
inputSchema: {
|
|
1941
1971
|
base: z.string().optional().describe("Diff against this base ref (e.g. origin/main) — for a PR/branch."),
|
|
1942
1972
|
commit: z.string().optional().describe("Impact of a single commit (sha/ref). Omit base AND commit for staged changes."),
|
|
@@ -1945,11 +1975,11 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
1945
1975
|
}, async ({ base, commit, working }) => {
|
|
1946
1976
|
try {
|
|
1947
1977
|
if ([base, commit, working].filter(Boolean).length > 1)
|
|
1948
|
-
return
|
|
1978
|
+
return invalid("Pass at most one of base/commit/working (omit all for staged changes).");
|
|
1949
1979
|
if (base && !revExists(base, root))
|
|
1950
|
-
return
|
|
1980
|
+
return invalid(`base ref "${base}" does not resolve (in CI, fetch the base branch first).`);
|
|
1951
1981
|
if (commit && !revExists(commit, root))
|
|
1952
|
-
return
|
|
1982
|
+
return invalid(`commit "${commit}" does not resolve.`);
|
|
1953
1983
|
const files = commit ? commitFiles(commit, root) : base ? rangeFiles(base, root) : working ? workingFiles(root) : stagedFiles(root);
|
|
1954
1984
|
const scope = commit ? `commit ${commit}` : base ? `${base}..HEAD` : working ? "working changes" : "staged changes";
|
|
1955
1985
|
if (!files.length)
|
|
@@ -1974,9 +2004,9 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
1974
2004
|
const A = store.resolveNodeIds(from);
|
|
1975
2005
|
const B = store.resolveNodeIds(to);
|
|
1976
2006
|
if (!A.length)
|
|
1977
|
-
return
|
|
2007
|
+
return invalid(`"${from}" resolves to no indexed symbol/component (is the repo indexed?).`);
|
|
1978
2008
|
if (!B.length)
|
|
1979
|
-
return
|
|
2009
|
+
return invalid(`"${to}" resolves to no indexed symbol/component.`);
|
|
1980
2010
|
let best = null;
|
|
1981
2011
|
for (const a of A.slice(0, 4)) {
|
|
1982
2012
|
for (const b of B.slice(0, 4)) {
|
|
@@ -2002,9 +2032,9 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
2002
2032
|
try {
|
|
2003
2033
|
const b = base ?? "main";
|
|
2004
2034
|
if (!candidates.length)
|
|
2005
|
-
return
|
|
2035
|
+
return invalid("Pass at least one candidate ref.");
|
|
2006
2036
|
if (!revExists(b, root))
|
|
2007
|
-
return
|
|
2037
|
+
return invalid(`base ref "${b}" does not resolve (in CI, fetch it first).`);
|
|
2008
2038
|
const ranked = compareCandidates(store, root, b, candidates);
|
|
2009
2039
|
const icon = (v) => (v === "pass" ? "✅" : v === "warn" ? "⚠" : "⛔");
|
|
2010
2040
|
const lines = ranked.map((c, i) => c.error
|
|
@@ -2222,7 +2252,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
2222
2252
|
// -- hunch_conformance ----------------------------------------------------
|
|
2223
2253
|
server.registerTool("hunch_conformance", {
|
|
2224
2254
|
title: "Does the code still satisfy the recorded intent?",
|
|
2225
|
-
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.",
|
|
2255
|
+
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).",
|
|
2226
2256
|
inputSchema: {},
|
|
2227
2257
|
}, async () => {
|
|
2228
2258
|
try {
|
|
@@ -19,7 +19,12 @@ export const LEDGER_SCHEMA_VERSION = "nuryel.ledger/1";
|
|
|
19
19
|
export const CHANGES_DIR = "changes";
|
|
20
20
|
const IdempotencyEntrySchema = z.object({
|
|
21
21
|
record_id: z.string().min(1),
|
|
22
|
+
/** Hash of the record ON FILE (what reads, events and refs see). */
|
|
22
23
|
record_hash: z.string(),
|
|
24
|
+
/** Hash of the normalized payload as the writer sent it (additive). The store may enrich a
|
|
25
|
+
* record on put (a private-mode decision gains `valid_from`), so a replay is recognized by
|
|
26
|
+
* the payload it re-sends, while `record_hash` stays the truth a reader can verify. */
|
|
27
|
+
payload_hash: z.string().optional(),
|
|
23
28
|
facet: z.string(),
|
|
24
29
|
seq: z.number().int().nonnegative(),
|
|
25
30
|
at: z.string(),
|
|
@@ -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
|