@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
|
@@ -26,7 +26,7 @@ import { decisionId } from "../core/ids.js";
|
|
|
26
26
|
import { ENTITY_KINDS, SCHEMAS } from "../core/types.js";
|
|
27
27
|
import { captureConflicts, isLive } from "../core/topics.js";
|
|
28
28
|
import { buildDeliveryEnvelope } from "../core/delivery.js";
|
|
29
|
-
import { STATE_CAPABILITIES, STATE_CONTRACT_VERSION, STATE_FACETS, STATE_READ_VERSION, STATE_SUBSCRIBE_VERSION, STATE_WRITE_VERSION, ReadRequestSchema, ReadResponseSchema, WriteRequestSchema, WriteResultSchema, SubscribeRequestSchema, ChangeEventSchema, RecordsRequestSchema, RecordsResponseSchema, STATE_RECORDS_VERSION, ScopeSchema, scopePath, stateHash, actionReceiptId, commitmentId, derivedId, assertReadWithinGrants, assertWriteWellFormed, assertDerivedState, } from "../core/stateContract.js";
|
|
29
|
+
import { STATE_CAPABILITIES, STATE_CONTRACT_VERSION, STATE_FACETS, STATE_READ_VERSION, STATE_SUBSCRIBE_VERSION, STATE_WRITE_VERSION, ReadRequestSchema, ReadResponseSchema, WriteRequestSchema, WriteResultSchema, SubscribeRequestSchema, ChangeEventSchema, RecordsRequestSchema, RecordsResponseSchema, STATE_RECORDS_VERSION, ScopeSchema, scopePath, stateHash, actionReceiptId, commitmentId, derivedId, externalKey, subjectOfRef, assertReadWithinGrants, assertWriteWellFormed, assertDerivedState, isHumanConfirmed, } from "../core/stateContract.js";
|
|
30
30
|
/** A typed refusal. `code` is stable for bindings; `conflict` names the incumbent when one exists. */
|
|
31
31
|
export class StateRefusal extends Error {
|
|
32
32
|
code;
|
|
@@ -81,7 +81,9 @@ export function capabilities(store) {
|
|
|
81
81
|
}
|
|
82
82
|
// ---- homing --------------------------------------------------------------------------------
|
|
83
83
|
const granted = (principal, scope) => principal.grants.some((g) => scopePath(g) === scopePath(scope));
|
|
84
|
-
|
|
84
|
+
/** Where a scope's records and ledger live in this store (exported for the replay check, which
|
|
85
|
+
* must read the SAME home the write verb wrote — never a second routing rule). */
|
|
86
|
+
export function stateHomeFor(store, scope) {
|
|
85
87
|
const own = partitionOf(store);
|
|
86
88
|
if (scopePath(scope) === scopePath(own)) {
|
|
87
89
|
// The store IS this partition: its capture home (public `.hunch/`, or the overlay in shared mode).
|
|
@@ -104,6 +106,62 @@ const recordScope = (record, repo) => {
|
|
|
104
106
|
function refOf(facet, record, scope) {
|
|
105
107
|
return { facet, id: record.id, record_hash: stateHash(record), scope };
|
|
106
108
|
}
|
|
109
|
+
/** Follow `merged_into` to the entity that stands for this one now (cycle-safe, bounded). */
|
|
110
|
+
function survivorOf(byId, entity) {
|
|
111
|
+
let current = entity;
|
|
112
|
+
const seen = new Set([current.id]);
|
|
113
|
+
while (current.lifecycle === "retired" && current.merged_into) {
|
|
114
|
+
const next = byId.get(current.merged_into);
|
|
115
|
+
if (!next || seen.has(next.id))
|
|
116
|
+
break;
|
|
117
|
+
seen.add(next.id);
|
|
118
|
+
current = next;
|
|
119
|
+
}
|
|
120
|
+
return current;
|
|
121
|
+
}
|
|
122
|
+
/** The entities in the principal's grants that stand for an external key or an entity id — active
|
|
123
|
+
* ones directly, retired-and-merged ones through the survivor they name. */
|
|
124
|
+
function entityIndex(store, principal, repo) {
|
|
125
|
+
const byId = new Map();
|
|
126
|
+
for (const e of store.recs("entities"))
|
|
127
|
+
if (granted(principal, recordScope(e, repo)))
|
|
128
|
+
byId.set(e.id, e);
|
|
129
|
+
const survivor = (e) => survivorOf(byId, e);
|
|
130
|
+
const byKey = new Map();
|
|
131
|
+
const bySubject = new Map();
|
|
132
|
+
for (const e of byId.values()) {
|
|
133
|
+
const stands = e.lifecycle === "active" ? e : (e.lifecycle === "retired" && e.merged_into ? survivor(e) : null);
|
|
134
|
+
if (!stands || stands.lifecycle !== "active")
|
|
135
|
+
continue;
|
|
136
|
+
for (const ref of e.refs) {
|
|
137
|
+
if (!byKey.has(externalKey(ref)) || e.lifecycle === "active")
|
|
138
|
+
byKey.set(externalKey(ref), stands);
|
|
139
|
+
if (!bySubject.has(subjectOfRef(ref)) || e.lifecycle === "active")
|
|
140
|
+
bySubject.set(subjectOfRef(ref), stands);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return { byKey, bySubject, byId, survivor };
|
|
144
|
+
}
|
|
145
|
+
/** The names one subject is filed under: itself, the entity that stands for it (through merges),
|
|
146
|
+
* every entity merged into that one, and every key any of them carries. Explicit refs only. */
|
|
147
|
+
function subjectAliases(store, principal, repo, subject) {
|
|
148
|
+
const aliases = new Set([subject]);
|
|
149
|
+
const { bySubject, byId, survivor } = entityIndex(store, principal, repo);
|
|
150
|
+
const named = bySubject.get(subject) ?? byId.get(subject);
|
|
151
|
+
if (!named)
|
|
152
|
+
return aliases;
|
|
153
|
+
const stands = survivor(named);
|
|
154
|
+
if (stands.lifecycle !== "active")
|
|
155
|
+
return aliases;
|
|
156
|
+
for (const e of byId.values()) {
|
|
157
|
+
if (e.id !== stands.id && survivor(e).id !== stands.id)
|
|
158
|
+
continue;
|
|
159
|
+
aliases.add(e.id);
|
|
160
|
+
for (const ref of e.refs)
|
|
161
|
+
aliases.add(subjectOfRef(ref));
|
|
162
|
+
}
|
|
163
|
+
return aliases;
|
|
164
|
+
}
|
|
107
165
|
/** read — the system-of-record answer for a subject, under the delivery envelope's receipt.
|
|
108
166
|
* Grants are the first predicate on every candidate; a matching record in a scope the
|
|
109
167
|
* principal lacks is NAMED in denied_scopes and never described. */
|
|
@@ -133,6 +191,11 @@ export function readState(store, input) {
|
|
|
133
191
|
denied.set(scopePath(s), s);
|
|
134
192
|
if (request.subject !== undefined) {
|
|
135
193
|
const subject = request.subject;
|
|
194
|
+
// Subject identity by external reference: a read for an external record's key (`event:26904`,
|
|
195
|
+
// `customer:Site:7`) also finds what is filed under the entity that carries that ref, and a
|
|
196
|
+
// read for the entity id finds what was filed under its keys — one explicit hop, grants first.
|
|
197
|
+
const aliases = subjectAliases(store, request.principal, repo, subject);
|
|
198
|
+
const isSubject = (s) => s !== undefined && aliases.has(s);
|
|
136
199
|
const current = [];
|
|
137
200
|
const inForce = [];
|
|
138
201
|
const done = [];
|
|
@@ -173,30 +236,36 @@ export function readState(store, input) {
|
|
|
173
236
|
}
|
|
174
237
|
if (facets.has("receipts"))
|
|
175
238
|
for (const r of store.recs("receipts")) {
|
|
176
|
-
const targets = r.id === subject || r.invalidates.
|
|
239
|
+
const targets = r.id === subject || r.invalidates.some(isSubject) || isSubject(subjectOfRef(r.target));
|
|
177
240
|
if (!targets)
|
|
178
241
|
continue;
|
|
179
242
|
const scope = admit("receipts", r);
|
|
180
243
|
if (!scope)
|
|
181
244
|
continue;
|
|
182
|
-
if (r.state === "succeeded" || r.state === "verified")
|
|
245
|
+
if (r.state === "succeeded" || r.state === "verified") {
|
|
183
246
|
done.push(keep("receipts", r, scope));
|
|
184
|
-
|
|
247
|
+
dependsOn.push(...(r.rests_on ?? []));
|
|
248
|
+
}
|
|
249
|
+
if (r.invalidates.some(isSubject))
|
|
185
250
|
invalidatedBy.add(r.id);
|
|
186
251
|
}
|
|
187
252
|
if (facets.has("commitments"))
|
|
188
253
|
for (const c of store.recs("commitments")) {
|
|
189
|
-
if (c.subject
|
|
254
|
+
if (!isSubject(c.subject) && c.id !== subject)
|
|
190
255
|
continue;
|
|
191
256
|
const scope = admit("commitments", c);
|
|
192
257
|
if (!scope)
|
|
193
258
|
continue;
|
|
194
259
|
if ((c.status === "open" || c.status === "waiting") && c.valid_to == null)
|
|
195
260
|
inForce.push(keep("commitments", c, scope));
|
|
261
|
+
// A commitment fulfilled by a receipt is part of what HAPPENED for the subject: it
|
|
262
|
+
// leaves in_force and joins done beside the receipt that closed it (the chain's last link).
|
|
263
|
+
else if (c.status === "done" && c.closed_by)
|
|
264
|
+
done.push(keep("commitments", c, scope));
|
|
196
265
|
}
|
|
197
266
|
if (facets.has("derived"))
|
|
198
267
|
for (const d of store.recs("derived")) {
|
|
199
|
-
if (d.subject
|
|
268
|
+
if (!isSubject(d.subject) && d.id !== subject)
|
|
200
269
|
continue;
|
|
201
270
|
const scope = admit("derived", d);
|
|
202
271
|
if (!scope)
|
|
@@ -208,7 +277,7 @@ export function readState(store, input) {
|
|
|
208
277
|
}
|
|
209
278
|
if (facets.has("entities"))
|
|
210
279
|
for (const e of store.recs("entities")) {
|
|
211
|
-
if (e.id
|
|
280
|
+
if (!isSubject(e.id))
|
|
212
281
|
continue;
|
|
213
282
|
const scope = admit("entities", e);
|
|
214
283
|
if (!scope)
|
|
@@ -218,7 +287,7 @@ export function readState(store, input) {
|
|
|
218
287
|
}
|
|
219
288
|
if (facets.has("relationships"))
|
|
220
289
|
for (const r of store.recs("relationships")) {
|
|
221
|
-
if (r.from
|
|
290
|
+
if (!isSubject(r.from) && !isSubject(r.to))
|
|
222
291
|
continue;
|
|
223
292
|
const scope = admit("relationships", r);
|
|
224
293
|
if (!scope)
|
|
@@ -330,6 +399,125 @@ function subjectOf(facet, record) {
|
|
|
330
399
|
default: return undefined;
|
|
331
400
|
}
|
|
332
401
|
}
|
|
402
|
+
/** Which facet a record id belongs to, from its prefix; `null` for a kind-qualified entity id
|
|
403
|
+
* or an unknown shape (those are looked up across every facet). */
|
|
404
|
+
function facetOfId(id) {
|
|
405
|
+
const prefix = /^([a-z]+)_/.exec(id)?.[1];
|
|
406
|
+
switch (prefix) {
|
|
407
|
+
case "dec": return "decisions";
|
|
408
|
+
case "con": return "constraints";
|
|
409
|
+
case "bug": return "bugs";
|
|
410
|
+
case "fnd": return "findings";
|
|
411
|
+
case "nrc": return "receipts";
|
|
412
|
+
case "ncm": return "commitments";
|
|
413
|
+
case "nds": return "derived";
|
|
414
|
+
case "edge": return "relationships";
|
|
415
|
+
default: return null;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
/** Find a record by id in this store, with the facet it lives in. */
|
|
419
|
+
function findRecord(store, id) {
|
|
420
|
+
const facets = facetOfId(id) ? [facetOfId(id)] : [...STATE_FACETS];
|
|
421
|
+
for (const facet of facets) {
|
|
422
|
+
const record = store.getRec(facet, id);
|
|
423
|
+
if (record)
|
|
424
|
+
return { facet, record };
|
|
425
|
+
}
|
|
426
|
+
return null;
|
|
427
|
+
}
|
|
428
|
+
/** A receipt's `rests_on` record refs: one in a partition this store holds must exist there
|
|
429
|
+
* with the hash the writer saw (a stale hash means the decision moved — re-read); one in a
|
|
430
|
+
* partition the store does not hold is a pointer for the reader to resolve. Grants first: a
|
|
431
|
+
* ref into a partition the principal is not granted is refused by scope, never by content. */
|
|
432
|
+
function assertRestsOn(store, principal, scope, restsOn) {
|
|
433
|
+
const repo = partitionOf(store);
|
|
434
|
+
for (const dep of restsOn) {
|
|
435
|
+
if (dep.kind !== "record")
|
|
436
|
+
continue;
|
|
437
|
+
const refScope = dep.scope ?? scope;
|
|
438
|
+
if (!granted(principal, refScope))
|
|
439
|
+
throw new StateRefusal("outside-grants", `rests_on ${dep.id} points into ${scopePath(refScope)}, which is outside the principal's grants`);
|
|
440
|
+
const found = findRecord(store, dep.id);
|
|
441
|
+
if (!found) {
|
|
442
|
+
const held = scopePath(refScope) === scopePath(scope) || scopePath(refScope) === scopePath(repo) || (store.hasPrivate && refScope.kind !== "repository");
|
|
443
|
+
if (held)
|
|
444
|
+
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" });
|
|
445
|
+
continue; // a partition this store does not hold: a pointer, resolved by the reader
|
|
446
|
+
}
|
|
447
|
+
const actualScope = recordScope(found.record, repo);
|
|
448
|
+
if (scopePath(actualScope) !== scopePath(refScope))
|
|
449
|
+
throw new StateRefusal("conflict", `rests_on ${dep.id} lives in ${scopePath(actualScope)}, not ${scopePath(refScope)}`, { incumbent_id: dep.id, reason: "rests_on scope mismatch" });
|
|
450
|
+
const actualHash = stateHash(found.record);
|
|
451
|
+
if (actualHash !== dep.record_hash)
|
|
452
|
+
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" });
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
/** A commitment closed by a receipt: `closed_by` must name a succeeded/verified receipt the
|
|
456
|
+
* principal can see, and the status must be done — a closure is a fact that happened, never
|
|
457
|
+
* an opinion. Returns the receipt id when the closure is well-formed. */
|
|
458
|
+
function assertClosedBy(store, principal, commitment) {
|
|
459
|
+
if (!commitment.closed_by)
|
|
460
|
+
return null;
|
|
461
|
+
if (commitment.status !== "done")
|
|
462
|
+
throw new StateRefusal("malformed", `closed_by names a receipt but status is ${commitment.status}: a commitment closed by a receipt is done`);
|
|
463
|
+
const receipt = store.getRec("receipts", commitment.closed_by);
|
|
464
|
+
const scope = receipt ? recordScope(receipt, partitionOf(store)) : null;
|
|
465
|
+
if (!receipt || !scope || !granted(principal, scope)) {
|
|
466
|
+
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" });
|
|
467
|
+
}
|
|
468
|
+
if (receipt.state !== "succeeded" && receipt.state !== "verified") {
|
|
469
|
+
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}` });
|
|
470
|
+
}
|
|
471
|
+
return commitment.closed_by;
|
|
472
|
+
}
|
|
473
|
+
/** one-entity-per-external-ref. An entity: no other active entity in the partition may carry one
|
|
474
|
+
* of its external keys (the incumbent is named; merge/split are explicit, later). A commitment or
|
|
475
|
+
* derived statement: its subject may not be the external key of a record an entity already
|
|
476
|
+
* carries — the entity's id is the subject, and the refusal names it (the writer re-derives;
|
|
477
|
+
* ids derive from the subject, so nothing is rewritten under it). A subject no entity claims
|
|
478
|
+
* stays a free-form key: explicit refs only, no guessing. */
|
|
479
|
+
function assertExternalIdentity(store, principal, scope, facet, record) {
|
|
480
|
+
const repo = partitionOf(store);
|
|
481
|
+
const inPartition = (e) => scopePath(recordScope(e, repo)) === scopePath(scope);
|
|
482
|
+
if (facet === "entities") {
|
|
483
|
+
const entity = record;
|
|
484
|
+
if (entity.merged_into !== undefined) {
|
|
485
|
+
const target = store.recs("entities").find((e) => e.id === entity.merged_into);
|
|
486
|
+
if (!target || !inPartition(target))
|
|
487
|
+
throw new StateRefusal("conflict", `merged_into ${entity.merged_into} is not an entity on record in ${scopePath(scope)}: a merge names a survivor that exists — write it first`, { incumbent_id: entity.merged_into, reason: "merge survivor absent" });
|
|
488
|
+
if (!granted(principal, recordScope(target, repo)))
|
|
489
|
+
throw new StateRefusal("outside-grants", `merged_into ${entity.merged_into} is outside the principal's grants`);
|
|
490
|
+
if (target.lifecycle !== "active")
|
|
491
|
+
throw new StateRefusal("conflict", `merged_into ${entity.merged_into} is ${target.lifecycle}${target.merged_into ? ` (merged into ${target.merged_into})` : ""}: the survivor of a merge is an active entity — merge into the one that stands now`, { incumbent_id: target.merged_into ?? target.id, reason: "merge survivor not active" });
|
|
492
|
+
}
|
|
493
|
+
if (entity.lifecycle !== "active")
|
|
494
|
+
return;
|
|
495
|
+
const keys = new Set(entity.refs.map(externalKey));
|
|
496
|
+
for (const other of store.recs("entities")) {
|
|
497
|
+
if (other.id === entity.id || other.lifecycle !== "active" || !inPartition(other))
|
|
498
|
+
continue;
|
|
499
|
+
const shared = other.refs.map(externalKey).find((k) => keys.has(k));
|
|
500
|
+
if (shared)
|
|
501
|
+
throw new StateRefusal("conflict", `${shared} is already carried by entity ${other.id} in ${scopePath(scope)}: one external record is one entity — write under ${other.id}, or retire it first (merge and split are explicit)`, { incumbent_id: other.id, reason: "one-entity-per-external-ref" });
|
|
502
|
+
}
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
if (facet !== "commitments" && facet !== "derived")
|
|
506
|
+
return;
|
|
507
|
+
const subject = record.subject;
|
|
508
|
+
const { bySubject, byId, survivor } = entityIndex(store, principal, repo);
|
|
509
|
+
const byKey = bySubject.get(subject);
|
|
510
|
+
if (byKey && byKey.id !== subject && inPartition(byKey)) {
|
|
511
|
+
throw new StateRefusal("identity", `subject ${subject} is the external key of entity ${byKey.id} in ${scopePath(scope)}: the entity's id is the subject — re-derive with subject ${byKey.id}`, { incumbent_id: byKey.id, reason: "subject is an entity's external key" });
|
|
512
|
+
}
|
|
513
|
+
const named = byId.get(subject);
|
|
514
|
+
if (named && named.lifecycle === "retired" && named.merged_into && inPartition(named)) {
|
|
515
|
+
const stands = survivor(named);
|
|
516
|
+
if (stands.id !== named.id && stands.lifecycle === "active") {
|
|
517
|
+
throw new StateRefusal("identity", `subject ${subject} was merged into ${stands.id}: new state goes under the survivor — re-derive with subject ${stands.id}`, { incumbent_id: stands.id, reason: "subject was merged" });
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
}
|
|
333
521
|
/** Top-level fields whose canonical hash differs between two records, sorted. */
|
|
334
522
|
function differingFields(a, b) {
|
|
335
523
|
const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
|
|
@@ -411,22 +599,30 @@ export function writeState(store, input, opts = {}) {
|
|
|
411
599
|
catch (e) {
|
|
412
600
|
throw new StateRefusal(/grants/.test(e.message) ? "outside-grants" : "malformed", e.message);
|
|
413
601
|
}
|
|
414
|
-
const { home, hunchDir, isPrivate } =
|
|
602
|
+
const { home, hunchDir, isPrivate } = stateHomeFor(store, request.scope);
|
|
415
603
|
const now = (opts.now ?? (() => new Date()))().toISOString();
|
|
416
604
|
const facet = request.facet;
|
|
417
605
|
if (!ENTITY_KINDS.includes(facet))
|
|
418
606
|
throw new StateRefusal("unsupported", `facet ${facet} is not a store kind`);
|
|
419
607
|
const record = normalizeRecord(facet, request.scope, request.record, request.principal);
|
|
420
608
|
const id = record.id;
|
|
609
|
+
assertExternalIdentity(store, request.principal, request.scope, facet, record);
|
|
610
|
+
/** The normalized PAYLOAD hash: what idempotency recognizes on a re-send. */
|
|
421
611
|
const hash = stateHash(record);
|
|
422
612
|
const ledger = readLedger(hunchDir, request.scope);
|
|
423
613
|
const durability = () => opts.flush?.(isPrivate, `nuryel: write ${id}`) ?? "local";
|
|
424
|
-
|
|
614
|
+
/** The result reports the record ON FILE and its hash — the store may enrich a record on put
|
|
615
|
+
* (a private-mode decision gains `valid_from`), and a writer that goes on to rest a receipt
|
|
616
|
+
* on this record must hold the hash a reader will verify, never a pre-store one. */
|
|
617
|
+
const result = (outcome, conflict = null, rid = id) => {
|
|
618
|
+
const onFile = store.getRec(facet, rid) ?? record;
|
|
619
|
+
return WriteResultSchema.parse({ schema: STATE_WRITE_VERSION, record_id: rid, record_hash: stateHash(onFile), durability: durability(), outcome, conflict, record: onFile });
|
|
620
|
+
};
|
|
425
621
|
// Idempotency: the same key replays the original; the same key with a different payload
|
|
426
622
|
// is a refusal, never a second record.
|
|
427
623
|
const seen = ledger.idempotency[request.idempotency_key];
|
|
428
624
|
if (seen) {
|
|
429
|
-
if (seen.record_hash === hash
|
|
625
|
+
if (seen.record_id === id && (seen.record_hash === hash || seen.payload_hash === hash))
|
|
430
626
|
return result("replayed");
|
|
431
627
|
// Say WHAT differs and what to do: a stable key with a varying payload (a timestamp, new
|
|
432
628
|
// wording) is the trap every writer falls into once; the refusal must teach the way out.
|
|
@@ -437,7 +633,7 @@ export function writeState(store, input, opts = {}) {
|
|
|
437
633
|
}
|
|
438
634
|
const existing = store.recsInHome(facet, home).find((r) => r.id === id);
|
|
439
635
|
if (existing && stateHash(existing) === hash) {
|
|
440
|
-
appendChanges(hunchDir, request.scope, [], { key: request.idempotency_key, entry: { record_id: id, record_hash: hash, facet } }, now);
|
|
636
|
+
appendChanges(hunchDir, request.scope, [], { key: request.idempotency_key, entry: { record_id: id, record_hash: hash, payload_hash: hash, facet } }, now);
|
|
441
637
|
return result("replayed");
|
|
442
638
|
}
|
|
443
639
|
if (existing && request.expected_version !== null) {
|
|
@@ -447,8 +643,42 @@ export function writeState(store, input, opts = {}) {
|
|
|
447
643
|
if (!ok)
|
|
448
644
|
throw new StateRefusal("conflict", `expected_version does not match the incumbent ${id}`, { incumbent_id: id, reason: "expected_version mismatch" });
|
|
449
645
|
}
|
|
450
|
-
//
|
|
646
|
+
// human-correction-outranks-agent-writes: what a human confirmed, an agent does not rewrite.
|
|
647
|
+
// Allowed for an agent: a replay (the same facts, the tier downgrade aside), a derived statement
|
|
648
|
+
// written back stale with the external cause that moved (the writer's currentness duty), a
|
|
649
|
+
// commitment closed by a receipt on record (a fact that happened) — both keep the human's
|
|
650
|
+
// provenance on the record. Everything else on a human-confirmed incumbent — in place or by
|
|
651
|
+
// supersession — is refused with the incumbent named.
|
|
451
652
|
let supersedes = request.supersedes ?? null;
|
|
653
|
+
if (request.principal.kind !== "human") {
|
|
654
|
+
const guard = (incumbent, how) => {
|
|
655
|
+
if (!incumbent || !isHumanConfirmed(incumbent))
|
|
656
|
+
return null;
|
|
657
|
+
const incumbentId = String(incumbent.id);
|
|
658
|
+
const changed = how === "overwrite" ? differingFields(incumbent, record).filter((f) => f !== "provenance") : ["a new record"];
|
|
659
|
+
if (how === "overwrite") {
|
|
660
|
+
if (changed.length === 0)
|
|
661
|
+
return "replay";
|
|
662
|
+
const staleWithCause = facet === "derived" && incumbent.state === "current" && record.state === "stale" && request.cause?.kind === "external"
|
|
663
|
+
&& changed.every((f) => f === "state" || f === "valid_to");
|
|
664
|
+
const closedByReceipt = facet === "commitments" && !!record.closed_by && record.status === "done"
|
|
665
|
+
&& changed.every((f) => f === "status" || f === "closed_by" || f === "valid_to");
|
|
666
|
+
if (staleWithCause || closedByReceipt)
|
|
667
|
+
return "keep-provenance";
|
|
668
|
+
}
|
|
669
|
+
throw new StateRefusal("conflict", `${incumbentId} was confirmed by a human; ${request.principal.kind === "agent" ? "an agent" : "a service"} principal may not ${how} it (differs in: ${changed.join(", ")}). A human writes the change, or the agent leaves the record as the human left it.`, { incumbent_id: incumbentId, reason: "human-confirmed incumbent" });
|
|
670
|
+
};
|
|
671
|
+
const verdict = guard(existing, "overwrite");
|
|
672
|
+
if (verdict === "replay") {
|
|
673
|
+
appendChanges(hunchDir, request.scope, [], { key: request.idempotency_key, entry: { record_id: id, record_hash: stateHash(existing), payload_hash: hash, facet } }, now);
|
|
674
|
+
return result("replayed");
|
|
675
|
+
}
|
|
676
|
+
if (verdict === "keep-provenance")
|
|
677
|
+
record.provenance = existing.provenance;
|
|
678
|
+
if (supersedes && supersedes !== id)
|
|
679
|
+
guard(store.getRec(facet, supersedes), "supersede");
|
|
680
|
+
}
|
|
681
|
+
// one-live-decision-per-topic — refuse with the incumbent named; supersession is explicit.
|
|
452
682
|
if (facet === "decisions") {
|
|
453
683
|
const d = record;
|
|
454
684
|
if (d.topic && d.status === "accepted") {
|
|
@@ -482,10 +712,22 @@ export function writeState(store, input, opts = {}) {
|
|
|
482
712
|
supersedes = null; // already closed by this record: nothing to close again, no second "superseded" event
|
|
483
713
|
}
|
|
484
714
|
}
|
|
715
|
+
// The chain (Gate 4): a receipt names what it rested on, a closure names the receipt.
|
|
716
|
+
// Both are checked against the drawer, grants first, before anything lands.
|
|
717
|
+
if (facet === "receipts")
|
|
718
|
+
assertRestsOn(store, request.principal, request.scope, record.rests_on ?? []);
|
|
719
|
+
const closedBy = facet === "commitments" ? assertClosedBy(store, request.principal, record) : null;
|
|
485
720
|
store.putCapture(facet, record, isPrivate);
|
|
721
|
+
/** What is on file now — the hash every event, ref and result carries. */
|
|
722
|
+
const onFileHash = stateHash(store.getRec(facet, id) ?? record);
|
|
486
723
|
const changes = [];
|
|
487
|
-
const cause = { kind: "write", principal: request.principal.id };
|
|
724
|
+
const cause = closedBy ? { kind: "receipt", receipt_id: closedBy } : request.cause ?? { kind: "write", principal: request.principal.id };
|
|
725
|
+
// A current derived statement written back as stale is an INVALIDATION, not an update: the
|
|
726
|
+
// ledger says so, and names the external pointer that moved when the writer gives one.
|
|
727
|
+
const invalidated = facet === "derived" && !!existing && existing.state === "current" && record.state === "stale";
|
|
488
728
|
const invalidates = facet === "receipts" ? record.invalidates : [];
|
|
729
|
+
// An entity leaving service is a `retired` change (a merge names the survivor in the record).
|
|
730
|
+
const retired = facet === "entities" && record.lifecycle === "retired" && (!existing || existing.lifecycle !== "retired");
|
|
489
731
|
const subject = subjectOf(facet, record);
|
|
490
732
|
if (supersedes) {
|
|
491
733
|
const closed = closeWindow(store, facet, supersedes, id, now, isPrivate);
|
|
@@ -494,8 +736,8 @@ export function writeState(store, input, opts = {}) {
|
|
|
494
736
|
changes.push({ facet, record_id: supersedes, record_hash: stateHash(old), change: "superseded", subject: subjectOf(facet, old), invalidates: [], cause });
|
|
495
737
|
}
|
|
496
738
|
}
|
|
497
|
-
changes.push({ facet, record_id: id, record_hash:
|
|
498
|
-
appendChanges(hunchDir, request.scope, changes, { key: request.idempotency_key, entry: { record_id: id, record_hash: hash, facet } }, now);
|
|
739
|
+
changes.push({ facet, record_id: id, record_hash: onFileHash, change: invalidated ? "invalidated" : retired ? "retired" : existing ? "updated" : "created", subject, invalidates: invalidated && subject ? [subject] : invalidates, cause });
|
|
740
|
+
appendChanges(hunchDir, request.scope, changes, { key: request.idempotency_key, entry: { record_id: id, record_hash: onFileHash, payload_hash: hash, facet } }, now);
|
|
499
741
|
store.reindex();
|
|
500
742
|
return result(supersedes ? "superseded" : existing ? "updated" : "created");
|
|
501
743
|
}
|
|
@@ -506,7 +748,7 @@ export function subscribeState(store, input) {
|
|
|
506
748
|
const request = SubscribeRequestSchema.parse(input);
|
|
507
749
|
if (!granted(request.principal, request.scope))
|
|
508
750
|
throw new StateRefusal("outside-grants", `scope ${scopePath(request.scope)} is outside the principal's grants`);
|
|
509
|
-
const { hunchDir } =
|
|
751
|
+
const { hunchDir } = stateHomeFor(store, request.scope);
|
|
510
752
|
const ledger = readLedger(hunchDir, request.scope);
|
|
511
753
|
const facets = request.facets ? new Set(request.facets) : null;
|
|
512
754
|
const subjects = request.subjects ? new Set(request.subjects) : null;
|
|
@@ -31,6 +31,7 @@ import { tmpdir } from "node:os";
|
|
|
31
31
|
import { dirname, join } from "node:path";
|
|
32
32
|
import { writeFileAtomic } from "../core/io.js";
|
|
33
33
|
import { summarizeDiff } from "../extractors/diff.js";
|
|
34
|
+
import { languageFor } from "../extractors/languages.js";
|
|
34
35
|
const IS_WIN = process.platform === "win32";
|
|
35
36
|
/**
|
|
36
37
|
* Run a command, optionally feeding `input` to its stdin, and resolve its
|
|
@@ -614,6 +615,9 @@ export class DeterministicProvider {
|
|
|
614
615
|
const dirs = topDirs(input.files);
|
|
615
616
|
const a = input.analysis;
|
|
616
617
|
const summary = a ? summarizeDiff(a) : "";
|
|
618
|
+
// input.files can be markdown-only (issue #12) — don't claim "code" for a
|
|
619
|
+
// commit that touched none.
|
|
620
|
+
const noun = input.files.some((f) => languageFor(f) !== null) ? "code" : "content";
|
|
617
621
|
const verb = /^(add|introduce|create|feat)/i.test(input.subject) ? "introduced"
|
|
618
622
|
: /^(remove|delete|drop)/i.test(input.subject) ? "removed"
|
|
619
623
|
: /^(refactor|rework|restructure)/i.test(input.subject) ? "refactored"
|
|
@@ -630,12 +634,12 @@ export class DeterministicProvider {
|
|
|
630
634
|
// We extracted real structure → a bit more trustworthy than a blind heuristic.
|
|
631
635
|
const informative = !!(a && (a.addedSymbols.length || a.removedSymbols.length || a.changedSymbols.length || a.addedDeps.length || a.removedDeps.length));
|
|
632
636
|
return {
|
|
633
|
-
title: input.subject ||
|
|
637
|
+
title: input.subject || `${cap(noun)} change`,
|
|
634
638
|
context: [input.body, summary && `What changed: ${summary}.`].filter(Boolean).join(" ").slice(0, 500)
|
|
635
639
|
|| `Touched ${input.files.length} file(s) across ${dirs.join(", ") || "the repo"}.`,
|
|
636
640
|
decision: summary
|
|
637
641
|
? `${cap(verb)} ${dirs.join(", ") || "the repo"}: ${summary}.`
|
|
638
|
-
: `${cap(verb)}
|
|
642
|
+
: `${cap(verb)} ${noun} in ${dirs.join(", ") || "the repo"} (${input.files.length} file(s)).`,
|
|
639
643
|
consequences,
|
|
640
644
|
alternatives_rejected: [],
|
|
641
645
|
// advisory either way, but real extraction earns a touch more confidence
|
|
@@ -5,7 +5,7 @@ import { decisionId, bugId, constraintId } from "../core/ids.js";
|
|
|
5
5
|
import { commitCoveredBy } from "../core/dupdetect.js";
|
|
6
6
|
import { pathMatchesGlob } from "../core/glob.js";
|
|
7
7
|
import { draftTripwires, knownRepoDeps } from "./tripwires.js";
|
|
8
|
-
import {
|
|
8
|
+
import { isSubstantive } from "../extractors/languages.js";
|
|
9
9
|
// "chore(deps):" is anchored separately (not via \b) because \b requires a
|
|
10
10
|
// word/non-word transition, and the character after the closing ")" is ":" or a
|
|
11
11
|
// space — both non-word — so no boundary ever fires there.
|
|
@@ -26,14 +26,16 @@ export function isTrivialSubject(meta) {
|
|
|
26
26
|
* deterministic. Any structural change (symbol/dependency delta), non-trivial
|
|
27
27
|
* churn, several files, OR an explanatory commit body signals a real decision
|
|
28
28
|
* worth the model. Everything below (typo/tweak/one-liner with no message) falls
|
|
29
|
-
* to the free deterministic draft — shallower but honestly low-confidence.
|
|
30
|
-
|
|
29
|
+
* to the free deterministic draft — shallower but honestly low-confidence.
|
|
30
|
+
* `files` is whatever the caller is synthesizing from (code and/or markdown, per
|
|
31
|
+
* isSubstantive) — the line/file-count checks are format-agnostic. */
|
|
32
|
+
export function isSignificant(meta, a, files) {
|
|
31
33
|
const structural = a.addedSymbols.length + a.removedSymbols.length + a.changedSymbols.length + a.addedDeps.length + a.removedDeps.length;
|
|
32
34
|
if (structural > 0)
|
|
33
35
|
return true;
|
|
34
36
|
if (a.addedLines + a.removedLines >= SIG_MIN_LINES)
|
|
35
37
|
return true;
|
|
36
|
-
if (
|
|
38
|
+
if (files.length >= 3)
|
|
37
39
|
return true;
|
|
38
40
|
if (meta.body.trim().length >= SIG_MIN_BODY)
|
|
39
41
|
return true;
|
|
@@ -50,9 +52,28 @@ export async function syncCommit(store, root, sha, opts = {}) {
|
|
|
50
52
|
return { status: "skipped", reason: "commit not found" };
|
|
51
53
|
if (isTrivialSubject(meta))
|
|
52
54
|
return { status: "skipped", reason: `trivial subject: ${meta.subject}` };
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
55
|
+
// .hunch/** is Hunch's OWN store — commitDiff already excludes it from the diff
|
|
56
|
+
// CONTENT via DIFF_NOISE ("circular noise": re-synthesizing a commit that wrote
|
|
57
|
+
// it would draft a decision about Hunch's own bookkeeping). That guard never
|
|
58
|
+
// covered this file-LIST gate, and before #12 it didn't need to: languageFor()
|
|
59
|
+
// already returned null for .hunch/**'s JSON and for the .md grounding docs
|
|
60
|
+
// flushCapture regenerates alongside it (AGENTS.md/CLAUDE.md/copilot-instructions.md),
|
|
61
|
+
// so such a commit failed "no code files changed" by coincidence. Once markdown
|
|
62
|
+
// became substantive input those grounding docs alone made the commit eligible.
|
|
63
|
+
// Checked by PATH, not commit-message convention (flushCapture's exact subject
|
|
64
|
+
// wording lives as separate literals in mcp/server.ts/cli/index.ts and could
|
|
65
|
+
// drift independently of any subject regex here) — a human editing AGENTS.md on
|
|
66
|
+
// its own, with no .hunch/** change alongside it, stays fully synthesis-eligible.
|
|
67
|
+
if (meta.files.some((f) => pathMatchesGlob(f, "**/.hunch/**"))) {
|
|
68
|
+
return { status: "skipped", reason: "touches Hunch's own store (.hunch/**) — circular, never synthesis input" };
|
|
69
|
+
}
|
|
70
|
+
// Substantive, not just parseable: markdown carries the *why* in a docs/ADR repo
|
|
71
|
+
// just as legitimately as a .ts diff does, even though it has no symbol graph
|
|
72
|
+
// (issue #12). languageFor() stays the parseability question for the symbol/dep
|
|
73
|
+
// extraction inside analyzeDiff below.
|
|
74
|
+
const substantiveFiles = meta.files.filter((f) => isSubstantive(f));
|
|
75
|
+
if (substantiveFiles.length === 0)
|
|
76
|
+
return { status: "skipped", reason: "no code or markdown files changed" };
|
|
56
77
|
// Seed the id from the COMMIT (stable across runs), not the LLM-generated title
|
|
57
78
|
// (which varies) — so re-syncing a commit updates rather than dupes.
|
|
58
79
|
const id = decisionId(meta.sha);
|
|
@@ -83,7 +104,7 @@ export async function syncCommit(store, root, sha, opts = {}) {
|
|
|
83
104
|
// review triage measured 7 of 14 queued drafts as exactly this. A recent
|
|
84
105
|
// human-confirmed decision claiming this commit's files → skip the draft (and
|
|
85
106
|
// the subscription call). Recency-windowed; --force overrides.
|
|
86
|
-
const covered = commitCoveredBy(
|
|
107
|
+
const covered = commitCoveredBy(substantiveFiles, meta.subject, store.recs("decisions"), Date.now());
|
|
87
108
|
if (covered && !opts.force) {
|
|
88
109
|
return {
|
|
89
110
|
status: "skipped",
|
|
@@ -111,10 +132,10 @@ export async function syncCommit(store, root, sha, opts = {}) {
|
|
|
111
132
|
? new DeterministicProvider()
|
|
112
133
|
: opts.deep
|
|
113
134
|
? (await selectEnsemble({ samples: opts.samples })) ?? await selectProvider({ root })
|
|
114
|
-
: opts.force || opts.verify || isSignificant(meta, analysis,
|
|
135
|
+
: opts.force || opts.verify || isSignificant(meta, analysis, substantiveFiles)
|
|
115
136
|
? await selectProvider({ root })
|
|
116
137
|
: new DeterministicProvider();
|
|
117
|
-
const input = { subject: meta.subject, body: meta.body, files:
|
|
138
|
+
const input = { subject: meta.subject, body: meta.body, files: substantiveFiles, diff, analysis };
|
|
118
139
|
let draft = await draftDecisionSafe(provider, input);
|
|
119
140
|
// The Critic pass: audit the draft against the commit, PRUNE unsupported alternatives
|
|
120
141
|
// (BEFORE they scaffold tripwires below) and consequences, and lower confidence on weak
|
|
@@ -150,13 +171,13 @@ export async function syncCommit(store, root, sha, opts = {}) {
|
|
|
150
171
|
const branchTag = branch ? [`branch:${branch}`] : [];
|
|
151
172
|
const components = store.json.loadAll("components");
|
|
152
173
|
const relatedComponents = components
|
|
153
|
-
.filter((c) =>
|
|
174
|
+
.filter((c) => substantiveFiles.some((f) => c.paths.some((g) => pathMatchesGlob(f, g))))
|
|
154
175
|
.map((c) => c.id);
|
|
155
176
|
// Surface any do-not-break constraints this commit's files touch (DESIGN §4
|
|
156
177
|
// "constraint touched" flag) right in the decision context, with evidence.
|
|
157
178
|
const touchedConstraints = store.json
|
|
158
179
|
.loadAll("constraints")
|
|
159
|
-
.filter((c) =>
|
|
180
|
+
.filter((c) => substantiveFiles.some((f) => c.scope.some((g) => pathMatchesGlob(f, g))));
|
|
160
181
|
const constraintNote = touchedConstraints.length
|
|
161
182
|
? ` Touches invariant(s): ${touchedConstraints.map((c) => `${c.id} (${c.statement})`).join("; ")}.`
|
|
162
183
|
: "";
|
|
@@ -183,9 +204,9 @@ export async function syncCommit(store, root, sha, opts = {}) {
|
|
|
183
204
|
// → never block until confirmed via `hunch review --accept` (dec_a466655539).
|
|
184
205
|
rejected_tripwires: existing?.rejected_tripwires?.length
|
|
185
206
|
? existing.rejected_tripwires
|
|
186
|
-
: draftTripwires(draft.alternatives_rejected,
|
|
207
|
+
: draftTripwires(draft.alternatives_rejected, substantiveFiles, knownRepoDeps(root)),
|
|
187
208
|
related_components: relatedComponents,
|
|
188
|
-
related_files:
|
|
209
|
+
related_files: substantiveFiles,
|
|
189
210
|
supersedes: existing?.supersedes ?? null,
|
|
190
211
|
superseded_by: existing?.superseded_by ?? null,
|
|
191
212
|
caused_by_bug: existing?.caused_by_bug ?? null,
|
|
@@ -201,7 +222,7 @@ export async function syncCommit(store, root, sha, opts = {}) {
|
|
|
201
222
|
provenance: {
|
|
202
223
|
source: draft.source,
|
|
203
224
|
confidence: draft.confidence,
|
|
204
|
-
evidence: [`commit:${meta.shortSha}`, synthEvidence, ...branchTag, ...
|
|
225
|
+
evidence: [`commit:${meta.shortSha}`, synthEvidence, ...branchTag, ...substantiveFiles.slice(0, 8)],
|
|
205
226
|
last_verified: new Date().toISOString(), // when the Hunch last re-derived this
|
|
206
227
|
},
|
|
207
228
|
date: meta.date, // the commit date
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@davesheffer/hunch",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.30.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.30.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.30.0",
|
|
17
17
|
"runtimeHint": "npx",
|
|
18
18
|
"packageArguments": [
|
|
19
19
|
{
|