@davesheffer/hunch 1.28.0 → 1.29.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 CHANGED
@@ -1,25 +1,27 @@
1
1
  # Hunch
2
2
 
3
- ## Your repo remembers why and teaches every coding agent how the project works.
3
+ ## Agents are probabilistic. Organizations need deterministic state. Hunch is the state layer between them.
4
4
 
5
5
  [![npm version](https://img.shields.io/npm/v/@davesheffer/hunch?color=2742ff&label=npm)](https://www.npmjs.com/package/@davesheffer/hunch)
6
6
  [![GitHub stars](https://img.shields.io/github/stars/davesheffer/hunch?color=2742ff&label=%E2%98%85%20star)](https://github.com/davesheffer/hunch)
7
7
  [![license](https://img.shields.io/npm/l/@davesheffer/hunch?color=2742ff)](LICENSE)
8
8
 
9
- Every new AI coding session can read your code. It cannot automatically see why your team chose this design, which alternative already failed, what an odd-looking line protects, or how this repository expects work to be explained and reviewed.
9
+ Every employee is getting an agent. Each one reads the code, the CRM, the mail thread, the chat, and forms its own opinion about what was decided, what was done and what is still owed. Two agents, two slightly different realities. Neither is wrong. Both are guesses, made fresh, from raw material.
10
10
 
11
- That is how settled decisions get reopened, fixed bugs return, and technically plausible changes arrive feeling foreign to the project.
11
+ An organization cannot run on guesses. It runs on state: this was decided, this rule is in force, this action happened and was verified, this promise is due Thursday, this summary is current and rests on these sources. Hunch holds that state in git, refuses it when it contradicts, and delivers it to Claude, Codex, Cursor, Copilot, Windsurf, Antigravity and any other MCP client before the agent answers or edits.
12
12
 
13
- **Hunch is evidence-backed deterministic project intelligence for the AI coding tools you already use.** It gives Claude, Codex, Cursor, Copilot, Windsurf, Antigravity and other MCP clients the same durable understanding of your codebase:
13
+ **Hunch started as engineering memory for coding agents** and still is: why the code is shaped this way, which alternative already failed, what an odd-looking line protects, what depends on the code about to change, and which trusted decisions, fixes and architectural boundaries a change must preserve. The same graph now holds organizational state for operations agents too (see [Deterministic organizational state](#deterministic-organizational-state)).
14
14
 
15
- - why the code is shaped this way;
16
- - how the repository communicates, reviews and builds;
17
- - what depends on the code about to change; and
18
- - which trusted decisions, fixes and architectural boundaries the result must preserve.
15
+ ## Why Hunch, not another memory layer
19
16
 
20
- For precise rules your team has explicitly trusted, the promise is **Never Twice**: an agent may propose a different direction, but it cannot quietly re-make a decided decision or re-introduce a fixed failure without Hunch surfacing the conflict and its evidence.
17
+ Memory optimizes recall. State optimizes refusal. Hunch competes for the deterministic state layer from the organizational side, and every point below is a property you can verify in this repository rather than a claim:
21
18
 
22
- Memory starts advisory. Nothing blocks until a human deliberately trusts a precise rule and opts into strict enforcement.
19
+ - **Git is the source of truth.** Every fact is a JSON file under `.hunch/`, every change a commit: diffable, PR-reviewable, revertable, mergeable, never locked in a vendor database. SQLite is only a derived index.
20
+ - **Refusal, not convergence.** One live decision per topic. A second contradicting record is refused at write time with the incumbent named, and a supersede target must still be open. Diverging writes are not merged later.
21
+ - **Organization drawers with a key per agent.** Repository, user, team and organization partitions; the bearer key resolves the principal and decides visibility before anything is looked up.
22
+ - **Receipts and commitments are facts.** What was actually done in an external system, verified or not, and who owes what by when, readable by any agent with the key.
23
+ - **Never Twice.** A human correction becomes an enforced rule, not a one-session memory. Nothing blocks until a human deliberately trusts a precise rule and opts into strict enforcement.
24
+ - **The code spoke.** Decisions, constraints and bug lineage are checked against the code deterministically, with no model in the block path. No state-layer peer has it.
23
25
 
24
26
  ## Start in five minutes
25
27
 
@@ -144,19 +146,19 @@ hunch check --staged --strict
144
146
 
145
147
  Captured memory cannot silently hard-block on its own.
146
148
 
147
- ## Deterministic organizational state — next product direction
149
+ ## Deterministic organizational state
148
150
 
149
- Repository memory solves one version of a larger problem.
151
+ Repository memory solves one version of a larger problem, and since 1.25.0 Hunch ships the larger one.
150
152
 
151
153
  As organizations give every employee an agent that can work across CRM, email, messaging, repositories and other tools, the agents become probabilistic writers/readers of the same organization. If each one independently reconstructs what was decided, what was already done or what is still owed, the organization gets multiple conflicting realities.
152
154
 
153
- The active roadmap asks whether Hunch can become the deterministic state layer between those agents and the organization:
155
+ Hunch is the deterministic state layer between those agents and the organization:
154
156
 
155
157
  > **Agents are probabilistic. Organizations need deterministic state. Hunch is the state layer between them.**
156
158
 
157
159
  The target is **one product, one authorized state graph and one versioned state contract** across repository, user, team and organization scopes.
158
160
 
159
- Planned state includes:
161
+ The state it holds:
160
162
 
161
163
  - decisions currently in force;
162
164
  - verified action receipts / what was done;
@@ -203,7 +205,9 @@ user:david --root <dir> --principal sofia@david` declares a partition and mints
203
205
  client is `import { createStateClient } from "@davesheffer/hunch/state"`. This folds the separate
204
206
  Hunch Memory service into Hunch.
205
207
 
206
- Read [Deterministic organizational state](docs/deterministic-state.md) and the [roadmap](ROADMAP.md).
208
+ As of 1.27.0 a fourth verb, `records`, lists a subject's records for the first writers, and the per-scope ledger compacts and merges across clones. As of 1.28.0 reads are a union across writers, a supersede target must still be open (two racing writers can no longer leave two current records), state records are searchable and delivered by subject, and subjects are keyed by the external record rather than by the agent. Proven on an emulated organization: three agents over ten clinics and a generated year of mail, chat and CRM, one organization drawer, 96 cited summaries, 24 verified receipts, 24 commitments, zero contradictions.
209
+
210
+ Read [Deterministic organizational state](docs/deterministic-state.md), the [roadmap](ROADMAP.md) and the dated [competitive landscape](docs/competitive-landscape.md).
207
211
 
208
212
  ### Naming
209
213
 
@@ -216,7 +220,7 @@ The current release can keep a team's **repository-scoped** memory in a dedicate
216
220
  Today Hunch does not host that shared Git repository; teammates/CI use normal Git access and one maintainer connects it:
217
221
 
218
222
  ```bash
219
- npm i -g @davesheffer/hunch@1.23.3
223
+ npm i -g @davesheffer/hunch@1.28.0
220
224
  hunch shared --repo git@github.com:acme/project-hunch-memory.git
221
225
  git add .gitignore .hunch/team.json
222
226
  git commit -m "chore: connect shared Hunch memory"
@@ -226,7 +230,7 @@ git push
226
230
  Teammates then install the same version and run:
227
231
 
228
232
  ```bash
229
- npm i -g @davesheffer/hunch@1.23.3
233
+ npm i -g @davesheffer/hunch@1.28.0
230
234
  git pull
231
235
  hunch init
232
236
  hunch doctor
package/dist/cli/index.js CHANGED
@@ -45,15 +45,16 @@ import { deriveForbids, effectiveForbids } from "../core/constraintmatch.js";
45
45
  import { extractInlineIntent } from "../extractors/comments.js";
46
46
  import { renderText, renderMarkdown, renderSarif, renderImpact, reportFailsStrict } from "../core/checkreport.js";
47
47
  import { partitionReview, isReviewDraft, READY_MIN_GROUNDED } from "../core/reviewqueue.js";
48
- import { installPostCommitHook, installPreCommitHook } from "../integrations/hooks.js";
48
+ import { installPostCommitHook, installPreCommitHook, installPostMergeHook } from "../integrations/hooks.js";
49
49
  import { ensureSharedOverlayPointer } from "../integrations/worktree.js";
50
50
  import { flushCapture, flushMemoryHome, flushMemoryHomes, pinSharedRemote, sharedRemoteFor } from "../integrations/sync.js";
51
51
  import { installMergeDriver } from "../integrations/mergeDriver.js";
52
52
  import { ensureGitignore, ignoreHunchMemory, HUNCH_MEMORY_DIRS } from "../integrations/gitignore.js";
53
53
  import { writeCiWorkflow } from "../integrations/ciAction.js";
54
- import { updateClaudeMd } from "../integrations/claudemd.js";
54
+ import { updateClaudeMd, renderHunchSection } from "../integrations/claudemd.js";
55
+ import { classifyGroundingBlock, describeGroundingFreshness } from "../core/groundingLag.js";
55
56
  import { writeMcpJson, writeSlashCommands, installClaudeHooks } from "../integrations/scaffold.js";
56
- import { scaffoldProviders, regenerateGrounding, refreshExistingGrounding, refreshCommittableGrounding } from "../integrations/providers.js";
57
+ import { scaffoldProviders, regenerateGrounding, refreshExistingGrounding, refreshCommittableGrounding, GROUNDING_DOC_PATHS } from "../integrations/providers.js";
57
58
  import { healClaudeConfigCaseSplit } from "../integrations/claudeConfig.js";
58
59
  import { formatContext, formatSearchHit, formatStructure } from "../core/format.js";
59
60
  import { isStateKind, renderStateLine, stateSupplements } from "../core/stateDelivery.js";
@@ -282,6 +283,8 @@ program
282
283
  console.log(` ✓ post-commit hook ${h.action} (learning loop)${syncToOverlay ? " — syncs to the shared overlay" : ""}${opts.autoCommit ? " — auto-commit on" : ""}`);
283
284
  const m = installMergeDriver(root, inv.shell);
284
285
  console.log(` ✓ team merge driver ${m.action}`);
286
+ const pm = installPostMergeHook(root, inv.shell);
287
+ console.log(` ✓ post-merge hook ${pm.action} (re-syncs grounding docs after a merge that brought memory in)`);
285
288
  // Auto-install the pre-commit guard by default (advisory: flags invariants
286
289
  // touched directly OR via blast radius, never blocks). Opt out with
287
290
  // --no-enforce; --enforce-strict makes blocking near/direct hits fail the commit.
@@ -5468,6 +5471,79 @@ program
5468
5471
  store.close();
5469
5472
  }
5470
5473
  });
5474
+ // ---- grounding (the committed grounding docs vs the PUBLIC graph) -------------
5475
+ const GROUNDING_START = "<!-- HUNCH:START — auto-generated, do not edit by hand -->";
5476
+ const GROUNDING_END = "<!-- HUNCH:END -->";
5477
+ function groundingBlockContent(text) {
5478
+ const i = text.indexOf(GROUNDING_START);
5479
+ const j = text.indexOf(GROUNDING_END);
5480
+ if (i === -1 || j === -1 || j < i)
5481
+ return null;
5482
+ return text.slice(i + GROUNDING_START.length, j).trim();
5483
+ }
5484
+ program
5485
+ .command("grounding")
5486
+ .description("Check the committed grounding docs (CLAUDE.md, AGENTS.md, copilot-instructions, hunch.mdc, hunch.md) against what the PUBLIC graph generates — direction-aware: counts that merely LAG a merge are reported, counts AHEAD of the store (a record never committed) or divergent prose fail. --refresh regenerates every existing doc from the public store (never the overlay union, never a doc the project lacks). Exits 1 on ahead/diverged unless refreshed.")
5487
+ .option("--refresh", "regenerate the existing grounding docs from the public store (what the post-merge hook and the release remedy run)")
5488
+ .option("--json", "machine-readable verdicts")
5489
+ .option("--quiet", "print nothing on success")
5490
+ .action((opts) => {
5491
+ const root = findRoot();
5492
+ // PUBLIC-ONLY by construction, exactly as the release gate and the freshness test
5493
+ // read it: HUNCH_PRIVATE_DIR at an empty overlay beats .hunch/local.json and the
5494
+ // shared pointer, so a dev machine with an overlay attached can never write union
5495
+ // counts into a committed public doc.
5496
+ const emptyPrivate = mkdtempSync(join(tmpdir(), "hunch-grounding-public-"));
5497
+ const prior = process.env.HUNCH_PRIVATE_DIR;
5498
+ process.env.HUNCH_PRIVATE_DIR = emptyPrivate;
5499
+ const store = new HunchStore(hunchPaths(root));
5500
+ try {
5501
+ const rendered = renderHunchSection(store, root);
5502
+ const generated = groundingBlockContent(rendered) ?? rendered.trim();
5503
+ const verdicts = GROUNDING_DOC_PATHS.map((rel) => {
5504
+ const file = join(root, ...rel.split("/"));
5505
+ if (!existsSync(file))
5506
+ return { doc: rel, verdict: { kind: "absent" } };
5507
+ const committed = groundingBlockContent(readFileSync(file, "utf8"));
5508
+ if (committed === null)
5509
+ return { doc: rel, verdict: { kind: "diverged", reason: "no managed HUNCH block" } };
5510
+ return { doc: rel, verdict: classifyGroundingBlock(committed, generated) };
5511
+ });
5512
+ const refreshed = opts.refresh ? refreshExistingGrounding(root, store) : [];
5513
+ const failing = verdicts.filter((v) => v.verdict.kind === "ahead" || v.verdict.kind === "diverged");
5514
+ const lagging = verdicts.filter((v) => v.verdict.kind === "lagging");
5515
+ if (opts.json) {
5516
+ console.log(JSON.stringify({ docs: verdicts, refreshed, ok: opts.refresh ? true : failing.length === 0 }, null, 2));
5517
+ }
5518
+ else if (opts.refresh) {
5519
+ if (!opts.quiet)
5520
+ console.log(refreshed.length ? `grounding refreshed: ${refreshed.join(", ")}` : "grounding already fresh — nothing to regenerate");
5521
+ }
5522
+ else {
5523
+ for (const v of verdicts) {
5524
+ if (v.verdict.kind === "absent")
5525
+ continue;
5526
+ if (v.verdict.kind === "fresh" && opts.quiet)
5527
+ continue;
5528
+ console.log(`${v.verdict.kind === "fresh" ? "✓" : v.verdict.kind === "lagging" ? "·" : "✗"} ${describeGroundingFreshness(v.doc, v.verdict)}`);
5529
+ }
5530
+ if (!opts.quiet && !failing.length) {
5531
+ console.log(lagging.length
5532
+ ? `\n${lagging.length} doc(s) lag a merge — transient; the next capture commit or \`hunch grounding --refresh\` heals it.`
5533
+ : "✓ grounding docs are fresh.");
5534
+ }
5535
+ }
5536
+ if (!opts.refresh && failing.length)
5537
+ process.exitCode = 1;
5538
+ }
5539
+ finally {
5540
+ store.close();
5541
+ process.env.HUNCH_PRIVATE_DIR = prior;
5542
+ if (prior === undefined)
5543
+ delete process.env.HUNCH_PRIVATE_DIR;
5544
+ rmSync(emptyPrivate, { recursive: true, force: true });
5545
+ }
5546
+ });
5471
5547
  // ---- findings (the open-observations ledger) --------------------------------
5472
5548
  program
5473
5549
  .command("findings")
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Grounding-block freshness classification (fnd_c402046ac7).
3
+ *
4
+ * The committed grounding docs (CLAUDE.md, AGENTS.md, copilot-instructions,
5
+ * hunch.mdc, hunch.md) carry a managed block whose first sentence states the
6
+ * public store's RECORD COUNTS. Those counts are a pure function of .hunch/*.json,
7
+ * so two branches that each capture one decision both regenerate the very same
8
+ * "N+1 decisions" line. Git merges identical lines silently, the merged store holds
9
+ * N+2, and the committed doc is one behind — with no conflict, no hook (the merge
10
+ * happened on the forge) and no human error. Every release-gate red of that class
11
+ * (PR #128, #135, v1.26.2's first run) was this lag.
12
+ *
13
+ * The lag is transient and self-healing: the next capture commit folds the
14
+ * regenerated docs in (refreshCommittableGrounding), and the release gate's
15
+ * repository-index stage regenerates them in the worktree and already treats the
16
+ * dirt as memory churn. What must STILL fail is a doc that is genuinely wrong:
17
+ *
18
+ * - prose in the block differs (a hand edit, a stale renderer, a template change);
19
+ * - an append-only count is AHEAD of the store: the doc counted a record the
20
+ * repository does not carry — the never-committed Video component of
21
+ * fnd_6391b4242f, the only defect the counts ever caught.
22
+ *
23
+ * So the rule is direction-aware and deterministic: decisions, bugs, constraints,
24
+ * components and policies only accrue, so a doc may lag behind them (merge) but never
25
+ * run ahead (missing record). Open findings move both ways (a finding resolved on one
26
+ * branch, a finding recorded on another), so a differing findings count alone is lag.
27
+ */
28
+ const COUNTS_RE = /\*\*(\d+) decisions?, (\d+) bugs?, (\d+) constraints?, (\d+) components?, (\d+) polic(?:y|ies)(?:, (\d+) open findings?)?\*\*/;
29
+ /** Record kinds whose committed count may only ever lag behind the store. */
30
+ export const APPEND_ONLY_COUNT_KINDS = ["decisions", "bugs", "constraints", "components", "policies"];
31
+ export function parseGroundingCounts(block) {
32
+ const m = COUNTS_RE.exec(block);
33
+ if (!m)
34
+ return null;
35
+ return {
36
+ match: m[0],
37
+ counts: {
38
+ decisions: Number(m[1]),
39
+ bugs: Number(m[2]),
40
+ constraints: Number(m[3]),
41
+ components: Number(m[4]),
42
+ policies: Number(m[5]),
43
+ findings: m[6] === undefined ? 0 : Number(m[6]),
44
+ },
45
+ };
46
+ }
47
+ /** Classify a committed managed block against the one the graph generates NOW.
48
+ * Both inputs are block CONTENT (markers stripped, trimmed). */
49
+ export function classifyGroundingBlock(committed, generated) {
50
+ if (committed === generated)
51
+ return { kind: "fresh" };
52
+ const c = parseGroundingCounts(committed);
53
+ const g = parseGroundingCounts(generated);
54
+ if (!c)
55
+ return { kind: "diverged", reason: "the committed block carries no record-counts sentence" };
56
+ if (!g)
57
+ return { kind: "diverged", reason: "the generated block carries no record-counts sentence" };
58
+ const withoutCounts = (text, match) => text.replace(match, "<counts>");
59
+ if (withoutCounts(committed, c.match) !== withoutCounts(generated, g.match)) {
60
+ return { kind: "diverged", reason: "the block differs outside the record-counts sentence" };
61
+ }
62
+ const ahead = APPEND_ONLY_COUNT_KINDS.filter((k) => c.counts[k] > g.counts[k]);
63
+ if (ahead.length)
64
+ return { kind: "ahead", committed: c.counts, generated: g.counts, ahead };
65
+ const behind = Object.keys(g.counts).filter((k) => c.counts[k] !== g.counts[k]);
66
+ return { kind: "lagging", committed: c.counts, generated: g.counts, behind };
67
+ }
68
+ /** One human line per verdict — shared by the freshness test and `hunch grounding`. */
69
+ export function describeGroundingFreshness(doc, verdict) {
70
+ const delta = (a, b, kinds) => kinds.map((k) => `${k} ${a[k]} → ${b[k]}`).join(", ");
71
+ switch (verdict.kind) {
72
+ case "fresh":
73
+ return `${doc}: fresh`;
74
+ case "lagging":
75
+ return `${doc}: counts lag the store (${delta(verdict.committed, verdict.generated, verdict.behind)}) — records merged in behind the doc; heals on the next capture or \`hunch grounding --refresh\``;
76
+ case "ahead":
77
+ return `${doc}: counts run AHEAD of the store (${delta(verdict.committed, verdict.generated, verdict.ahead)}) — the doc counted a record this repository does not carry; commit the missing .hunch/ record or regenerate`;
78
+ case "diverged":
79
+ return `${doc}: stale — ${verdict.reason}; regenerate with \`hunch grounding --refresh\` and commit`;
80
+ }
81
+ }
82
+ //# sourceMappingURL=groundingLag.js.map
@@ -113,6 +113,10 @@ export const WriteRequestSchema = z.object({
113
113
  idempotency_key: z.string().min(8).max(256),
114
114
  expected_version: z.union([z.string().max(256), z.number().int().nonnegative()]).nullable().default(null),
115
115
  supersedes: z.string().max(2048).optional(),
116
+ /** Why this write happened, when it is not the writer's own doing (additive): an external
117
+ * source moved. A derived record written back as `stale` with this cause emits an
118
+ * `invalidated` change naming the pointer — the `changed` facet, finally written by someone. */
119
+ cause: z.object({ kind: z.literal("external"), ref: ExternalRefSchema }).strict().optional(),
116
120
  }).strict();
117
121
  export const DURABILITY = ["pushed", "committed", "local"];
118
122
  export const WriteResultSchema = z.object({
@@ -230,6 +234,7 @@ export const STATE_INVARIANTS = [
230
234
  { id: "one-live-decision-per-topic", statement: "A second live decision on a topic is refused with the incumbent named; supersession is explicit." },
231
235
  { id: "external-truth-stays-external", statement: "External systems remain authoritative for their own content; Nuryel holds credential-free pointers, versions and hashes, never mirrored bodies." },
232
236
  { id: "derived-state-carries-dependencies", statement: "A derived statement without dependencies cannot be invalidated and is therefore not state." },
237
+ { id: "derived-state-writer-owns-currentness", statement: "No source writes the drawer. The writer of a derived statement owns keeping its dependencies true: re-validate them on a schedule or on a source event, and write the statement back stale with the moved pointer as cause when one no longer holds. An agent that will not do this must not write derived state." },
233
238
  ];
234
239
  const grantKey = (scope) => scopePath(scope);
235
240
  /** authorization-before-retrieval, checked on the way OUT as well: nothing in a read response
@@ -46,7 +46,9 @@ export const ExternalRefSchema = z.object({
46
46
  }).strict();
47
47
  /** What a derived statement rests on. Exactly what a currentness check re-validates. */
48
48
  export const DependencyRefSchema = z.discriminatedUnion("kind", [
49
- z.object({ kind: z.literal("record"), id: z.string().regex(TOKEN), record_hash: z.string().regex(SHA256) }).strict(),
49
+ /** `scope` (additive) points into ANOTHER partition — the repository decision an
50
+ * organization-drawer receipt rests on. Absent, the ref is in the record's own partition. */
51
+ z.object({ kind: z.literal("record"), id: z.string().regex(TOKEN), record_hash: z.string().regex(SHA256), scope: ScopeSchema.optional() }).strict(),
50
52
  z.object({ kind: z.literal("external"), ref: ExternalRefSchema }).strict(),
51
53
  z.object({ kind: z.literal("schema"), name: z.string().max(256), fingerprint: z.string().regex(SHA256) }).strict(),
52
54
  ]);
@@ -66,6 +68,12 @@ export const ActionReceiptSchema = z.object({
66
68
  verified_at: z.string().regex(ISO).optional(),
67
69
  result_fingerprint: z.string().regex(SHA256).optional(),
68
70
  invalidates: z.array(z.string().max(512)).max(64).default([]),
71
+ /** What the action rested on (additive): the decision it implements, the change proof for
72
+ * the shipped revision, the commitment or incident it answers. Same shape as a derived
73
+ * statement's dependencies, so "what does this closure rest on" is one read. A record ref
74
+ * in the receipt's own partition is verified by hash on write; a ref into another partition
75
+ * is a pointer the reader resolves with `records`, grants first. */
76
+ rests_on: z.array(DependencyRefSchema).max(64).optional(),
69
77
  provenance: ProvenanceSchema,
70
78
  }).strict();
71
79
  /** committed — an obligation with a due date and an in-force window. */
@@ -80,6 +88,9 @@ export const CommitmentSchema = z.object({
80
88
  status: z.enum(["open", "waiting", "done", "cancelled"]),
81
89
  source: ExternalRefSchema.optional(),
82
90
  evidence_excerpt: z.string().max(900).optional(),
91
+ /** The receipt that fulfilled this commitment (additive). A closure names what happened:
92
+ * the binding refuses a `closed_by` that is not a succeeded/verified receipt on record. */
93
+ closed_by: z.string().regex(/^nrc_[a-f0-9]{24}$/).optional(),
83
94
  valid_from: z.string().regex(ISO),
84
95
  valid_to: z.string().regex(ISO).nullable().default(null),
85
96
  provenance: ProvenanceSchema,
@@ -97,4 +97,45 @@ export function installPreCommitHook(root, invocation, strict = false) {
97
97
  chmodSync(hookPath, 0o755);
98
98
  return { path: hookPath, action: "appended" };
99
99
  }
100
+ const MERGE_MARK = "# >>> hunch post-merge >>>";
101
+ const MERGE_END = "# <<< hunch post-merge <<<";
102
+ /** Install a post-merge hook that re-syncs the committed grounding docs when a merge
103
+ * brought memory in behind them (fnd_c402046ac7). Two branches that each captured a
104
+ * record regenerate the same "N+1" counts line; git merges identical lines silently
105
+ * and the doc ends up one behind the store. The hook regenerates the existing docs
106
+ * from the PUBLIC store right after a local merge/pull that touched .hunch/, so the
107
+ * next commit carries them. Foreground (it rewrites five files), loop-guarded via
108
+ * HUNCH_SYNC, and it can never fail the merge. Preserves any existing hook. */
109
+ export function installPostMergeHook(root, invocation) {
110
+ const dir = hooksDir(root);
111
+ const abs = isAbsolute(dir) ? dir : join(root, dir);
112
+ mkdirSync(abs, { recursive: true });
113
+ const hookPath = join(abs, "post-merge");
114
+ const blk = [
115
+ MERGE_MARK,
116
+ 'if [ -z "$HUNCH_SYNC" ]; then',
117
+ " if ! git diff --quiet ORIG_HEAD HEAD -- .hunch 2>/dev/null; then",
118
+ ` ( HUNCH_SYNC=1 ${invocation} grounding --refresh 2>/dev/null || true )`,
119
+ " fi",
120
+ "fi",
121
+ MERGE_END,
122
+ ].join("\n");
123
+ if (!existsSync(hookPath)) {
124
+ writeFileSync(hookPath, `#!/bin/sh\n${blk}\n`);
125
+ chmodSync(hookPath, 0o755);
126
+ return { path: hookPath, action: "created" };
127
+ }
128
+ const cur = readFileSync(hookPath, "utf8");
129
+ if (cur.includes(MERGE_MARK)) {
130
+ const updated = cur.replace(new RegExp(`${escapeRe(MERGE_MARK)}[\\s\\S]*?${escapeRe(MERGE_END)}`), blk);
131
+ if (updated === cur)
132
+ return { path: hookPath, action: "unchanged" };
133
+ writeFileSync(hookPath, updated);
134
+ chmodSync(hookPath, 0o755);
135
+ return { path: hookPath, action: "updated" };
136
+ }
137
+ writeFileSync(hookPath, cur.endsWith("\n") ? `${cur}${blk}\n` : `${cur}\n${blk}\n`);
138
+ chmodSync(hookPath, 0o755);
139
+ return { path: hookPath, action: "appended" };
140
+ }
100
141
  //# sourceMappingURL=hooks.js.map
@@ -360,6 +360,14 @@ export function regenerateGrounding(root, store) {
360
360
  writeWindsurfRule(root, store),
361
361
  ];
362
362
  }
363
+ /** The five grounding docs, repo-relative (POSIX separators, as git prints them). */
364
+ export const GROUNDING_DOC_PATHS = Object.freeze([
365
+ "CLAUDE.md",
366
+ "AGENTS.md",
367
+ ".github/copilot-instructions.md",
368
+ ".cursor/rules/hunch.mdc",
369
+ ".windsurf/rules/hunch.md",
370
+ ]);
363
371
  function groundingTargets(root, store) {
364
372
  return [
365
373
  ["CLAUDE.md", () => updateClaudeMd(root, store)],
@@ -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";
@@ -931,7 +931,9 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
931
931
  return {
932
932
  content: [{
933
933
  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.`,
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.`
935
+ // The chain: a `shipped` receipt rests on this proof as a credential-free pointer.
936
+ + `\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
937
  }],
936
938
  structuredContent: proof,
937
939
  };
@@ -1532,7 +1534,13 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
1532
1534
  ? ` [PRIVATE overlay — not committed to this repo]${flushed}`
1533
1535
  : home === "private" ? ` [SHARED store — one source of truth for the whole team]${flushed}` : flushed;
1534
1536
  const dest = destinationNote(resolveDestRoot(home, store, root));
1535
- return ok(`Recorded decision ${id}: "${rec.title}" (status ${rec.status}, ${source}).${where}${dest}${supNote}${note}${captureNote}${quality}`);
1537
+ // The chain (nuryel.state/1): a `shipped` receipt in an organization drawer rests on
1538
+ // this decision by id + the hash ON FILE + this repository's partition. Hand the ref
1539
+ // over now so the agent never rests on a pre-store hash or re-derives the scope.
1540
+ const onFile = store.getRec("decisions", id) ?? rec;
1541
+ const restsOn = JSON.stringify({ kind: "record", id, record_hash: stateHash(onFile), scope: partitionOf(store) });
1542
+ const chainNote = `\n\nrests_on ref (for a nuryel receipt that implements this decision): ${restsOn}`;
1543
+ return ok(`Recorded decision ${id}: "${rec.title}" (status ${rec.status}, ${source}).${where}${dest}${supNote}${note}${chainNote}${captureNote}${quality}`);
1536
1544
  }
1537
1545
  catch (e) {
1538
1546
  return err(`Failed to record decision: ${e.message}`);
@@ -1728,10 +1736,24 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
1728
1736
  if (ref.facet === "derived")
1729
1737
  return `- ${label} derived ${ref.id} · computed ${g("computed_at")} · ${r.dependencies?.length ?? 0} dependencies\n ${g("content").slice(0, 1200)}`;
1730
1738
  if (ref.facet === "commitments")
1731
- return `- ${label} commitment ${ref.id} · ${g("status")} · due ${g("due")} · owner ${g("owner")}: ${g("title")}`;
1739
+ return `- ${label} commitment ${ref.id} · ${g("status")} · due ${g("due")} · owner ${g("owner")}: ${g("title")}${r.closed_by ? ` · closed by ${g("closed_by")}` : ""}`;
1732
1740
  if (ref.facet === "receipts") {
1733
1741
  const t = (r.target ?? {});
1734
- 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")}`;
1742
+ // The chain: what the action rested on, one pointer per line, so a reader follows
1743
+ // incident → decision → change proof → closure without a second call.
1744
+ const rests = (Array.isArray(r.rests_on) ? r.rests_on : []);
1745
+ const restLines = rests.map((d) => {
1746
+ if (d.kind === "record") {
1747
+ const sc = d.scope;
1748
+ return `\n rests on record ${String(d.id)}${sc ? ` in ${String(sc.kind)}/${String(sc.id)}` : ""}`;
1749
+ }
1750
+ if (d.kind === "external") {
1751
+ const x = (d.ref ?? {});
1752
+ return `\n rests on ${String(x.system ?? "")} ${String(x.object_type ?? "")}:${String(x.object_key ?? "")}`;
1753
+ }
1754
+ return `\n rests on ${String(d.kind)} ${String(d.name ?? "")}`;
1755
+ }).join("");
1756
+ 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
1757
  }
1736
1758
  if (ref.facet === "decisions")
1737
1759
  return `- ${label} decision ${ref.id} · ${g("status")}: ${g("title")}`;
@@ -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(),
@@ -179,8 +179,10 @@ export function readState(store, input) {
179
179
  const scope = admit("receipts", r);
180
180
  if (!scope)
181
181
  continue;
182
- if (r.state === "succeeded" || r.state === "verified")
182
+ if (r.state === "succeeded" || r.state === "verified") {
183
183
  done.push(keep("receipts", r, scope));
184
+ dependsOn.push(...(r.rests_on ?? []));
185
+ }
184
186
  if (r.invalidates.includes(subject))
185
187
  invalidatedBy.add(r.id);
186
188
  }
@@ -193,6 +195,10 @@ export function readState(store, input) {
193
195
  continue;
194
196
  if ((c.status === "open" || c.status === "waiting") && c.valid_to == null)
195
197
  inForce.push(keep("commitments", c, scope));
198
+ // A commitment fulfilled by a receipt is part of what HAPPENED for the subject: it
199
+ // leaves in_force and joins done beside the receipt that closed it (the chain's last link).
200
+ else if (c.status === "done" && c.closed_by)
201
+ done.push(keep("commitments", c, scope));
196
202
  }
197
203
  if (facets.has("derived"))
198
204
  for (const d of store.recs("derived")) {
@@ -330,6 +336,77 @@ function subjectOf(facet, record) {
330
336
  default: return undefined;
331
337
  }
332
338
  }
339
+ /** Which facet a record id belongs to, from its prefix; `null` for a kind-qualified entity id
340
+ * or an unknown shape (those are looked up across every facet). */
341
+ function facetOfId(id) {
342
+ const prefix = /^([a-z]+)_/.exec(id)?.[1];
343
+ switch (prefix) {
344
+ case "dec": return "decisions";
345
+ case "con": return "constraints";
346
+ case "bug": return "bugs";
347
+ case "fnd": return "findings";
348
+ case "nrc": return "receipts";
349
+ case "ncm": return "commitments";
350
+ case "nds": return "derived";
351
+ case "edge": return "relationships";
352
+ default: return null;
353
+ }
354
+ }
355
+ /** Find a record by id in this store, with the facet it lives in. */
356
+ function findRecord(store, id) {
357
+ const facets = facetOfId(id) ? [facetOfId(id)] : [...STATE_FACETS];
358
+ for (const facet of facets) {
359
+ const record = store.getRec(facet, id);
360
+ if (record)
361
+ return { facet, record };
362
+ }
363
+ return null;
364
+ }
365
+ /** A receipt's `rests_on` record refs: one in a partition this store holds must exist there
366
+ * with the hash the writer saw (a stale hash means the decision moved — re-read); one in a
367
+ * partition the store does not hold is a pointer for the reader to resolve. Grants first: a
368
+ * ref into a partition the principal is not granted is refused by scope, never by content. */
369
+ function assertRestsOn(store, principal, scope, restsOn) {
370
+ const repo = partitionOf(store);
371
+ for (const dep of restsOn) {
372
+ if (dep.kind !== "record")
373
+ continue;
374
+ const refScope = dep.scope ?? scope;
375
+ if (!granted(principal, refScope))
376
+ throw new StateRefusal("outside-grants", `rests_on ${dep.id} points into ${scopePath(refScope)}, which is outside the principal's grants`);
377
+ const found = findRecord(store, dep.id);
378
+ if (!found) {
379
+ const held = scopePath(refScope) === scopePath(scope) || scopePath(refScope) === scopePath(repo) || (store.hasPrivate && refScope.kind !== "repository");
380
+ if (held)
381
+ throw new StateRefusal("conflict", `rests_on ${dep.id} is not on record in ${scopePath(refScope)}: a receipt rests on state that exists; write or re-read it first`, { incumbent_id: dep.id, reason: "rests_on target absent" });
382
+ continue; // a partition this store does not hold: a pointer, resolved by the reader
383
+ }
384
+ const actualScope = recordScope(found.record, repo);
385
+ if (scopePath(actualScope) !== scopePath(refScope))
386
+ throw new StateRefusal("conflict", `rests_on ${dep.id} lives in ${scopePath(actualScope)}, not ${scopePath(refScope)}`, { incumbent_id: dep.id, reason: "rests_on scope mismatch" });
387
+ const actualHash = stateHash(found.record);
388
+ if (actualHash !== dep.record_hash)
389
+ throw new StateRefusal("conflict", `rests_on ${dep.id} has moved: the record on file hashes ${actualHash}, not ${dep.record_hash} — re-read it and rest on what is current`, { incumbent_id: dep.id, reason: "rests_on hash mismatch" });
390
+ }
391
+ }
392
+ /** A commitment closed by a receipt: `closed_by` must name a succeeded/verified receipt the
393
+ * principal can see, and the status must be done — a closure is a fact that happened, never
394
+ * an opinion. Returns the receipt id when the closure is well-formed. */
395
+ function assertClosedBy(store, principal, commitment) {
396
+ if (!commitment.closed_by)
397
+ return null;
398
+ if (commitment.status !== "done")
399
+ throw new StateRefusal("malformed", `closed_by names a receipt but status is ${commitment.status}: a commitment closed by a receipt is done`);
400
+ const receipt = store.getRec("receipts", commitment.closed_by);
401
+ const scope = receipt ? recordScope(receipt, partitionOf(store)) : null;
402
+ if (!receipt || !scope || !granted(principal, scope)) {
403
+ throw new StateRefusal("conflict", `closed_by ${commitment.closed_by} is not a receipt on record within the principal's grants: a commitment is closed by an action that happened — write the receipt first, then close with its id`, { incumbent_id: commitment.closed_by, reason: "closed_by receipt absent" });
404
+ }
405
+ if (receipt.state !== "succeeded" && receipt.state !== "verified") {
406
+ throw new StateRefusal("conflict", `closed_by ${commitment.closed_by} is ${receipt.state}, not succeeded or verified: only an action that happened closes a commitment`, { incumbent_id: commitment.closed_by, reason: `closed_by receipt ${receipt.state}` });
407
+ }
408
+ return commitment.closed_by;
409
+ }
333
410
  /** Top-level fields whose canonical hash differs between two records, sorted. */
334
411
  function differingFields(a, b) {
335
412
  const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
@@ -418,15 +495,22 @@ export function writeState(store, input, opts = {}) {
418
495
  throw new StateRefusal("unsupported", `facet ${facet} is not a store kind`);
419
496
  const record = normalizeRecord(facet, request.scope, request.record, request.principal);
420
497
  const id = record.id;
498
+ /** The normalized PAYLOAD hash: what idempotency recognizes on a re-send. */
421
499
  const hash = stateHash(record);
422
500
  const ledger = readLedger(hunchDir, request.scope);
423
501
  const durability = () => opts.flush?.(isPrivate, `nuryel: write ${id}`) ?? "local";
424
- const result = (outcome, conflict = null, rid = id, rhash = hash) => WriteResultSchema.parse({ schema: STATE_WRITE_VERSION, record_id: rid, record_hash: rhash, durability: durability(), outcome, conflict, record: store.getRec(facet, rid) ?? record });
502
+ /** The result reports the record ON FILE and its hash the store may enrich a record on put
503
+ * (a private-mode decision gains `valid_from`), and a writer that goes on to rest a receipt
504
+ * on this record must hold the hash a reader will verify, never a pre-store one. */
505
+ const result = (outcome, conflict = null, rid = id) => {
506
+ const onFile = store.getRec(facet, rid) ?? record;
507
+ return WriteResultSchema.parse({ schema: STATE_WRITE_VERSION, record_id: rid, record_hash: stateHash(onFile), durability: durability(), outcome, conflict, record: onFile });
508
+ };
425
509
  // Idempotency: the same key replays the original; the same key with a different payload
426
510
  // is a refusal, never a second record.
427
511
  const seen = ledger.idempotency[request.idempotency_key];
428
512
  if (seen) {
429
- if (seen.record_hash === hash && seen.record_id === id)
513
+ if (seen.record_id === id && (seen.record_hash === hash || seen.payload_hash === hash))
430
514
  return result("replayed");
431
515
  // Say WHAT differs and what to do: a stable key with a varying payload (a timestamp, new
432
516
  // wording) is the trap every writer falls into once; the refusal must teach the way out.
@@ -437,7 +521,7 @@ export function writeState(store, input, opts = {}) {
437
521
  }
438
522
  const existing = store.recsInHome(facet, home).find((r) => r.id === id);
439
523
  if (existing && stateHash(existing) === hash) {
440
- appendChanges(hunchDir, request.scope, [], { key: request.idempotency_key, entry: { record_id: id, record_hash: hash, facet } }, now);
524
+ appendChanges(hunchDir, request.scope, [], { key: request.idempotency_key, entry: { record_id: id, record_hash: hash, payload_hash: hash, facet } }, now);
441
525
  return result("replayed");
442
526
  }
443
527
  if (existing && request.expected_version !== null) {
@@ -482,9 +566,19 @@ export function writeState(store, input, opts = {}) {
482
566
  supersedes = null; // already closed by this record: nothing to close again, no second "superseded" event
483
567
  }
484
568
  }
569
+ // The chain (Gate 4): a receipt names what it rested on, a closure names the receipt.
570
+ // Both are checked against the drawer, grants first, before anything lands.
571
+ if (facet === "receipts")
572
+ assertRestsOn(store, request.principal, request.scope, record.rests_on ?? []);
573
+ const closedBy = facet === "commitments" ? assertClosedBy(store, request.principal, record) : null;
485
574
  store.putCapture(facet, record, isPrivate);
575
+ /** What is on file now — the hash every event, ref and result carries. */
576
+ const onFileHash = stateHash(store.getRec(facet, id) ?? record);
486
577
  const changes = [];
487
- const cause = { kind: "write", principal: request.principal.id };
578
+ const cause = closedBy ? { kind: "receipt", receipt_id: closedBy } : request.cause ?? { kind: "write", principal: request.principal.id };
579
+ // A current derived statement written back as stale is an INVALIDATION, not an update: the
580
+ // ledger says so, and names the external pointer that moved when the writer gives one.
581
+ const invalidated = facet === "derived" && !!existing && existing.state === "current" && record.state === "stale";
488
582
  const invalidates = facet === "receipts" ? record.invalidates : [];
489
583
  const subject = subjectOf(facet, record);
490
584
  if (supersedes) {
@@ -494,8 +588,8 @@ export function writeState(store, input, opts = {}) {
494
588
  changes.push({ facet, record_id: supersedes, record_hash: stateHash(old), change: "superseded", subject: subjectOf(facet, old), invalidates: [], cause });
495
589
  }
496
590
  }
497
- changes.push({ facet, record_id: id, record_hash: hash, change: existing ? "updated" : "created", subject, invalidates, cause });
498
- appendChanges(hunchDir, request.scope, changes, { key: request.idempotency_key, entry: { record_id: id, record_hash: hash, facet } }, now);
591
+ changes.push({ facet, record_id: id, record_hash: onFileHash, change: invalidated ? "invalidated" : existing ? "updated" : "created", subject, invalidates: invalidated && subject ? [subject] : invalidates, cause });
592
+ appendChanges(hunchDir, request.scope, changes, { key: request.idempotency_key, entry: { record_id: id, record_hash: onFileHash, payload_hash: hash, facet } }, now);
499
593
  store.reindex();
500
594
  return result(supersedes ? "superseded" : existing ? "updated" : "created");
501
595
  }
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@davesheffer/hunch",
3
- "version": "1.28.0",
3
+ "version": "1.29.0",
4
4
  "mcpName": "io.github.davesheffer/hunch",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Dave Sheffer <dave.sheffer1@gmail.com>",
7
- "description": "Engineering memory and a deterministic Change Gate for AI-assisted codebases: decisions, rejected approaches, constraints, and bug lineage become portable context and opt-in enforcement for every MCP assistant.",
7
+ "description": "Deterministic state for organizations that run many probabilistic agents: decisions, receipts, commitments, constraints and bug lineage held in git, refused when they contradict, and delivered to every MCP assistant before it answers or edits code.",
8
8
  "homepage": "https://www.hunchmemory.com",
9
9
  "repository": {
10
10
  "type": "git",
package/server.json CHANGED
@@ -7,13 +7,13 @@
7
7
  "source": "github"
8
8
  },
9
9
  "websiteUrl": "https://www.hunchmemory.com",
10
- "version": "1.28.0",
10
+ "version": "1.29.0",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "npm",
14
14
  "registryBaseUrl": "https://registry.npmjs.org",
15
15
  "identifier": "@davesheffer/hunch",
16
- "version": "1.28.0",
16
+ "version": "1.29.0",
17
17
  "runtimeHint": "npx",
18
18
  "packageArguments": [
19
19
  {
@@ -9,6 +9,7 @@ const repos = [
9
9
  "riponcm/projectmem",
10
10
  "Cranot/roam-code",
11
11
  "blackwell-systems/knowing",
12
+ "markmhendrickson/neotoma",
12
13
  ];
13
14
 
14
15
  const distinctivePhrases = [