@davesheffer/hunch 1.38.1 → 1.39.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.
Files changed (54) hide show
  1. package/dist/cli/index.js +355 -55
  2. package/dist/cli/integrations.js +10 -0
  3. package/dist/cli/serve.js +1 -0
  4. package/dist/client/readOrCompute.d.ts +77 -0
  5. package/dist/client/readOrCompute.js +85 -0
  6. package/dist/client/state.d.ts +1 -0
  7. package/dist/client/state.js +1 -0
  8. package/dist/constitution/g2.d.ts +1 -0
  9. package/dist/constitution/service.js +8 -0
  10. package/dist/constitution/sourceMutation.js +23 -18
  11. package/dist/core/agenthook.d.ts +14 -0
  12. package/dist/core/agenthook.js +48 -5
  13. package/dist/core/changeProof.js +5 -1
  14. package/dist/core/checkreport.d.ts +7 -0
  15. package/dist/core/checkreport.js +20 -3
  16. package/dist/core/compare.js +3 -2
  17. package/dist/core/config.d.ts +16 -0
  18. package/dist/core/config.js +13 -0
  19. package/dist/core/machine.d.ts +20 -0
  20. package/dist/core/machine.js +101 -0
  21. package/dist/core/taskReportEvidence.js +6 -6
  22. package/dist/core/types.d.ts +67 -1
  23. package/dist/core/types.js +3 -0
  24. package/dist/core/workspace.d.ts +256 -0
  25. package/dist/core/workspace.js +359 -0
  26. package/dist/extractors/diff.d.ts +34 -0
  27. package/dist/extractors/diff.js +147 -5
  28. package/dist/extractors/git.d.ts +40 -11
  29. package/dist/extractors/git.js +147 -43
  30. package/dist/extractors/helm.d.ts +17 -28
  31. package/dist/extractors/helm.js +12 -12
  32. package/dist/extractors/indexer.js +171 -7
  33. package/dist/extractors/k8sManifest.d.ts +59 -0
  34. package/dist/extractors/k8sManifest.js +507 -0
  35. package/dist/extractors/workspaces.d.ts +28 -0
  36. package/dist/extractors/workspaces.js +427 -0
  37. package/dist/integrations/claudemd.js +1 -0
  38. package/dist/integrations/gitignore.d.ts +27 -2
  39. package/dist/integrations/gitignore.js +103 -17
  40. package/dist/integrations/hooks.d.ts +63 -7
  41. package/dist/integrations/hooks.js +350 -38
  42. package/dist/integrations/scaffold.js +11 -0
  43. package/dist/integrations/workspaceLedger.d.ts +93 -0
  44. package/dist/integrations/workspaceLedger.js +307 -0
  45. package/dist/mcp/server.js +59 -5
  46. package/dist/serve/app.d.ts +2 -0
  47. package/dist/serve/app.js +107 -92
  48. package/dist/serve/mcpHttp.d.ts +27 -0
  49. package/dist/serve/mcpHttp.js +95 -0
  50. package/dist/store/hunchStore.d.ts +4 -2
  51. package/dist/store/hunchStore.js +23 -6
  52. package/dist/store/stateBinding.js +83 -35
  53. package/package.json +1 -1
  54. package/server.json +2 -2
@@ -26,6 +26,7 @@ import { writeFileAtomic } from "../core/io.js";
26
26
  import { join } from "node:path";
27
27
  import { z } from "zod";
28
28
  import { appendChanges, latestSeqFor, readLedger } from "./changeLedger.js";
29
+ import { STATE_ONLY_FACETS } from "./replay.js";
29
30
  import { hunchPaths } from "../core/paths.js";
30
31
  import { decisionId } from "../core/ids.js";
31
32
  import { ENTITY_KINDS, SCHEMAS } from "../core/types.js";
@@ -467,6 +468,16 @@ function subjectOf(facet, record) {
467
468
  default: return undefined;
468
469
  }
469
470
  }
