@davesheffer/hunch 1.32.8 → 1.33.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.
Files changed (56) hide show
  1. package/README.md +9 -3
  2. package/dist/cli/index.js +2 -0
  3. package/dist/cli/serve.js +28 -2
  4. package/dist/cli/state.d.ts +3 -0
  5. package/dist/cli/state.js +150 -0
  6. package/dist/client/state.d.ts +82 -14
  7. package/dist/client/state.js +16 -2
  8. package/dist/client/stateProof.d.ts +4 -0
  9. package/dist/client/stateProof.js +17 -0
  10. package/dist/constitution/behaviorEvaluator.js +1 -1
  11. package/dist/constitution/schema.d.ts +2 -2
  12. package/dist/core/automaticReviewMemory.d.ts +5 -0
  13. package/dist/core/conventionDelivery.d.ts +8 -0
  14. package/dist/core/conventionDelivery.js +52 -0
  15. package/dist/core/fieldProvenance.d.ts +8 -0
  16. package/dist/core/fieldProvenance.js +72 -0
  17. package/dist/core/recordVisibility.d.ts +9 -0
  18. package/dist/core/recordVisibility.js +25 -0
  19. package/dist/core/stateCanonical.d.ts +3 -0
  20. package/dist/core/stateCanonical.js +34 -0
  21. package/dist/core/stateContract.d.ts +122 -7
  22. package/dist/core/stateContract.js +26 -31
  23. package/dist/core/stateDelivery.d.ts +3 -3
  24. package/dist/core/stateDelivery.js +10 -1
  25. package/dist/core/stateHttp.d.ts +280 -0
  26. package/dist/core/stateHttp.js +17 -0
  27. package/dist/core/stateProof.d.ts +13 -0
  28. package/dist/core/stateProof.js +34 -0
  29. package/dist/core/stateRecords.d.ts +127 -0
  30. package/dist/core/stateRecords.js +48 -0
  31. package/dist/core/types.d.ts +146 -4
  32. package/dist/core/types.js +8 -2
  33. package/dist/extractors/git.js +3 -10
  34. package/dist/mcp/server.js +10 -4
  35. package/dist/serve/app.d.ts +2 -0
  36. package/dist/serve/app.js +71 -30
  37. package/dist/serve/config.d.ts +16 -0
  38. package/dist/serve/config.js +27 -7
  39. package/dist/serve/operator.d.ts +4 -0
  40. package/dist/serve/operator.js +223 -0
  41. package/dist/serve/stateProof.d.ts +15 -0
  42. package/dist/serve/stateProof.js +105 -0
  43. package/dist/store/changeLedger.d.ts +6 -0
  44. package/dist/store/hunchStore.d.ts +4 -2
  45. package/dist/store/hunchStore.js +18 -19
  46. package/dist/store/stateAccess.d.ts +13 -0
  47. package/dist/store/stateAccess.js +85 -0
  48. package/dist/store/stateBinding.d.ts +13 -18
  49. package/dist/store/stateBinding.js +161 -52
  50. package/dist/store/stateCapture.js +10 -2
  51. package/dist/store/stateError.d.ts +12 -0
  52. package/dist/store/stateError.js +12 -0
  53. package/dist/store/statePartition.d.ts +9 -0
  54. package/dist/store/statePartition.js +30 -0
  55. package/package.json +5 -1
  56. package/server.json +2 -2
