@davesheffer/hunch 1.30.0 → 1.31.1

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.
@@ -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, externalKey, subjectOfRef, assertReadWithinGrants, assertWriteWellFormed, assertDerivedState, isHumanConfirmed, } 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, relationshipId, 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;
@@ -169,6 +169,9 @@ export function readState(store, input) {
169
169
  const request = ReadRequestSchema.parse(input);
170
170
  if (!granted(request.principal, request.scope))
171
171
  throw new StateRefusal("outside-grants", `scope ${scopePath(request.scope)} is outside the principal's grants`);
172
+ if (request.observed_page && (request.subject === undefined || request.scopes !== undefined || (request.facets && !request.facets.includes('derived')))) {
173
+ throw new StateRefusal('malformed', 'observation pages require a subject, the derived facet and a single partition without scopes');
174
+ }
172
175
  const repo = partitionOf(store);
173
176
  const facets = new Set(request.facets ?? STATE_FACETS);
174
177
  const target = request.task ?? request.subject ?? scopePath(request.scope);
@@ -196,9 +199,23 @@ export function readState(store, input) {
196
199
  // read for the entity id finds what was filed under its keys — one explicit hop, grants first.
197
200
  const aliases = subjectAliases(store, request.principal, repo, subject);
198
201
  const isSubject = (s) => s !== undefined && aliases.has(s);
202
+ // One hop only. Do not broaden aliases: a linked observation does not merge subjects,
203
+ // bring unrelated facts, receipts or commitments, or traverse another relationship.
204
+ const linkedObservations = new Map();
205
+ for (const r of store.recs("relationships")) {
206
+ if (!granted(request.principal, r.scope) || scopePath(r.scope) !== scopePath(request.scope))
207
+ continue;
208
+ if (r.type !== "observation_about" || r.lifecycle === "retired" || !isSubject(r.to) || !r.observation_hash)
209
+ continue;
210
+ const hashes = linkedObservations.get(r.from) ?? new Set();
211
+ hashes.add(r.observation_hash);
212
+ linkedObservations.set(r.from, hashes);
213
+ }
199
214
  const current = [];
200
215
  const inForce = [];
201
216
  const done = [];
217
+ const observations = [];
218
+ let relationshipsTruncated = false;
202
219
  const dependsOn = [];
203
220
  const invalidatedBy = new Set();
204
221
  /** authorization-before-retrieval: the grant check runs before the record is examined. */
@@ -265,15 +282,26 @@ export function readState(store, input) {
265
282
  }
266
283
  if (facets.has("derived"))
267
284
  for (const d of store.recs("derived")) {
268
- if (!isSubject(d.subject) && d.id !== subject)
269
- continue;
270
285
  const scope = admit("derived", d);
271
286
  if (!scope)
272
287
  continue;
288
+ if (request.observed_page && scopePath(scope) !== scopePath(request.scope))
289
+ continue;
290
+ const direct = isSubject(d.subject) || d.id === subject;
291
+ const linked = scopePath(scope) === scopePath(request.scope) && linkedObservations.get(d.id)?.has(stateHash(d));
292
+ if (!direct && !linked)
293
+ continue;
294
+ if (!direct) {
295
+ if (d.state === "unknown" && d.valid_to == null)
296
+ observations.push(d);
297
+ continue;
298
+ }
273
299
  if (d.state === "current" && d.valid_to == null) {
274
300
  current.push(keep("derived", d, scope));
275
301
  dependsOn.push(...d.dependencies);
276
302
  }
303
+ else if (d.state === "unknown" && d.valid_to == null)
304
+ observations.push(d);
277
305
  }
278
306
  if (facets.has("entities"))
279
307
  for (const e of store.recs("entities")) {
@@ -287,14 +315,39 @@ export function readState(store, input) {
287
315
  }
288
316
  if (facets.has("relationships"))
289
317
  for (const r of store.recs("relationships")) {
318
+ if (r.lifecycle === "retired")
319
+ continue;
290
320
  if (!isSubject(r.from) && !isSubject(r.to))
291
321
  continue;
292
322
  const scope = admit("relationships", r);
293
323
  if (!scope)
294
324
  continue;
325
+ if (current.length >= 256) {
326
+ relationshipsTruncated = true;
327
+ continue;
328
+ }
295
329
  current.push(keep("relationships", r, scope));
296
330
  }
297
- stateOfRecord = { subject, current, in_force: inForce, done, depends_on: dependsOn, invalidated_by: [...invalidatedBy].sort() };
331
+ observations.sort((a, b) => Date.parse(b.computed_at) - Date.parse(a.computed_at) || a.id.localeCompare(b.id));
332
+ let offset = 0;
333
+ let page;
334
+ if (request.observed_page) {
335
+ // Fingerprint the authorized membership AND record contents. A change between
336
+ // pages is a conflict, never a silently skipped or duplicated observation.
337
+ const snapshot_hash = stateHash({ scope: request.scope, subject, observations });
338
+ const cursor = request.observed_page.cursor;
339
+ if (cursor && cursor.snapshot_hash !== snapshot_hash)
340
+ throw new StateRefusal('conflict', 'observations changed between pages; restart from the first page');
341
+ offset = cursor?.offset ?? 0;
342
+ if (offset > observations.length)
343
+ throw new StateRefusal('malformed', 'observation cursor is outside this snapshot');
344
+ page = { snapshot_hash, total: observations.length, next_cursor: offset + 64 < observations.length ? { snapshot_hash, offset: offset + 64 } : null };
345
+ }
346
+ const observed = observations.slice(offset, offset + 64).map(d => keep("derived", d, recordScope(d, repo)));
347
+ stateOfRecord = { subject, current, in_force: inForce, done, depends_on: dependsOn, invalidated_by: [...invalidatedBy].sort(),
348
+ ...(relationshipsTruncated ? { relationships_truncated: true } : {}),
349
+ ...(observed.length || page ? { observed, observed_truncated: observations.length > offset + observed.length } : {}),
350
+ ...(page ? { observed_page: page } : {}) };
298
351
  }
299
352
  const response = ReadResponseSchema.parse({
300
353
  schema: STATE_READ_VERSION,
@@ -364,6 +417,9 @@ export function mergeReadResponses(primary, others, extraDenied = []) {
364
417
  current: dedupeRefs((s) => s.current),
365
418
  in_force: dedupeRefs((s) => s.in_force),
366
419
  done: dedupeRefs((s) => s.done),
420
+ ...(sors.some(s => s.relationships_truncated) ? { relationships_truncated: true } : {}),
421
+ ...(sors.some(s => s.observed?.length) ? { observed: dedupeRefs(s => s.observed ?? []).slice(0, 64),
422
+ observed_truncated: sors.some(s => s.observed_truncated) || dedupeRefs(s => s.observed ?? []).length > 64 } : {}),
367
423
  depends_on: dependsOn,
368
424
  invalidated_by: [...new Set(sors.flatMap((s) => s.invalidated_by))].sort(),
369
425
  };
@@ -390,7 +446,7 @@ function subjectOf(facet, record) {
390
446
  case "commitments":
391
447
  case "derived": return typeof r.subject === "string" ? r.subject : undefined;
392
448
  case "entities": return typeof r.id === "string" ? r.id : undefined;
393
- case "relationships": return typeof r.from === "string" ? r.from : undefined;
449
+ case "relationships": return r.type === "observation_about" && typeof r.to === "string" ? r.to : typeof r.from === "string" ? r.from : undefined;
394
450
  case "receipts": {
395
451
  const t = r.target;
396
452
  return t?.object_type && t.object_key ? `${t.object_type}:${t.object_key}` : undefined;
@@ -565,6 +621,8 @@ function normalizeRecord(facet, scope, raw, principal) {
565
621
  expectedId = commitmentId(record);
566
622
  else if (facet === "derived")
567
623
  expectedId = derivedId(record);
624
+ else if (facet === "relationships")
625
+ expectedId = relationshipId(String(record.from), String(record.to), String(record.type));
568
626
  else if (facet === "decisions" && typeof record.id !== "string")
569
627
  expectedId = decisionId(String(record.topic ?? record.title ?? ""));
570
628
  }
@@ -602,20 +660,46 @@ export function writeState(store, input, opts = {}) {
602
660
  const { home, hunchDir, isPrivate } = stateHomeFor(store, request.scope);
603
661
  const now = (opts.now ?? (() => new Date()))().toISOString();
604
662
  const facet = request.facet;
663
+ const getHere = (id) => facet === "derived" || facet === "receipts" || facet === "commitments"
664
+ ? store.getStateDirect(facet, id, home)
665
+ : home === "private" ? store.getPrivateRec(facet, id) : store.json.get(facet, id);
605
666
  if (!ENTITY_KINDS.includes(facet))
606
667
  throw new StateRefusal("unsupported", `facet ${facet} is not a store kind`);
607
668
  const record = normalizeRecord(facet, request.scope, request.record, request.principal);
669
+ let replayLink;
670
+ if (facet === "relationships" && record.type === "observation_about") {
671
+ const link = record;
672
+ const observation = store.getStateDirect("derived", link.from, home);
673
+ if (!observation || scopePath(observation.scope) !== scopePath(request.scope) || !granted(request.principal, observation.scope))
674
+ throw new StateRefusal("conflict", "observation is absent from the granted partition");
675
+ if (!observation.transform_version.startsWith("agent-capture/1:"))
676
+ throw new StateRefusal("malformed", "only captured observations can be linked");
677
+ if (link.lifecycle !== "retired" && (observation.state !== "unknown" || observation.valid_to != null || stateHash(observation) !== link.observation_hash))
678
+ throw new StateRefusal("conflict", "observation changed or is no longer eligible; re-read before linking");
679
+ const prior = getHere(link.id);
680
+ if (prior?.lifecycle === "retired" && link.lifecycle !== "retired" && request.expected_version === null)
681
+ throw new StateRefusal("conflict", "retired observation link requires an explicit expected_version to reactivate");
682
+ // Repeated reads and different agents do not rewrite an identical association.
683
+ // Preserve its first author/evidence time and avoid index/Git work on replay.
684
+ if (prior && prior.from === link.from && prior.to === link.to && prior.type === link.type && prior.reason === link.reason
685
+ && prior.observation_hash === link.observation_hash && prior.lifecycle === link.lifecycle
686
+ && prior.evidence && link.evidence && externalKey(prior.evidence) === externalKey(link.evidence) && prior.evidence.content_hash === link.evidence.content_hash) {
687
+ replayLink = prior;
688
+ }
689
+ }
608
690
  const id = record.id;
609
691
  assertExternalIdentity(store, request.principal, request.scope, facet, record);
610
692
  /** The normalized PAYLOAD hash: what idempotency recognizes on a re-send. */
611
693
  const hash = stateHash(record);
612
- const ledger = readLedger(hunchDir, request.scope);
694
+ const ledger = opts.ledgerCache?.ledger ?? readLedger(hunchDir, request.scope);
695
+ if (opts.ledgerCache)
696
+ opts.ledgerCache.ledger = ledger;
613
697
  const durability = () => opts.flush?.(isPrivate, `nuryel: write ${id}`) ?? "local";
614
698
  /** The result reports the record ON FILE and its hash — the store may enrich a record on put
615
699
  * (a private-mode decision gains `valid_from`), and a writer that goes on to rest a receipt
616
700
  * on this record must hold the hash a reader will verify, never a pre-store one. */
617
701
  const result = (outcome, conflict = null, rid = id) => {
618
- const onFile = store.getRec(facet, rid) ?? record;
702
+ const onFile = getHere(rid) ?? record;
619
703
  return WriteResultSchema.parse({ schema: STATE_WRITE_VERSION, record_id: rid, record_hash: stateHash(onFile), durability: durability(), outcome, conflict, record: onFile });
620
704
  };
621
705
  // Idempotency: the same key replays the original; the same key with a different payload
@@ -631,9 +715,14 @@ export function writeState(store, input, opts = {}) {
631
715
  const where = differing.length ? ` — this payload differs in: ${differing.join(", ")}` : (seen.record_id !== id ? ` — this payload derives a different identity (${id})` : "");
632
716
  throw new StateRefusal("idempotency", `idempotency key "${request.idempotency_key}" was already used for ${seen.record_id}${where}. A key names ONE request payload: re-send the original payload to replay it, or use a new key to write this payload (the record keeps its derived id and is updated in place).`, { incumbent_id: seen.record_id, reason: "idempotency key reused with a different payload" });
633
717
  }
634
- const existing = store.recsInHome(facet, home).find((r) => r.id === id);
718
+ const existing = getHere(id);
719
+ if (facet === 'derived') {
720
+ const review = record.review;
721
+ if (review && stateHash(review) !== stateHash(existing?.review ?? null) && review.by !== request.principal.id)
722
+ throw new StateRefusal('malformed', 'reviewer must be the initiating principal');
723
+ }
635
724
  if (existing && stateHash(existing) === hash) {
636
- appendChanges(hunchDir, request.scope, [], { key: request.idempotency_key, entry: { record_id: id, record_hash: hash, payload_hash: hash, facet } }, now);
725
+ appendChanges(hunchDir, request.scope, [], { key: request.idempotency_key, entry: { record_id: id, record_hash: hash, payload_hash: hash, facet } }, now, opts.ledgerCache?.ledger);
637
726
  return result("replayed");
638
727
  }
639
728
  if (existing && request.expected_version !== null) {
@@ -643,6 +732,11 @@ export function writeState(store, input, opts = {}) {
643
732
  if (!ok)
644
733
  throw new StateRefusal("conflict", `expected_version does not match the incumbent ${id}`, { incumbent_id: id, reason: "expected_version mismatch" });
645
734
  }
735
+ if (replayLink) {
736
+ const recordHash = stateHash(replayLink);
737
+ appendChanges(hunchDir, request.scope, [], { key: request.idempotency_key, entry: { record_id: replayLink.id, record_hash: recordHash, payload_hash: hash, facet } }, now, opts.ledgerCache?.ledger);
738
+ return WriteResultSchema.parse({ schema: STATE_WRITE_VERSION, record_id: replayLink.id, record_hash: recordHash, record: replayLink, outcome: "replayed", conflict: null, durability: "local" });
739
+ }
646
740
  // human-correction-outranks-agent-writes: what a human confirmed, an agent does not rewrite.
647
741
  // Allowed for an agent: a replay (the same facts, the tier downgrade aside), a derived statement
648
742
  // written back stale with the external cause that moved (the writer's currentness duty), a
@@ -670,7 +764,7 @@ export function writeState(store, input, opts = {}) {
670
764
  };
671
765
  const verdict = guard(existing, "overwrite");
672
766
  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);
767
+ appendChanges(hunchDir, request.scope, [], { key: request.idempotency_key, entry: { record_id: id, record_hash: stateHash(existing), payload_hash: hash, facet } }, now, opts.ledgerCache?.ledger);
674
768
  return result("replayed");
675
769
  }
676
770
  if (verdict === "keep-provenance")
@@ -719,15 +813,15 @@ export function writeState(store, input, opts = {}) {
719
813
  const closedBy = facet === "commitments" ? assertClosedBy(store, request.principal, record) : null;
720
814
  store.putCapture(facet, record, isPrivate);
721
815
  /** What is on file now — the hash every event, ref and result carries. */
722
- const onFileHash = stateHash(store.getRec(facet, id) ?? record);
816
+ const onFileHash = stateHash(getHere(id) ?? record);
723
817
  const changes = [];
724
818
  const cause = closedBy ? { kind: "receipt", receipt_id: closedBy } : request.cause ?? { kind: "write", principal: request.principal.id };
725
819
  // A current derived statement written back as stale is an INVALIDATION, not an update: the
726
820
  // 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";
821
+ const invalidated = facet === "derived" && !!existing && (existing.state === "current" || existing.state === "unknown") && record.state === "stale";
728
822
  const invalidates = facet === "receipts" ? record.invalidates : [];
729
823
  // 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");
824
+ const retired = (facet === "entities" || facet === "relationships") && record.lifecycle === "retired" && (!existing || existing.lifecycle !== "retired");
731
825
  const subject = subjectOf(facet, record);
732
826
  if (supersedes) {
733
827
  const closed = closeWindow(store, facet, supersedes, id, now, isPrivate);
@@ -737,8 +831,9 @@ export function writeState(store, input, opts = {}) {
737
831
  }
738
832
  }
739
833
  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);
741
- store.reindex();
834
+ appendChanges(hunchDir, request.scope, changes, { key: request.idempotency_key, entry: { record_id: id, record_hash: onFileHash, payload_hash: hash, facet } }, now, opts.ledgerCache?.ledger);
835
+ if (!opts.deferReindex)
836
+ store.reindex();
742
837
  return result(supersedes ? "superseded" : existing ? "updated" : "created");
743
838
  }
744
839
  // ---- subscribe -----------------------------------------------------------------------------
@@ -0,0 +1,145 @@
1
+ import { CaptureRequestSchema, CaptureBatchRequestSchema, STATE_CAPTURE_VERSION, STATE_CAPTURE_BATCH_VERSION, STATE_WRITE_VERSION, assertDerivedState, captureTransform, derivedId, normalizeAssertion, canonicalObjectKey, externalKey, scopePath, stateHash } from "../core/stateContract.js";
2
+ import { isCredentialFreeValue } from "../core/provenance.js";
3
+ import { StateRefusal, stateHomeFor, writeState } from "./stateBinding.js";
4
+ /** The caller selects relevant atomic claims; this deterministic boundary checks evidence
5
+ * fidelity and deduplication. It does not pretend to prove semantic entailment or relevance.
6
+ * Both bindings hold the partition write lock over lookup AND write. */
7
+ export function captureState(store, input, opts = {}) {
8
+ const request = CaptureRequestSchema.parse(input);
9
+ if (!request.principal.grants.some(s => scopePath(s) === scopePath(request.scope)))
10
+ throw new StateRefusal("outside-grants", "capture scope is outside the principal's grants");
11
+ const { home } = stateHomeFor(store, request.scope);
12
+ const statement = normalizeAssertion(request.statement);
13
+ if (![statement, request.relevance.reason, ...request.evidence.map(e => e.excerpt)].every(isCredentialFreeValue))
14
+ throw new StateRefusal("malformed", "captured content must not contain credential material");
15
+ const evidence = request.evidence.map(e => {
16
+ if (!e.source_text.includes(e.excerpt))
17
+ throw new StateRefusal("malformed", "every excerpt must occur exactly in its supplied source text");
18
+ const hash = opts.sourceHashes?.get(e.source_text) ?? stateHash(e.source_text);
19
+ opts.sourceHashes?.set(e.source_text, hash);
20
+ if (e.ref.content_hash && e.ref.content_hash !== hash)
21
+ throw new StateRefusal("malformed", "source text does not match its declared content hash");
22
+ return { ref: { ...e.ref, object_key: canonicalObjectKey(e.ref.object_key), content_hash: hash }, excerpt: e.excerpt };
23
+ });
24
+ // Ignore read time, writer and unrelated source text when deduplicating an assertion.
25
+ // A different excerpt, statement, source identity or subject is a distinct observation.
26
+ const transform = captureTransform(request.scope, request.subject, statement, evidence.map(e => ({ source: externalKey(e.ref), excerpt: e.excerpt })));
27
+ const id = derivedId({ scope: request.scope, subject: request.subject, transform_version: transform, dependencies: [] });
28
+ const incumbent = store.getStateDirect("derived", id, home);
29
+ if (incumbent) {
30
+ assertDerivedState(incumbent);
31
+ if (incumbent.transform_version !== transform || scopePath(incumbent.scope) !== scopePath(request.scope) || incumbent.subject !== request.subject)
32
+ throw new StateRefusal("conflict", "capture identity collision; incumbent preserved");
33
+ // Never revive stale or human-corrected evidence, replace its author, or claim a new
34
+ // observation because another agent saw the same excerpt. Return what actually exists.
35
+ return { schema: STATE_WRITE_VERSION, record_id: incumbent.id, record_hash: stateHash(incumbent), durability: "local", outcome: "replayed", conflict: null, record: incumbent };
36
+ }
37
+ const refs = [...new Map(evidence.map(e => [stateHash(e.ref), e.ref])).entries()].sort(([a], [b]) => a.localeCompare(b)).map(([, ref]) => ref);
38
+ const content = JSON.stringify({ schema: "nuryel.observation-content/1", statement, relevance: request.relevance,
39
+ evidence: evidence.map(e => ({ source: externalKey(e.ref), excerpt: e.excerpt })), captured_by: request.principal.id });
40
+ const record = {
41
+ schema: "nuryel.derived/1", scope: request.scope, subject: request.subject, content, content_hash: stateHash(content),
42
+ dependencies: refs.map(ref => ({ kind: "external", ref })), transform_version: transform,
43
+ computed_at: (opts.now ?? (() => new Date()))().toISOString(), valid_to: null, state: "unknown",
44
+ provenance: { source: "agent_recorded", confidence: 0.8, evidence: [`captured by ${request.principal.id}`, ...refs.map(externalKey)] },
45
+ };
46
+ return writeState(store, { schema: STATE_WRITE_VERSION, principal: request.principal, scope: request.scope, facet: "derived", record, idempotency_key: transform }, opts);
47
+ }
48
+ /** Bounded partial-success batch. The caller holds the same partition lock as writeState.
49
+ * Every result has its input index; a refused claim never hides a later valid new detail.
50
+ * A duplicate-only batch performs no record, ledger, index or Git writes. */
51
+ export function captureBatchState(store, input, opts = {}) {
52
+ const request = CaptureBatchRequestSchema.parse(input);
53
+ if (!request.principal.grants.some(s => scopePath(s) === scopePath(request.scope)))
54
+ throw new StateRefusal("outside-grants", "capture scope is outside the principal's grants");
55
+ if (!request.observations.length && !request.reviews?.length)
56
+ throw new StateRefusal("malformed", "capture batch must contain observations or reviews");
57
+ const { home, isPrivate } = stateHomeFor(store, request.scope);
58
+ const sourceHashes = new Map();
59
+ const ledgerCache = {};
60
+ const results = [];
61
+ const reviews = [];
62
+ let changed = false;
63
+ try {
64
+ // Review before capture: a replay in the same batch must see the withdrawn state.
65
+ for (const [index, review] of (request.reviews ?? []).entries()) {
66
+ try {
67
+ const record = store.getStateDirect("derived", review.record_id, home);
68
+ if (!record || scopePath(record.scope) !== scopePath(request.scope) || !record.transform_version.startsWith('agent-capture/1:'))
69
+ throw new StateRefusal('conflict', 'captured observation is absent from this partition');
70
+ if (!isCredentialFreeValue(review.reason))
71
+ throw new StateRefusal('malformed', 'review reason contains credential material');
72
+ const evidence = review.evidence.map(e => {
73
+ const source = request.sources[e.source];
74
+ if (!source || !source.source_text.includes(e.excerpt) || !isCredentialFreeValue(e.excerpt))
75
+ throw new StateRefusal('malformed', 'review excerpt must occur exactly in the supplied source');
76
+ const hash = sourceHashes.get(source.source_text) ?? stateHash(source.source_text);
77
+ sourceHashes.set(source.source_text, hash);
78
+ if (source.ref.content_hash && source.ref.content_hash !== hash)
79
+ throw new StateRefusal('malformed', 'review source hash mismatch');
80
+ const ref = { ...source.ref, object_key: canonicalObjectKey(source.ref.object_key), content_hash: hash };
81
+ const original = record.dependencies.find(d => d.kind === 'external' && externalKey(d.ref) === externalKey(ref));
82
+ if (!original || original.kind !== 'external' || original.ref.content_hash === hash)
83
+ throw new StateRefusal('conflict', 'review must cite a changed source the observation actually depends on');
84
+ return { ref, excerpt: e.excerpt };
85
+ });
86
+ const identity = stateHash({ record_id: record.id, expected_hash: review.expected_hash, reason: review.reason, evidence: evidence.map(e => ({ source: externalKey(e.ref), hash: e.ref.content_hash, excerpt: e.excerpt })) });
87
+ // A safe retry returns the same withdrawal without changing its reviewer/time.
88
+ const old = record.review;
89
+ if (record.state === 'stale' && old && old.previous_hash === review.expected_hash && old.reason === review.reason && stateHash(old.evidence.map(e => ({ source: externalKey(e.ref), hash: e.ref.content_hash, excerpt: e.excerpt }))) === stateHash(evidence.map(e => ({ source: externalKey(e.ref), hash: e.ref.content_hash, excerpt: e.excerpt })))) {
90
+ reviews.push({ index, status: 'saved', result: { schema: STATE_WRITE_VERSION, record_id: record.id, record_hash: stateHash(record), record: record, durability: 'local', outcome: 'replayed', conflict: null } });
91
+ continue;
92
+ }
93
+ if (record.state !== 'unknown' || record.valid_to != null || stateHash(record) !== review.expected_hash)
94
+ throw new StateRefusal('conflict', 'observation changed since review; read it again');
95
+ const at = (opts.now ?? (() => new Date()))().toISOString();
96
+ const result = writeState(store, { schema: STATE_WRITE_VERSION, principal: request.principal, scope: request.scope, facet: 'derived',
97
+ record: { ...record, state: 'stale', review: { by: request.principal.id, at, previous_hash: review.expected_hash, reason: review.reason, evidence } },
98
+ expected_version: review.expected_hash, idempotency_key: `observation-review:${identity}`, cause: { kind: 'external', ref: evidence[0].ref } }, { now: opts.now, ledgerCache, deferReindex: true });
99
+ changed ||= result.outcome !== 'replayed';
100
+ reviews.push({ index, status: 'saved', result });
101
+ }
102
+ catch (error) {
103
+ if (!(error instanceof StateRefusal))
104
+ throw error;
105
+ reviews.push({ index, status: 'refused', code: error.code, message: error.message });
106
+ }
107
+ }
108
+ for (const [index, observation] of request.observations.entries()) {
109
+ try {
110
+ const evidence = observation.evidence.map(e => {
111
+ const source = request.sources[e.source];
112
+ if (!source)
113
+ throw new StateRefusal("malformed", `evidence source index ${e.source} is absent`);
114
+ return { ...source, excerpt: e.excerpt };
115
+ });
116
+ const result = captureState(store, { schema: STATE_CAPTURE_VERSION, principal: request.principal, scope: request.scope, ...observation, evidence }, { now: opts.now, sourceHashes, ledgerCache, deferReindex: true });
117
+ changed ||= result.outcome !== "replayed";
118
+ results.push({ index, status: "saved", result });
119
+ }
120
+ catch (error) {
121
+ if (!(error instanceof StateRefusal))
122
+ throw error;
123
+ results.push({ index, status: "refused", code: error.code, message: error.message });
124
+ }
125
+ }
126
+ }
127
+ catch (error) {
128
+ // A filesystem failure may occur after an atomic record write but before its
129
+ // result; refresh the derived index before propagating the failure to the caller.
130
+ changed = true;
131
+ throw error;
132
+ }
133
+ finally {
134
+ if (changed)
135
+ store.reindex();
136
+ }
137
+ if (changed) {
138
+ const durability = opts.flush?.(isPrivate, `nuryel: capture ${results.filter(r => r.status === "saved" && r.result.outcome === "created").length} observations`) ?? "local";
139
+ for (const item of [...results, ...reviews])
140
+ if (item.status === "saved")
141
+ item.result.durability = durability;
142
+ }
143
+ return { schema: STATE_CAPTURE_BATCH_VERSION, results, ...(request.reviews ? { reviews } : {}) };
144
+ }
145
+ //# sourceMappingURL=stateCapture.js.map
@@ -0,0 +1,168 @@
1
+ /** Extensible local CLI transport. Executables/arguments come only from explicit user config. */
2
+ import spawn from "cross-spawn";
3
+ import { execFile } from "node:child_process";
4
+ import { mkdtempSync, readFileSync, realpathSync, rmSync, statSync } from "node:fs";
5
+ import { tmpdir } from "node:os";
6
+ import { isAbsolute, join, relative } from "node:path";
7
+ import { z } from "zod";
8
+ import { assertInitiatorProvider, initiatorChildEnv } from "./initiator.js";
9
+ const adapterSchema = z.object({
10
+ name: z.string().regex(/^[a-z][a-z0-9-]{0,59}$/),
11
+ command: z.string().min(1).max(1000).refine(s => !/[\r\n\0]/.test(s)),
12
+ args: z.array(z.string().max(2000).refine(s => !/[\r\n\0]/.test(s))).max(40),
13
+ protocol: z.enum(["stdin", "acp"]),
14
+ probe_args: z.array(z.string().max(200).refine(s => !/[\r\n\0]/.test(s))).max(10).default(["--version"]),
15
+ timeout_ms: z.number().int().min(1000).max(600000).default(120000),
16
+ }).strict();
17
+ export function readAgentCliConfig(file) {
18
+ if (statSync(file).size > 64 * 1024)
19
+ throw new Error("CLI adapter config exceeds 64 KiB");
20
+ const adapters = z.array(adapterSchema).max(20).parse(JSON.parse(readFileSync(file, "utf8")));
21
+ if (new Set(adapters.map(a => a.name)).size !== adapters.length)
22
+ throw new Error("duplicate CLI adapter names");
23
+ return adapters;
24
+ }
25
+ /** ACP (Kimi and other agents) or plain stdin → JSON stdout for any user-configured CLI. */
26
+ export async function runAgentCli(adapterInput, prompt) {
27
+ const adapter = adapterSchema.parse(adapterInput);
28
+ assertInitiatorProvider(adapter.name);
29
+ const parent = realpathSync(tmpdir());
30
+ const cwd = mkdtempSync(join(parent, "hunch-agent-provider-"));
31
+ const child = spawn(adapter.command, adapter.args, { cwd, env: initiatorChildEnv(), windowsHide: true, stdio: "pipe" });
32
+ const stop = () => {
33
+ if (process.platform === "win32" && child.pid) {
34
+ execFile("taskkill", ["/pid", String(child.pid), "/T", "/F"], { windowsHide: true }, () => { });
35
+ }
36
+ child.kill();
37
+ };
38
+ try {
39
+ return await new Promise((resolve, reject) => {
40
+ let done = false;
41
+ let buffer = "";
42
+ let output = "";
43
+ let bytes = 0;
44
+ let sequence = 0;
45
+ let sessionId;
46
+ const waiting = new Map();
47
+ const finish = (error) => {
48
+ if (done)
49
+ return;
50
+ done = true;
51
+ clearTimeout(timer);
52
+ if (error)
53
+ reject(error);
54
+ else
55
+ resolve(output.trim());
56
+ };
57
+ const timer = setTimeout(() => { finish(new Error("CLI provider timed out")); stop(); }, adapter.timeout_ms);
58
+ const send = (message) => child.stdin.write(JSON.stringify(message) + "\n");
59
+ const request = (method, params, callback) => {
60
+ const id = ++sequence;
61
+ waiting.set(id, callback);
62
+ send({ jsonrpc: "2.0", id, method, params });
63
+ };
64
+ const receive = (line) => {
65
+ const message = JSON.parse(line);
66
+ if (message.method && message.id !== undefined) {
67
+ // No permission approvals, filesystem reads/writes or shell services are granted.
68
+ if (message.method === "session/request_permission") {
69
+ send({ jsonrpc: "2.0", id: message.id, result: { outcome: { outcome: "cancelled" } } });
70
+ }
71
+ else
72
+ send({ jsonrpc: "2.0", id: message.id, error: { code: -32601, message: "Unavailable in review-only client" } });
73
+ return;
74
+ }
75
+ if (message.method === "session/update") {
76
+ const params = message.params;
77
+ if (params?.sessionId === sessionId && params.update?.sessionUpdate === "agent_message_chunk"
78
+ && params.update.content?.type === "text" && typeof params.update.content.text === "string")
79
+ output += params.update.content.text;
80
+ }
81
+ else if (typeof message.id === "number" && waiting.has(message.id)) {
82
+ const callback = waiting.get(message.id);
83
+ waiting.delete(message.id);
84
+ if (message.error || !message.result || typeof message.result !== "object")
85
+ throw new Error("ACP request failed");
86
+ callback(message.result);
87
+ }
88
+ };
89
+ child.on("error", () => finish(new Error("CLI provider could not start")));
90
+ child.stdin.on("error", () => finish(new Error("CLI provider input closed")));
91
+ child.stderr.on("data", () => { }); // drain without collecting credentials or raw prompts
92
+ child.stdout.setEncoding("utf8");
93
+ child.stdout.on("data", (chunk) => {
94
+ if (done)
95
+ return;
96
+ bytes += Buffer.byteLength(chunk);
97
+ if (bytes > 2 * 1024 * 1024) {
98
+ finish(new Error("CLI provider exceeded output budget"));
99
+ stop();
100
+ return;
101
+ }
102
+ if (adapter.protocol === "stdin") {
103
+ output += chunk;
104
+ return;
105
+ }
106
+ buffer += chunk;
107
+ try {
108
+ let newline;
109
+ while ((newline = buffer.indexOf("\n")) >= 0 && !done) {
110
+ const line = buffer.slice(0, newline).trim();
111
+ buffer = buffer.slice(newline + 1);
112
+ if (line)
113
+ receive(line);
114
+ }
115
+ }
116
+ catch {
117
+ finish(new Error("Invalid ACP response"));
118
+ }
119
+ });
120
+ child.on("close", code => {
121
+ if (adapter.protocol === "stdin" && code === 0)
122
+ finish();
123
+ else
124
+ finish(new Error("CLI provider exited before completing review"));
125
+ });
126
+ if (adapter.protocol === "stdin")
127
+ child.stdin.end(prompt);
128
+ else
129
+ request("initialize", { protocolVersion: 1, clientInfo: { name: "hunch-review-memory", version: "1" },
130
+ clientCapabilities: { fs: { readTextFile: true, writeTextFile: true }, terminal: true } }, initialized => {
131
+ if (initialized.protocolVersion !== 1)
132
+ throw new Error("Unsupported ACP protocol");
133
+ request("session/new", { cwd, mcpServers: [] }, session => {
134
+ if (typeof session.sessionId !== "string" || !session.sessionId)
135
+ throw new Error("Missing ACP session");
136
+ sessionId = session.sessionId;
137
+ request("session/prompt", { sessionId, prompt: [{ type: "text", text: prompt }] }, result => {
138
+ if (result.stopReason !== "end_turn")
139
+ throw new Error("ACP turn did not complete");
140
+ finish();
141
+ });
142
+ });
143
+ });
144
+ });
145
+ }
146
+ finally {
147
+ stop();
148
+ // Only remove the directory created for this invocation, after checking its resolved boundary.
149
+ const rel = relative(parent, realpathSync(cwd));
150
+ if (!isAbsolute(rel) && rel.startsWith("hunch-agent-provider-") && !rel.includes("/") && !rel.includes("\\")) {
151
+ try {
152
+ rmSync(cwd, { recursive: true, force: true });
153
+ }
154
+ catch { /* transient Windows handles; never affect review result */ }
155
+ }
156
+ }
157
+ }
158
+ export function discoverAgentClis(configured = [], initiator) {
159
+ const kimi = adapterSchema.parse({ name: "kimi-cli", command: "kimi", args: ["acp"], protocol: "acp" });
160
+ const adapters = [...configured, ...(configured.some(a => a.name === kimi.name) ? [] : [kimi])];
161
+ return adapters.filter(adapter => !initiator || adapter.name === initiator).filter(adapter => {
162
+ const result = spawn.sync(adapter.command, adapter.probe_args, {
163
+ cwd: tmpdir(), encoding: "utf8", windowsHide: true, timeout: 5000, maxBuffer: 64 * 1024,
164
+ });
165
+ return !result.error && result.status === 0;
166
+ }).map(adapter => ({ name: adapter.name, draftProse: prompt => runAgentCli(adapter, prompt) }));
167
+ }
168
+ //# sourceMappingURL=cliAdapter.js.map
@@ -0,0 +1,58 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ const context = new AsyncLocalStorage();
3
+ const aliases = { claude: "claude-cli", codex: "codex-cli", cursor: "cursor-agent", kimi: "kimi-cli", ollama: "openai-compat" };
4
+ export function normalizeInitiator(name) {
5
+ const normalized = aliases[name] ?? name;
6
+ if (!/^[a-z][a-z0-9-]{0,59}$/.test(normalized) || ["auto", "available", "deterministic"].includes(normalized)) {
7
+ throw new Error("Initiator must identify one concrete agent provider.");
8
+ }
9
+ return normalized;
10
+ }
11
+ export function detectInitiator(env = process.env) {
12
+ if (env.HUNCH_INITIATOR === "unknown")
13
+ return { provider: null, source: "unknown" };
14
+ if (env.HUNCH_INITIATOR)
15
+ return { provider: normalizeInitiator(env.HUNCH_INITIATOR), source: "explicit" };
16
+ const names = new Set();
17
+ if (env.CODEX_THREAD_ID || env.CODEX_SESSION_ID)
18
+ names.add("codex-cli");
19
+ if (env.CLAUDECODE === "1")
20
+ names.add("claude-cli");
21
+ return { provider: names.size === 1 ? [...names][0] : null,
22
+ source: names.size === 1 ? "environment" : names.size > 1 ? "ambiguous" : "unknown" };
23
+ }
24
+ export function currentInitiator(env = process.env) {
25
+ return context.getStore() ?? detectInitiator(env);
26
+ }
27
+ export function withInitiator(initiator, work) {
28
+ return context.run(Object.freeze({ ...initiator }), work);
29
+ }
30
+ /** Bind the MCP client, not the process that happened to start the server. Unknown clients stay unknown. */
31
+ export function initiatorFromClient(name) {
32
+ const lower = name?.toLowerCase() ?? "";
33
+ const providers = [
34
+ [/\bclaude(?:[ _-]code)?\b/, "claude-cli"], [/\bcodex\b/, "codex-cli"],
35
+ [/\bcursor\b/, "cursor-agent"], [/\bkimi\b/, "kimi-cli"],
36
+ ];
37
+ const matched = providers.filter(([pattern]) => pattern.test(lower));
38
+ return { provider: matched.length === 1 ? matched[0][1] : null,
39
+ source: matched.length > 1 ? "ambiguous" : "client" };
40
+ }
41
+ /** Freeze the operation's origin before spawning Git hooks or other deferred children. */
42
+ export function initiatorChildEnv(env = process.env) {
43
+ const origin = currentInitiator(env);
44
+ return { ...env, HUNCH_INITIATOR: origin.provider ?? "unknown" };
45
+ }
46
+ export function assertInitiatorProvider(provider) {
47
+ const origin = currentInitiator();
48
+ if (origin.provider && origin.provider !== provider)
49
+ throw new Error(`Initiator ${origin.provider} cannot launch ${provider}; refusing an account switch.`);
50
+ if (origin.source === "ambiguous")
51
+ throw new Error("Ambiguous initiating agent; refusing to launch another provider.");
52
+ if (origin.source === "client" && !origin.provider)
53
+ throw new Error("Unknown initiating MCP client; refusing to launch another provider.");
54
+ if (!origin.provider && (context.getStore() || process.env.HUNCH_INITIATOR === "unknown")) {
55
+ throw new Error("Unknown initiating event; refusing to launch another provider.");
56
+ }
57
+ }
58
+ //# sourceMappingURL=initiator.js.map