@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.
- package/dist/cli/index.js +355 -55
- package/dist/cli/integrations.js +10 -0
- package/dist/cli/serve.js +1 -0
- package/dist/client/readOrCompute.d.ts +77 -0
- package/dist/client/readOrCompute.js +85 -0
- package/dist/client/state.d.ts +1 -0
- package/dist/client/state.js +1 -0
- package/dist/constitution/g2.d.ts +1 -0
- package/dist/constitution/service.js +8 -0
- package/dist/constitution/sourceMutation.js +23 -18
- package/dist/core/agenthook.d.ts +14 -0
- package/dist/core/agenthook.js +48 -5
- package/dist/core/changeProof.js +5 -1
- package/dist/core/checkreport.d.ts +7 -0
- package/dist/core/checkreport.js +20 -3
- package/dist/core/compare.js +3 -2
- package/dist/core/config.d.ts +16 -0
- package/dist/core/config.js +13 -0
- package/dist/core/machine.d.ts +20 -0
- package/dist/core/machine.js +101 -0
- package/dist/core/taskReportEvidence.js +6 -6
- package/dist/core/types.d.ts +67 -1
- package/dist/core/types.js +3 -0
- package/dist/core/workspace.d.ts +256 -0
- package/dist/core/workspace.js +359 -0
- package/dist/extractors/diff.d.ts +34 -0
- package/dist/extractors/diff.js +147 -5
- package/dist/extractors/git.d.ts +40 -11
- package/dist/extractors/git.js +147 -43
- package/dist/extractors/helm.d.ts +17 -28
- package/dist/extractors/helm.js +12 -12
- package/dist/extractors/indexer.js +171 -7
- package/dist/extractors/k8sManifest.d.ts +59 -0
- package/dist/extractors/k8sManifest.js +507 -0
- package/dist/extractors/workspaces.d.ts +28 -0
- package/dist/extractors/workspaces.js +427 -0
- package/dist/integrations/claudemd.js +1 -0
- package/dist/integrations/gitignore.d.ts +27 -2
- package/dist/integrations/gitignore.js +103 -17
- package/dist/integrations/hooks.d.ts +63 -7
- package/dist/integrations/hooks.js +350 -38
- package/dist/integrations/scaffold.js +11 -0
- package/dist/integrations/workspaceLedger.d.ts +93 -0
- package/dist/integrations/workspaceLedger.js +307 -0
- package/dist/mcp/server.js +59 -5
- package/dist/serve/app.d.ts +2 -0
- package/dist/serve/app.js +107 -92
- package/dist/serve/mcpHttp.d.ts +27 -0
- package/dist/serve/mcpHttp.js +95 -0
- package/dist/store/hunchStore.d.ts +4 -2
- package/dist/store/hunchStore.js +23 -6
- package/dist/store/stateBinding.js +83 -35
- package/package.json +1 -1
- 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
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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(
|
|
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
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.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.
|
|
16
|
+
"version": "1.39.1",
|
|
17
17
|
"runtimeHint": "npx",
|
|
18
18
|
"packageArguments": [
|
|
19
19
|
{
|