471
+ /** The published `ChangeEvent.subject` bound. Record subjects can be longer (a receipt's
472
+ * `object_type:object_key`, an entity id, a relationship endpoint, a decision topic); the record
473
+ * limits are never lowered and the event limit is never raised (dec_a9bfb5dd8d). */
474
+ const EVENT_SUBJECT_MAX = 512;
475
+ /** The subject an EVENT carries: the record's subject when it fits the event limit, otherwise
476
+ * omitted (`subject` is optional). A subscriber still matches the event by `record_id`. */
477
+ function eventSubjectOf(facet, record) {
478
+ const subject = subjectOf(facet, record);
479
+ return subject !== undefined && subject.length <= EVENT_SUBJECT_MAX ? subject : undefined;
480
+ }
470
481
  /** Which facet a record id belongs to, from its prefix; `null` for a kind-qualified entity id
471
482
  * or an unknown shape (those are looked up across every facet). */
472
483
  function facetOfId(id) {
@@ -607,6 +618,29 @@ function closeWindow(store, facet, incumbentId, byId, at, isPrivate) {
607
618
  store.putCapture(facet, closed, isPrivate);
608
619
  return true;
609
620
  }
621
+ /** Validate pending change events against the published event schema without writing anything,
622
+ * so a write whose event could not be appended is refused before its record lands (#283). */
623
+ function assertEventsValid(ledger, scope, at, changes) {
624
+ changes.forEach((change, i) => {
625
+ const parsed = ChangeEventSchema.safeParse({ schema: STATE_SUBSCRIBE_VERSION, seq: ledger.head_seq + i + 1, at, scope, ...change });
626
+ if (!parsed.success)
627
+ throw new StateRefusal("malformed", `change event for ${change.record_id} is malformed: ${parsed.error.issues.map((x) => `${x.path.join(".") || "event"}: ${x.message}`).join("; ")}`);
628
+ });
629
+ }
630
+ /** The event a replayed write owes the ledger (#282): a state-only record on file that the ledger
631
+ * has never seen — no event names it and no idempotency entry references it (compaction drops
632
+ * events but keeps the table, so an entry means "known") — was written without its event, by a
633
+ * crash between put and append or a refusal after the put. The retry appends it with the hash on
634
+ * file. A record the ledger already knows gets no new event. */
635
+ function missingEventFor(ledger, facet, onFile, principal) {
636
+ if (!STATE_ONLY_FACETS.includes(facet))
637
+ return [];
638
+ const id = String(onFile.id);
639
+ if (latestSeqFor(ledger, id) > 0 || Object.values(ledger.idempotency).some((e) => e.record_id === id))
640
+ return [];
641
+ const invalidates = facet === "receipts" && Array.isArray(onFile.invalidates) ? onFile.invalidates : [];
642
+ return [{ ...(onFile.visibility ? { visibility: onFile.visibility } : {}), facet, record_id: id, record_hash: stateHash(onFile), change: "created", subject: eventSubjectOf(facet, onFile), invalidates, cause: { kind: "write", principal } }];
643
+ }
610
644
  /** Normalize + validate the record for its facet; enforce the identity rule (an id, when
611
645
  * given, must be the one the record's facts derive). Returns the canonical record. */
612
646
  function normalizeRecord(facet, scope, raw, principal) {
@@ -701,6 +735,33 @@ function writeStateAuthorized(store, input, opts, access) {
701
735
  throw new StateRefusal("unsupported", `facet ${facet} is not a store kind`);
702
736
  const record = normalizeRecord(facet, request.scope, request.record, request.principal);
703
737
  const deny = () => { throw new StateRefusal('outside-grants', 'record unavailable or operation not permitted'); };
738
+ const id = record.id;
739
+ /** The normalized PAYLOAD hash: what idempotency recognizes on a re-send. */
740
+ const hash = stateHash(record);
741
+ const ledger = opts.ledgerCache?.ledger ?? readLedger(hunchDir, request.scope);
742
+ if (opts.ledgerCache)
743
+ opts.ledgerCache.ledger = ledger;
744
+ const durability = () => opts.flush?.(isPrivate, `nuryel: write ${id}`) ?? "local";
745
+ /** The result reports the record ON FILE and its hash — the store may enrich a record on put
746
+ * (a private-mode decision gains `valid_from`), and a writer that goes on to rest a receipt
747
+ * on this record must hold the hash a reader will verify, never a pre-store one. */
748
+ const result = (outcome, conflict = null, rid = id) => {
749
+ const onFile = getHere(rid) ?? record;
750
+ return WriteResultSchema.parse({ schema: STATE_WRITE_VERSION, record_id: rid, record_hash: stateHash(onFile), durability: durability(), outcome, conflict, record: onFile });
751
+ };
752
+ // Idempotency, exact replay FIRST: the same key with the same payload for the same record is
753
+ // the retry of a request that already succeeded, and returns what it wrote — before any check
754
+ // that depends on state changed since (an entity now claiming the subject, a visibility or link
755
+ // change). Otherwise a writer whose response was lost is refused, re-derives, and duplicates
756
+ // the record it already holds (#284). A differing payload under the key falls through to every
757
+ // check and the idempotency refusal below.
758
+ const seen = ledger.idempotency[request.idempotency_key];
759
+ if (seen && seen.record_id === id && (seen.record_hash === hash || seen.payload_hash === hash)) {
760
+ const prior = findRecord(store, seen.record_id)?.record;
761
+ if (prior && !access.canRead(prior))
762
+ deny();
763
+ return result("replayed");
764
+ }
704
765
  const incumbent = getHere(String(record.id));
705
766
  if (incumbent && !access.canWrite(incumbent))
706
767
  deny();
@@ -761,30 +822,12 @@ function writeStateAuthorized(store, input, opts, access) {
761
822
  replayLink = prior;
762
823
  }
763
824
  }
764
- const id = record.id;
765
825
  assertExternalIdentity(store, request.principal, request.scope, facet, record);
766
- /** The normalized PAYLOAD hash: what idempotency recognizes on a re-send. */
767
- const hash = stateHash(record);
768
- const ledger = opts.ledgerCache?.ledger ?? readLedger(hunchDir, request.scope);
769
- if (opts.ledgerCache)
770
- opts.ledgerCache.ledger = ledger;
771
- const durability = () => opts.flush?.(isPrivate, `nuryel: write ${id}`) ?? "local";
772
- /** The result reports the record ON FILE and its hash — the store may enrich a record on put
773
- * (a private-mode decision gains `valid_from`), and a writer that goes on to rest a receipt
774
- * on this record must hold the hash a reader will verify, never a pre-store one. */
775
- const result = (outcome, conflict = null, rid = id) => {
776
- const onFile = getHere(rid) ?? record;
777
- return WriteResultSchema.parse({ schema: STATE_WRITE_VERSION, record_id: rid, record_hash: stateHash(onFile), durability: durability(), outcome, conflict, record: onFile });
778
- };
779
- // Idempotency: the same key replays the original; the same key with a different payload
780
- // is a refusal, never a second record.
781
- const seen = ledger.idempotency[request.idempotency_key];
826
+ // Idempotency: the same key with a different payload is a refusal, never a second record.
782
827
  if (seen) {
783
828
  const prior = findRecord(store, seen.record_id)?.record;
784
829
  if (prior && !access.canRead(prior))
785
830
  deny();
786
- if (seen.record_id === id && (seen.record_hash === hash || seen.payload_hash === hash))
787
- return result("replayed");
788
831
  // Say WHAT differs and what to do: a stable key with a varying payload (a timestamp, new
789
832
  // wording) is the trap every writer falls into once; the refusal must teach the way out.
790
833
  const stored = store.getRec(facet, seen.record_id);
@@ -799,7 +842,7 @@ function writeStateAuthorized(store, input, opts, access) {
799
842
  throw new StateRefusal('malformed', 'reviewer must be the initiating principal');
800
843
  }
801
844
  if (existing && stateHash(existing) === hash) {
802
- appendChanges(hunchDir, request.scope, [], { key: request.idempotency_key, entry: { record_id: id, record_hash: hash, payload_hash: hash, facet } }, now, opts.ledgerCache?.ledger);
845
+ appendChanges(hunchDir, request.scope, missingEventFor(ledger, facet, existing, request.principal.id), { key: request.idempotency_key, entry: { record_id: id, record_hash: hash, payload_hash: hash, facet } }, now, opts.ledgerCache?.ledger);
803
846
  return result("replayed");
804
847
  }
805
848
  if (existing && request.expected_version !== null) {
@@ -811,7 +854,7 @@ function writeStateAuthorized(store, input, opts, access) {
811
854
  }
812
855
  if (replayLink) {
813
856
  const recordHash = stateHash(replayLink);
814
- appendChanges(hunchDir, request.scope, [], { key: request.idempotency_key, entry: { record_id: replayLink.id, record_hash: recordHash, payload_hash: hash, facet } }, now, opts.ledgerCache?.ledger);
857
+ appendChanges(hunchDir, request.scope, missingEventFor(ledger, facet, replayLink, request.principal.id), { key: request.idempotency_key, entry: { record_id: replayLink.id, record_hash: recordHash, payload_hash: hash, facet } }, now, opts.ledgerCache?.ledger);
815
858
  return WriteResultSchema.parse({ schema: STATE_WRITE_VERSION, record_id: replayLink.id, record_hash: recordHash, record: replayLink, outcome: "replayed", conflict: null, durability: "local" });
816
859
  }
817
860
  // human-correction-outranks-agent-writes: what a human confirmed, an agent does not rewrite.
@@ -841,7 +884,7 @@ function writeStateAuthorized(store, input, opts, access) {
841
884
  };
842
885
  const verdict = guard(existing, "overwrite");
843
886
  if (verdict === "replay") {
844
- appendChanges(hunchDir, request.scope, [], { key: request.idempotency_key, entry: { record_id: id, record_hash: stateHash(existing), payload_hash: hash, facet } }, now, opts.ledgerCache?.ledger);
887
+ appendChanges(hunchDir, request.scope, missingEventFor(ledger, facet, existing, request.principal.id), { key: request.idempotency_key, entry: { record_id: id, record_hash: stateHash(existing), payload_hash: hash, facet } }, now, opts.ledgerCache?.ledger);
845
888
  return result("replayed");
846
889
  }
847
890
  if (verdict === "keep-provenance")
@@ -922,6 +965,21 @@ function writeStateAuthorized(store, input, opts, access) {
922
965
  if (facet === "receipts")
923
966
  assertRestsOn(store, request.principal, request.scope, record.rests_on ?? []);
924
967
  const closedBy = facet === "commitments" ? assertClosedBy(store, request.principal, record) : null;
968
+ // Build and validate every change event BEFORE anything is written (#283): a refusal must never
969
+ // leave a record on file without its event. Only the hashes are filled in after the write (the
970
+ // store may enrich a record on put), and a sha256 always satisfies the event schema.
971
+ const cause = closedBy ? { kind: "receipt", receipt_id: closedBy } : request.cause ?? { kind: "write", principal: request.principal.id };
972
+ // A current derived statement written back as stale is an INVALIDATION, not an update: the
973
+ // ledger says so, and names the external pointer that moved when the writer gives one.
974
+ const invalidated = facet === "derived" && !!existing && (existing.state === "current" || existing.state === "unknown") && record.state === "stale";
975
+ const invalidates = facet === "receipts" ? record.invalidates : [];
976
+ // An entity leaving service is a `retired` change (a merge names the survivor in the record).
977
+ const retired = (facet === "entities" || facet === "relationships") && record.lifecycle === "retired" && (!existing || existing.lifecycle !== "retired");
978
+ const subject = eventSubjectOf(facet, record);
979
+ const supersededPrior = supersedes ? store.getRec(facet, supersedes) : undefined;
980
+ const supersededChange = (old) => ({ ...(old.visibility ? { visibility: old.visibility } : {}), facet, record_id: String(old.id), record_hash: stateHash(old), change: "superseded", subject: eventSubjectOf(facet, old), invalidates: [], cause });
981
+ const ownChange = (recordHash) => ({ ...(nextVisibility ? { visibility: nextVisibility } : {}), facet, record_id: id, record_hash: recordHash, change: invalidated ? "invalidated" : retired ? "retired" : existing ? "updated" : "created", subject, invalidates: invalidated && subject ? [subject] : invalidates, cause });
982
+ assertEventsValid(ledger, request.scope, now, [...(supersededPrior ? [supersededChange(supersededPrior)] : []), ownChange(hash)]);
925
983
  if (nextVisibility) {
926
984
  // Publish the fail-closed old-reader gate BEFORE protected bytes. An interrupted
927
985
  // write can leave a gate without a record, never a record without the gate.
@@ -932,22 +990,12 @@ function writeStateAuthorized(store, input, opts, access) {
932
990
  /** What is on file now — the hash every event, ref and result carries. */
933
991
  const onFileHash = stateHash(getHere(id) ?? record);
934
992
  const changes = [];
935
- const cause = closedBy ? { kind: "receipt", receipt_id: closedBy } : request.cause ?? { kind: "write", principal: request.principal.id };
936
- // A current derived statement written back as stale is an INVALIDATION, not an update: the
937
- // ledger says so, and names the external pointer that moved when the writer gives one.
938
- const invalidated = facet === "derived" && !!existing && (existing.state === "current" || existing.state === "unknown") && record.state === "stale";
939
- const invalidates = facet === "receipts" ? record.invalidates : [];
940
- // An entity leaving service is a `retired` change (a merge names the survivor in the record).
941
- const retired = (facet === "entities" || facet === "relationships") && record.lifecycle === "retired" && (!existing || existing.lifecycle !== "retired");
942
- const subject = subjectOf(facet, record);
943
993
  if (supersedes) {
944
994
  const closed = closeWindow(store, facet, supersedes, id, now, isPrivate);
945
- if (closed) {
946
- const old = store.getRec(facet, supersedes);
947
- changes.push({ ...("visibility" in old && old.visibility ? { visibility: old.visibility } : {}), facet, record_id: supersedes, record_hash: stateHash(old), change: "superseded", subject: subjectOf(facet, old), invalidates: [], cause });
948
- }
995
+ if (closed)
996
+ changes.push(supersededChange(store.getRec(facet, supersedes)));
949
997
  }
950
- changes.push({ ...(nextVisibility ? { visibility: nextVisibility } : {}), facet, record_id: id, record_hash: onFileHash, change: invalidated ? "invalidated" : retired ? "retired" : existing ? "updated" : "created", subject, invalidates: invalidated && subject ? [subject] : invalidates, cause });
998
+ changes.push(ownChange(onFileHash));
951
999
  appendChanges(hunchDir, request.scope, changes, { key: request.idempotency_key, entry: { record_id: id, record_hash: onFileHash, payload_hash: hash, facet } }, now, opts.ledgerCache?.ledger);
952
1000
  if (!opts.deferReindex)
953
1001
  store.reindex();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davesheffer/hunch",
3
- "version": "1.38.1",
3
+ "version": "1.39.1",
4
4
  "mcpName": "io.github.davesheffer/hunch",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Dave Sheffer <dave.sheffer1@gmail.com>",
package/server.json CHANGED
@@ -7,13 +7,13 @@
7
7
  "source": "github"
8
8
  },
9
9
  "websiteUrl": "https://www.hunchmemory.com",
10
- "version": "1.38.1",
10
+ "version": "1.39.1",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "npm",
14
14
  "registryBaseUrl": "https://registry.npmjs.org",
15
15
  "identifier": "@davesheffer/hunch",
16
- "version": "1.38.1",
16
+ "version": "1.39.1",
17
17
  "runtimeHint": "npx",
18
18
  "packageArguments": [
19
19
  {