@davesheffer/hunch 1.27.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.
@@ -125,6 +125,12 @@ export function readState(store, input) {
125
125
  let stateOfRecord = null;
126
126
  const records = {};
127
127
  const denied = new Map();
128
+ // Union read against ONE store: every requested scope the principal lacks is named up front;
129
+ // the partitions actually read are declared so a caller never mistakes this for the union
130
+ // (a multi-partition host merges per-store answers with mergeReadResponses).
131
+ for (const s of request.scopes ?? [])
132
+ if (!granted(request.principal, s))
133
+ denied.set(scopePath(s), s);
128
134
  if (request.subject !== undefined) {
129
135
  const subject = request.subject;
130
136
  const current = [];
@@ -173,8 +179,10 @@ export function readState(store, input) {
173
179
  const scope = admit("receipts", r);
174
180
  if (!scope)
175
181
  continue;
176
- if (r.state === "succeeded" || r.state === "verified")
182
+ if (r.state === "succeeded" || r.state === "verified") {
177
183
  done.push(keep("receipts", r, scope));
184
+ dependsOn.push(...(r.rests_on ?? []));
185
+ }
178
186
  if (r.invalidates.includes(subject))
179
187
  invalidatedBy.add(r.id);
180
188
  }
@@ -187,6 +195,10 @@ export function readState(store, input) {
187
195
  continue;
188
196
  if ((c.status === "open" || c.status === "waiting") && c.valid_to == null)
189
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));
190
202
  }
191
203
  if (facets.has("derived"))
192
204
  for (const d of store.recs("derived")) {
@@ -228,10 +240,86 @@ export function readState(store, input) {
228
240
  state_of_record: stateOfRecord,
229
241
  denied_scopes: [...denied.values()],
230
242
  ...(stateOfRecord ? { records } : {}),
243
+ ...(request.scopes ? { scopes: [request.scope], receipts: [{ scope: request.scope, receipt_id: envelope.receipt_id }] } : {}),
231
244
  });
232
245
  assertReadWithinGrants(request.principal, response);
233
246
  return { response, envelope };
234
247
  }
248
+ /** Union read — one state_of_record across several partitions, each read by `readState` against
249
+ * its own store. Pure: no store, no grants decided here (every input already passed its own
250
+ * grant check). The primary's receipt, scope and envelope lead; refs concatenate (each already
251
+ * carries its partition), `depends_on` concatenates, `invalidated_by` is a sorted union, `records`
252
+ * merge by id (first writer wins — ids are identity, two copies are the same record),
253
+ * `denied_scopes` is the union of every partition's denied plus `extraDenied` (requested-but-
254
+ * ungranted scopes the host refused to open), `scopes` names the partitions read and `receipts`
255
+ * carries one delivery receipt per partition. Reusable by any host (HTTP today; MCP or CLI
256
+ * fronting several roots later). */
257
+ export function mergeReadResponses(primary, others, extraDenied = []) {
258
+ const all = [primary, ...others];
259
+ const scopes = new Map();
260
+ const receipts = new Map();
261
+ for (const r of all) {
262
+ for (const s of r.scopes ?? [r.scope])
263
+ if (!scopes.has(scopePath(s)))
264
+ scopes.set(scopePath(s), s);
265
+ for (const x of r.receipts ?? [{ scope: r.scope, receipt_id: r.receipt_id }])
266
+ if (!receipts.has(scopePath(x.scope)))
267
+ receipts.set(scopePath(x.scope), x);
268
+ }
269
+ const denied = new Map();
270
+ for (const s of [...all.flatMap((r) => r.denied_scopes), ...extraDenied])
271
+ if (!scopes.has(scopePath(s)) && !denied.has(scopePath(s)))
272
+ denied.set(scopePath(s), s);
273
+ const sors = all.map((r) => r.state_of_record).filter((s) => s !== null);
274
+ let stateOfRecord = null;
275
+ const records = {};
276
+ if (sors.length) {
277
+ const refKey = (ref) => `${ref.facet}|${scopePath(ref.scope)}|${ref.id}`;
278
+ const dedupeRefs = (pick) => {
279
+ const seen = new Set();
280
+ const out = [];
281
+ for (const ref of sors.flatMap(pick)) {
282
+ const k = refKey(ref);
283
+ if (!seen.has(k)) {
284
+ seen.add(k);
285
+ out.push(ref);
286
+ }
287
+ }
288
+ return out;
289
+ };
290
+ const seenDeps = new Set();
291
+ const dependsOn = [];
292
+ for (const dep of sors.flatMap((s) => s.depends_on)) {
293
+ const k = stateHash(dep);
294
+ if (!seenDeps.has(k)) {
295
+ seenDeps.add(k);
296
+ dependsOn.push(dep);
297
+ }
298
+ }
299
+ stateOfRecord = {
300
+ subject: sors[0].subject,
301
+ current: dedupeRefs((s) => s.current),
302
+ in_force: dedupeRefs((s) => s.in_force),
303
+ done: dedupeRefs((s) => s.done),
304
+ depends_on: dependsOn,
305
+ invalidated_by: [...new Set(sors.flatMap((s) => s.invalidated_by))].sort(),
306
+ };
307
+ for (const r of all)
308
+ for (const [id, record] of Object.entries(r.records ?? {}))
309
+ if (!(id in records))
310
+ records[id] = record;
311
+ }
312
+ return ReadResponseSchema.parse({
313
+ schema: STATE_READ_VERSION,
314
+ receipt_id: primary.receipt_id,
315
+ scope: primary.scope,
316
+ state_of_record: stateOfRecord,
317
+ denied_scopes: [...denied.values()],
318
+ ...(stateOfRecord ? { records } : {}),
319
+ scopes: [...scopes.values()],
320
+ receipts: [...receipts.values()],
321
+ });
322
+ }
235
323
  /** What a record is ABOUT, for subscribers filtering by subject. Mirrors the read verb's matching. */
