@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.
- package/README.md +21 -17
- package/dist/cli/index.js +97 -7
- package/dist/cli/serve.js +1 -1
- package/dist/core/format.js +11 -0
- package/dist/core/groundingLag.js +82 -0
- package/dist/core/stateContract.js +24 -0
- package/dist/core/stateDelivery.js +179 -0
- package/dist/core/stateRecords.js +12 -1
- package/dist/integrations/hooks.js +41 -0
- package/dist/integrations/providers.js +8 -0
- package/dist/mcp/server.js +39 -10
- package/dist/serve/app.js +24 -5
- package/dist/store/changeLedger.js +5 -0
- package/dist/store/hunchStore.js +131 -4
- package/dist/store/schema.js +1 -0
- package/dist/store/stateBinding.js +201 -7
- package/package.json +2 -2
- package/server.json +2 -2
- package/tooling/competitive-watch.mjs +1 -0
|
@@ -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
|
-
|
|
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
|
|
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:
|
|
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.
|
|
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": "
|
|
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.
|
|
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.
|
|
16
|
+
"version": "1.29.0",
|
|
17
17
|
"runtimeHint": "npx",
|
|
18
18
|
"packageArguments": [
|
|
19
19
|
{
|