@@ -518,20 +518,20 @@ export class HunchStore {
518
518
  * relevance ordering (liveness/provenance/recency + topic-chain promotion)
519
519
  * go through hybridSearch/searchScoped, where rerankByPriors applies.
520
520
  * Falls back to LIKE if the query has no FTS-tokenizable terms. */
521
- search(query, limit = 12) {
521
+ search(query, limit = 12, allowedIds) {
522
522
  const match = toFtsQuery(query);
523
523
  // No FTS-tokenizable terms (e.g. a CJK-only query) — degrade to LIKE rather
524
524
  // than silently returning nothing (the documented fallback).
525
525
  if (!match)
526
- return this.likeSearch(query, limit);
526
+ return this.likeSearch(query, limit, undefined, allowedIds);
527
527
  try {
528
528
  const rows = this.db.prepare(`SELECT ref, kind, title, snippet(search, 3, '[', ']', '…', 12) AS snip, bm25(search) AS score
529
- FROM search WHERE search MATCH ? ORDER BY score LIMIT ?`).all(match, limit);
529
+ FROM search WHERE search MATCH ? ${allowedIds ? 'AND ref IN (SELECT value FROM json_each(?))' : ''} ORDER BY score LIMIT ?`).all(...(allowedIds ? [match, JSON.stringify(allowedIds), limit] : [match, limit]));
530
530
  return this.demoteHistoricalState(rows.map((r) => ({ ref: r.ref, kind: r.kind, title: r.title, snippet: r.snip, score: r.score })));
531
531
  }
532
532
  catch {
533
533
  // Malformed FTS expression — degrade to a LIKE scan over titles/bodies.
534
- return this.likeSearch(query, limit);
534
+ return this.likeSearch(query, limit, undefined, allowedIds);
535
535
  }
536
536
  }
537
537
  /** State-of-record ordering for nuryel.state/1 hits (superseded derived, done/cancelled
@@ -635,7 +635,7 @@ export class HunchStore {
635
635
  * `hunchrecorddecision` and matched nothing — on precisely the runtimes with no FTS5,
636
636
  * where this fallback is the only search there is. Escaping keeps the term literal;
637
637
  * leaving `_` unescaped would silently over-match instead. */
638
- likeSearch(query, limit, kind) {
638
+ likeSearch(query, limit, kind, allowedIds) {
639
639
  const terms = (query.toLowerCase().match(/[\p{L}\p{N}_]+/gu)
640
640
  ?? [query.toLowerCase().trim()].filter(Boolean)).slice(0, 32);
641
641
  if (!terms.length)
@@ -645,8 +645,7 @@ export class HunchStore {
645
645
  const like = `%${term.replace(/[\\%_]/g, "\\$&")}%`;
646
646
  return [like, like];
647
647
  });
648
- const where = kind ? `kind = ? AND (${predicates})` : `(${predicates})`;
649
- const params = kind ? [kind, ...likes, limit] : [...likes, limit];
648
+ const where = (kind ? `kind = ? AND (${predicates})` : `(${predicates})`) + (allowedIds ? " AND ref IN (SELECT value FROM json_each(?))" : "");
650
649
  // Ordered so a TRUNCATING limit drops the least relevant row rather than an
651
650
  // arbitrary one: a title hit outranks a body-only hit, then shortest title
652
651
  // (a constraint's one-line statement beats a long decision body that merely
@@ -657,7 +656,7 @@ export class HunchStore {
657
656
  const rows = this.db.prepare(`SELECT ref, kind, title, substr(body,1,120) AS snip FROM search
658
657
  WHERE ${where}
659
658
  ORDER BY CASE WHEN ${titleLikes} THEN 0 ELSE 1 END, length(title), ref
660
- LIMIT ?`).all(...(kind ? [kind, ...likes, ...titleParams, limit] : [...likes, ...titleParams, limit]));
659
+ LIMIT ?`).all(...[...(kind ? [kind] : []), ...likes, ...(allowedIds ? [JSON.stringify(allowedIds)] : []), ...titleParams, limit]);
661
660
  return this.demoteHistoricalState(rows.map((r) => ({ ref: r.ref, kind: r.kind, title: r.title, snippet: r.snip, score: 0 })));
662
661
  }
663
662
  // ---- semantic search (opt-in embeddings) --------------------------------
@@ -1174,11 +1173,11 @@ export class HunchStore {
1174
1173
  * history-inclusive view (backward-compatible default). */
1175
1174
  why(target, opts = {}) {
1176
1175
  target = toPosixTarget(target);
1177
- const decisions = this.recs("decisions");
1178
- const bugs = this.recs("bugs");
1179
- const constraints = this.recs("constraints");
1180
- const symbols = this.recs("symbols");
1181
- const components = this.recs("components");
1176
+ const decisions = this.recs("decisions").filter(opts.canRead ?? (() => true));
1177
+ const bugs = this.recs("bugs").filter(opts.canRead ?? (() => true));
1178
+ const constraints = this.recs("constraints").filter(opts.canRead ?? (() => true));
1179
+ const symbols = this.recs("symbols").filter(opts.canRead ?? (() => true));
1180
+ const components = this.recs("components").filter(opts.canRead ?? (() => true));
1182
1181
  const asOf = opts.asOf;
1183
1182
  // pathsRelated, not bare endsWith: "scenario.ts".endsWith("io.ts") is true,
1184
1183
  // so an unanchored suffix pulled unrelated files' records into why()/the
@@ -1801,13 +1800,13 @@ export class HunchStore {
1801
1800
  return out;
1802
1801
  }
1803
1802
  /** Bugs matching a symptom (FTS over bugs) or a symbol, with lineage (hunch_bug_lineage). */
1804
- bugLineage(symptomOrSymbol) {
1805
- const bugs = this.recs("bugs");
1803
+ bugLineage(symptomOrSymbol, canRead) {
1804
+ const bugs = this.recs("bugs").filter(canRead ?? (() => true));
1806
1805
  const direct = bugs.filter((b) => b.affected_symbols.includes(symptomOrSymbol) || b.affected_files.includes(symptomOrSymbol));
1807
1806
  if (direct.length)
1808
1807
  return direct;
1809
1808
  // fall back to fts over bug titles/symptoms
1810
- const hits = this.search(symptomOrSymbol).filter((h) => h.kind === "bugs").map((h) => h.ref);
1809
+ const hits = this.search(symptomOrSymbol, 12, canRead ? bugs.map(b => b.id) : undefined).filter((h) => h.kind === "bugs").map((h) => h.ref);
1811
1810
  const byHit = bugs.filter((b) => hits.includes(b.id));
1812
1811
  if (byHit.length)
1813
1812
  return byHit;
@@ -1905,7 +1904,7 @@ export class HunchStore {
1905
1904
  blast.set(d.id, d); // keep the MIN depth across start symbols
1906
1905
  }
1907
1906
  }
1908
- const bugs = w.bugs.length ? w.bugs : this.bugLineage(target);
1907
+ const bugs = w.bugs.length ? w.bugs : this.bugLineage(target, opts.canRead);
1909
1908
  const ctx = {
1910
1909
  target,
1911
1910
  constraints: w.constraints.sort((a, b) => sev(b.severity) - sev(a.severity)),
@@ -1913,13 +1912,13 @@ export class HunchStore {
1913
1912
  bugs,
1914
1913
  blast_radius: [...blast.values()].sort((a, b) => a.depth - b.depth).slice(0, 12),
1915
1914
  components: w.components,
1916
- findings: this.liveFindingsFor(target).slice(0, 8),
1915
+ findings: this.liveFindingsFor(target).filter(opts.canRead ?? (() => true)).slice(0, 8),
1917
1916
  // Landscape records do not yet carry a valid-time window. A historical
1918
1917
  // query therefore withholds them instead of mixing current graph state
1919
1918
  // into an as-of memory envelope.
1920
1919
  landscape: opts.asOf
1921
1920
  ? undefined
1922
- : selectReviewedLandscape(this.recs("resources"), this.recs("edges"), target),
1921
+ : selectReviewedLandscape(this.recs("resources").filter(opts.canRead ?? (() => true)), this.recs("edges").filter(opts.canRead ?? (() => true)), target),
1923
1922
  budget_tokens: budget,
1924
1923
  };
1925
1924
  return ctx;
@@ -0,0 +1,13 @@
1
+ /** A request-local access view. Never installs mutable filters on shared stores. */
2
+ import type { HunchStore } from './hunchStore.js';
3
+ import { type Principal } from '../core/stateContract.js';
4
+ export interface StateAccessOptions {
5
+ additionalStores?: readonly HunchStore[];
6
+ requireVisibility?: boolean;
7
+ }
8
+ export declare function createStateAccess(store: HunchStore, principal: Principal, opts?: StateAccessOptions): {
9
+ restricted: boolean;
10
+ canRead: (record: unknown) => boolean;
11
+ referencesVisible: (root: unknown) => boolean;
12
+ canWrite: (record: unknown) => boolean;
13
+ };
@@ -0,0 +1,85 @@
1
+ import { STATE_FACETS, scopePath } from '../core/stateContract.js';
2
+ import { visibilityAllows } from '../core/recordVisibility.js';
3
+ import { recordScope, partitionOf, partitionDeclarationOf } from './statePartition.js';
4
+ export function createStateAccess(store, principal, opts = {}) {
5
+ const repo = partitionOf(store), grants = new Set(principal.grants.map(scopePath));
6
+ const sources = [...new Set([store, ...(opts.additionalStores ?? [])])].map(source => ({ source, scope: partitionDeclarationOf(source) }));
7
+ // Protected writers persist this gate before the first restricted record and never remove it.
8
+ const restricted = !!opts.requireVisibility || sources.some(source => !!source.scope.required_capabilities?.length);
9
+ const recordScopes = new WeakMap();
10
+ const scoped = (record) => recordScopes.get(record) ?? recordScope(record, repo);
11
+ const baseAllows = (record, mode = 'read') => grants.has(scopePath(scoped(record))) && visibilityAllows(record, principal.id, mode);
12
+ const lookup = new Map();
13
+ function find(id) {
14
+ const cached = lookup.get(id);
15
+ if (cached)
16
+ return cached;
17
+ const found = [];
18
+ for (const { source, scope } of sources)
19
+ for (const facet of STATE_FACETS) {
20
+ let record;
21
+ // Exact capture/replay must not enumerate a high-cardinality collection. Its
22
+ // schemas require these prefixes; other strings cannot identify these records.
23
+ if (facet === 'derived' || facet === 'receipts' || facet === 'commitments') {
24
+ const prefix = { derived: 'nds', receipts: 'nrc', commitments: 'ncm' }[facet];
25
+ if (!new RegExp(`^${prefix}_[a-f0-9]{24}$`).test(id))
26
+ continue;
27
+ record = source.getStateDirect(facet, id, 'private') ?? source.getStateDirect(facet, id, 'public');
28
+ }
29
+ else
30
+ record = source.getRec(facet, id);
31
+ if (record) {
32
+ const value = record;
33
+ recordScopes.set(value, recordScope(value, scope));
34
+ found.push(value);
35
+ }
36
+ }
37
+ lookup.set(id, found);
38
+ return found;
39
+ }
40
+ // Walk only the reachable dependency graph, iteratively so cycles and deep chains
41
+ // cannot overflow the stack. Complete records are withheld, never hash-preserving redactions.
42
+ function referencesVisible(root) {
43
+ const queue = [root], visited = new Set();
44
+ for (let i = 0; i < queue.length; i++) {
45
+ const record = queue[i];
46
+ if (!record || typeof record !== 'object' || visited.has(record))
47
+ continue;
48
+ visited.add(record);
49
+ if (i > 0 && (!visibilityAllows(record, principal.id) || (restricted && !baseAllows(record))))
50
+ return false;
51
+ const own = scoped(record);
52
+ const values = [{ value: record }];
53
+ while (values.length) {
54
+ const { value, field } = values.pop();
55
+ if (field === 'visibility' || field === 'id')
56
+ continue;
57
+ if (typeof value === 'string') {
58
+ queue.push(...find(value));
59
+ continue;
60
+ }
61
+ if (Array.isArray(value)) {
62
+ for (const child of value)
63
+ values.push({ value: child });
64
+ continue;
65
+ }
66
+ if (!value || typeof value !== 'object')
67
+ continue;
68
+ const ref = value;
69
+ if (ref.kind === 'record' && typeof ref.id === 'string') {
70
+ const scope = recordScope(ref, own), matches = find(ref.id).filter(candidate => scopePath(scoped(candidate)) === scopePath(scope));
71
+ if (restricted && (!grants.has(scopePath(scope)) || !matches.length))
72
+ return false;
73
+ queue.push(...matches);
74
+ continue;
75
+ }
76
+ for (const [name, child] of Object.entries(ref))
77
+ values.push({ value: child, field: name });
78
+ }
79
+ }
80
+ return true;
81
+ }
82
+ const canRead = (record) => !!record && baseAllows(record) && referencesVisible(record);
83
+ return { restricted, canRead, referencesVisible, canWrite: (record) => canRead(record) && baseAllows(record, 'write') };
84
+ }
85
+ //# sourceMappingURL=stateAccess.js.map
@@ -1,27 +1,16 @@
1
+ import { type StateAccessOptions } from "./stateAccess.js";
1
2
  import { z } from "zod";
2
3
  import type { HunchStore } from "./hunchStore.js";
3
4
  import { readLedger } from "./changeLedger.js";
4
5
  import { type DeliveryEnvelope } from "../core/delivery.js";
5
6
  import { STATE_CONTRACT_VERSION, type Scope, type ReadResponse, type WriteResult, type RecordsResponse } from "../core/stateContract.js";
6
7
  /** A typed refusal. `code` is stable for bindings; `conflict` names the incumbent when one exists. */
7
- export declare class StateRefusal extends Error {
8
- readonly code: "outside-grants" | "unsupported" | "malformed" | "identity" | "conflict" | "no-partition-home" | "idempotency";
9
- readonly conflict: {
10
- incumbent_id: string;
11
- reason: string;
12
- } | null;
13
- constructor(code: "outside-grants" | "unsupported" | "malformed" | "identity" | "conflict" | "no-partition-home" | "idempotency", message: string, conflict?: {
14
- incumbent_id: string;
15
- reason: string;
16
- } | null);
17
- }
8
+ export { StateRefusal } from "./stateError.js";
18
9
  /** The partition this store IS. A served partition declares itself in `.hunch/partition.json`
19
10
  * (`{ kind, id }`, committed with the store); a plain checkout is the repository partition
20
11
  * named after its directory, sanitized to the contract's token grammar — stable per clone,
21
12
  * discoverable through `capabilities`, and the scope every legacy record defaults to. */
22
- export declare function partitionOf(store: HunchStore): Scope;
23
- /** @deprecated name kept for callers written before served partitions; same value as partitionOf. */
24
- export declare const repositoryScope: typeof partitionOf;
13
+ export { partitionOf, repositoryScope } from "./statePartition.js";
25
14
  export declare const SubscribeResponseSchema: z.ZodObject<{
26
15
  schema: z.ZodLiteral<"nuryel.state.subscribe/1">;
27
16
  scope: z.ZodObject<{
@@ -35,6 +24,11 @@ export declare const SubscribeResponseSchema: z.ZodObject<{
35
24
  }, z.core.$strict>;
36
25
  head_seq: z.ZodNumber;
37
26
  events: z.ZodArray<z.ZodObject<{
27
+ visibility: z.ZodOptional<z.ZodObject<{
28
+ owner: z.ZodString;
29
+ readers: z.ZodArray<z.ZodString>;
30
+ writers: z.ZodArray<z.ZodString>;
31
+ }, z.core.$strict>>;
38
32
  schema: z.ZodLiteral<"nuryel.state.subscribe/1">;
39
33
  seq: z.ZodNumber;
40
34
  at: z.ZodString;
@@ -57,6 +51,7 @@ export declare const SubscribeResponseSchema: z.ZodObject<{
57
51
  derived: "derived";
58
52
  entities: "entities";
59
53
  relationships: "relationships";
54
+ conventions: "conventions";
60
55
  }>;
61
56
  record_id: z.ZodString;
62
57
  record_hash: z.ZodString;
@@ -109,7 +104,7 @@ export declare function stateHomeFor(store: HunchStore, scope: Scope): {
109
104
  /** read — the system-of-record answer for a subject, under the delivery envelope's receipt.
110
105
  * Grants are the first predicate on every candidate; a matching record in a scope the
111
106
  * principal lacks is NAMED in denied_scopes and never described. */
112
- export declare function readState(store: HunchStore, input: unknown): {
107
+ export declare function readState(store: HunchStore, input: unknown, options?: StateAccessOptions): {
113
108
  response: ReadResponse;
114
109
  envelope: DeliveryEnvelope;
115
110
  };
@@ -123,7 +118,7 @@ export declare function readState(store: HunchStore, input: unknown): {
123
118
  * carries one delivery receipt per partition. Reusable by any host (HTTP today; MCP or CLI
124
119
  * fronting several roots later). */
125
120
  export declare function mergeReadResponses(primary: ReadResponse, others: readonly ReadResponse[], extraDenied?: readonly Scope[]): ReadResponse;
126
- export interface WriteOptions {
121
+ export interface WriteOptions extends StateAccessOptions {
127
122
  /** Internal batch owner rebuilds once in finally while holding the write lock. */
128
123
  deferReindex?: boolean;
129
124
  /** Internal cache scoped to one uninterrupted partition write lock. Never retained. */
@@ -139,7 +134,7 @@ export interface WriteOptions {
139
134
  export declare function writeState(store: HunchStore, input: unknown, opts?: WriteOptions): WriteResult;
140
135
  /** subscribe — the scope's ordered change stream after a cursor. Unfiltered, the events are
141
136
  * contiguous and assertChangeSequence holds; filtered, `head_seq` is still the cursor. */
142
- export declare function subscribeState(store: HunchStore, input: unknown): SubscribeResponse;
137
+ export declare function subscribeState(store: HunchStore, input: unknown, options?: StateAccessOptions): SubscribeResponse;
143
138
  /** records — fetch by id, grants first. Every id is accounted for: found, denied (its scope is
144
139
  * outside the grants — named, never described) or missing. */
145
- export declare function recordsState(store: HunchStore, input: unknown): RecordsResponse;
140
+ export declare function recordsState(store: HunchStore, input: unknown, options?: StateAccessOptions): RecordsResponse;