236
324
  function subjectOf(facet, record) {
237
325
  const r = record;
@@ -248,6 +336,77 @@ function subjectOf(facet, record) {
248
336
  default: return undefined;
249
337
  }
250
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
+ }
251
410
  /** Top-level fields whose canonical hash differs between two records, sorted. */
252
411
  function differingFields(a, b) {
253
412
  const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
@@ -336,15 +495,22 @@ export function writeState(store, input, opts = {}) {
336
495
  throw new StateRefusal("unsupported", `facet ${facet} is not a store kind`);
337
496
  const record = normalizeRecord(facet, request.scope, request.record, request.principal);
338
497
  const id = record.id;
498
+ /** The normalized PAYLOAD hash: what idempotency recognizes on a re-send. */
339
499
  const hash = stateHash(record);
340
500
  const ledger = readLedger(hunchDir, request.scope);
341
501
  const durability = () => opts.flush?.(isPrivate, `nuryel: write ${id}`) ?? "local";
342
- 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
+ };
343
509
  // Idempotency: the same key replays the original; the same key with a different payload
344
510
  // is a refusal, never a second record.
345
511
  const seen = ledger.idempotency[request.idempotency_key];
346
512
  if (seen) {
347
- if (seen.record_hash === hash && seen.record_id === id)
513
+ if (seen.record_id === id && (seen.record_hash === hash || seen.payload_hash === hash))
348
514
  return result("replayed");
349
515
  // Say WHAT differs and what to do: a stable key with a varying payload (a timestamp, new
350
516
  // wording) is the trap every writer falls into once; the refusal must teach the way out.
@@ -355,7 +521,7 @@ export function writeState(store, input, opts = {}) {
355
521
  }
356
522
  const existing = store.recsInHome(facet, home).find((r) => r.id === id);
357
523
  if (existing && stateHash(existing) === hash) {
358
- 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);
359
525
  return result("replayed");
360
526
  }
361
527
  if (existing && request.expected_version !== null) {
@@ -382,9 +548,37 @@ export function writeState(store, input, opts = {}) {
382
548
  }
383
549
  if (supersedes === id)
384
550
  supersedes = null;
551
+ // A supersede target must still be open. Two writers racing to replace the same incumbent
552
+ // would otherwise both succeed and leave two current records for one subject (fnd_eeb8bf3cb8);
553
+ // the loser is told which record is current now, so it can re-read and supersede that one.
554
+ // The writer that closed the incumbent itself (same id, new key) is not a loser.
555
+ if (supersedes && facet !== "decisions") {
556
+ const incumbent = store.getRec(facet, supersedes);
557
+ if (incumbent && "valid_to" in incumbent && incumbent.valid_to !== null) {
558
+ const subject = subjectOf(facet, incumbent);
559
+ const open = store.recsInHome(facet, home)
560
+ .filter((r) => subjectOf(facet, r) === subject && r.valid_to === null)
561
+ .map((r) => r.id).sort();
562
+ if (!open.includes(id)) {
563
+ const current = open.length ? `the current ${facet} record for ${subject ?? "that subject"} is ${open.join(", ")}` : `no ${facet} record for ${subject ?? "that subject"} is open now`;
564
+ throw new StateRefusal("conflict", `supersedes ${supersedes} was already superseded (window closed ${String(incumbent.valid_to)}); ${current}: re-read and supersede that one`, { incumbent_id: open[0] ?? supersedes, reason: "supersede target already closed" });
565
+ }
566
+ supersedes = null; // already closed by this record: nothing to close again, no second "superseded" event
567
+ }
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;
385
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);
386
577
  const changes = [];
387
- 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";
388
582
  const invalidates = facet === "receipts" ? record.invalidates : [];
389
583
  const subject = subjectOf(facet, record);
390
584
  if (supersedes) {
@@ -394,8 +588,8 @@ export function writeState(store, input, opts = {}) {
394
588
  changes.push({ facet, record_id: supersedes, record_hash: stateHash(old), change: "superseded", subject: subjectOf(facet, old), invalidates: [], cause });
395
589
  }
396
590
  }
397
- changes.push({ facet, record_id: id, record_hash: hash, change: existing ? "updated" : "created", subject, invalidates, cause });
398
- 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);
399
593
  store.reindex();
400
594
  return result(supersedes ? "superseded" : existing ? "updated" : "created");
401
595
  }
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@davesheffer/hunch",
3
- "version": "1.27.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.27.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.27.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 = [