@davesheffer/hunch 1.32.8 → 1.35.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 (79) hide show
  1. package/README.md +18 -4
  2. package/dist/cli/index.js +52 -6
  3. package/dist/cli/invocation.d.ts +8 -0
  4. package/dist/cli/invocation.js +17 -10
  5. package/dist/cli/serve.js +28 -2
  6. package/dist/cli/state.d.ts +3 -0
  7. package/dist/cli/state.js +150 -0
  8. package/dist/cli/taskReport.js +52 -4
  9. package/dist/cli/update.js +5 -5
  10. package/dist/client/state.d.ts +86 -18
  11. package/dist/client/state.js +16 -2
  12. package/dist/client/stateProof.d.ts +4 -0
  13. package/dist/client/stateProof.js +17 -0
  14. package/dist/constitution/behaviorEvaluator.js +1 -1
  15. package/dist/constitution/schema.d.ts +14 -14
  16. package/dist/core/automaticReviewMemory.d.ts +5 -0
  17. package/dist/core/conventionDelivery.d.ts +8 -0
  18. package/dist/core/conventionDelivery.js +52 -0
  19. package/dist/core/fieldProvenance.d.ts +8 -0
  20. package/dist/core/fieldProvenance.js +72 -0
  21. package/dist/core/recordVisibility.d.ts +9 -0
  22. package/dist/core/recordVisibility.js +25 -0
  23. package/dist/core/stateCanonical.d.ts +3 -0
  24. package/dist/core/stateCanonical.js +34 -0
  25. package/dist/core/stateContract.d.ts +125 -10
  26. package/dist/core/stateContract.js +26 -31
  27. package/dist/core/stateDelivery.d.ts +3 -3
  28. package/dist/core/stateDelivery.js +10 -1
  29. package/dist/core/stateHttp.d.ts +280 -0
  30. package/dist/core/stateHttp.js +17 -0
  31. package/dist/core/stateProof.d.ts +13 -0
  32. package/dist/core/stateProof.js +34 -0
  33. package/dist/core/stateRecords.d.ts +127 -0
  34. package/dist/core/stateRecords.js +48 -0
  35. package/dist/core/taskRecord.d.ts +39 -0
  36. package/dist/core/taskRecord.js +185 -0
  37. package/dist/core/taskReport.d.ts +8 -1
  38. package/dist/core/taskReport.js +28 -14
  39. package/dist/core/taskReportEvidence.js +2 -1
  40. package/dist/core/taskReportHook.d.ts +18 -3
  41. package/dist/core/taskReportHook.js +77 -8
  42. package/dist/core/taskReportPaths.d.ts +6 -0
  43. package/dist/core/taskReportPaths.js +13 -0
  44. package/dist/core/types.d.ts +321 -4
  45. package/dist/core/types.js +47 -2
  46. package/dist/core/updatecheck.d.ts +51 -0
  47. package/dist/core/updatecheck.js +266 -0
  48. package/dist/core/version.d.ts +2 -0
  49. package/dist/core/version.js +3 -1
  50. package/dist/extractors/git.js +3 -10
  51. package/dist/integrations/gitignore.js +1 -0
  52. package/dist/integrations/health.js +27 -2
  53. package/dist/mcp/server.js +15 -5
  54. package/dist/mcp/taskReportTools.d.ts +4 -4
  55. package/dist/mcp/taskReportTools.js +32 -3
  56. package/dist/serve/app.d.ts +2 -0
  57. package/dist/serve/app.js +71 -30
  58. package/dist/serve/config.d.ts +16 -0
  59. package/dist/serve/config.js +27 -7
  60. package/dist/serve/operator.d.ts +4 -0
  61. package/dist/serve/operator.js +223 -0
  62. package/dist/serve/stateProof.d.ts +15 -0
  63. package/dist/serve/stateProof.js +105 -0
  64. package/dist/store/changeLedger.d.ts +6 -0
  65. package/dist/store/hunchStore.d.ts +9 -3
  66. package/dist/store/hunchStore.js +36 -19
  67. package/dist/store/stateAccess.d.ts +13 -0
  68. package/dist/store/stateAccess.js +85 -0
  69. package/dist/store/stateBinding.d.ts +13 -18
  70. package/dist/store/stateBinding.js +161 -52
  71. package/dist/store/stateCapture.js +10 -2
  72. package/dist/store/stateError.d.ts +12 -0
  73. package/dist/store/stateError.js +12 -0
  74. package/dist/store/statePartition.d.ts +9 -0
  75. package/dist/store/statePartition.js +30 -0
  76. package/dist/taskReports.d.ts +1 -1
  77. package/dist/taskReports.js +16 -4
  78. package/package.json +5 -1
  79. package/server.json +2 -2
@@ -0,0 +1,15 @@
1
+ import { type ProofPublicKey } from '../core/stateProof.js';
2
+ export declare class StateProofError extends Error {
3
+ readonly code: 'invalid_dpop_proof' | 'use_dpop_nonce';
4
+ readonly nonce?: string | undefined;
5
+ constructor(code: 'invalid_dpop_proof' | 'use_dpop_nonce', nonce?: string | undefined);
6
+ }
7
+ export declare function verifyStateProof(input: {
8
+ proof?: string;
9
+ key: ProofPublicKey;
10
+ method: string;
11
+ url: string;
12
+ token: string;
13
+ stateDir: string;
14
+ now?: number;
15
+ }): Promise<void>;
@@ -0,0 +1,105 @@
1
+ import { withWriteLock } from './writelock.js';
2
+ /** Shared-disk nonce/replay state survives restarts and serializes independent server processes. */
3
+ import { createHash, createHmac, createPublicKey, randomBytes, timingSafeEqual, verify } from 'node:crypto';
4
+ import { chmodSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, rmSync } from 'node:fs';
5
+ import { join } from 'node:path';
6
+ import { z } from 'zod';
7
+ import { writeFileAtomicIfAbsent } from '../core/io.js';
8
+ import { ProofPublicKeySchema, proofThumbprint, proofTarget, tokenProofHash } from '../core/stateProof.js';
9
+ const Header = z.object({ typ: z.literal('dpop+jwt'), alg: z.literal('EdDSA'), jwk: ProofPublicKeySchema }).strict();
10
+ const Claims = z.object({ jti: z.string().min(16).max(128), htm: z.string().max(16), htu: z.string().max(2048), iat: z.number().int().nonnegative(), ath: z.string().regex(/^[A-Za-z0-9_-]{43}$/), nonce: z.string().max(128).optional() }).strict();
11
+ export class StateProofError extends Error {
12
+ code;
13
+ nonce;
14
+ constructor(code, nonce) {
15
+ super(code === 'use_dpop_nonce' ? 'a fresh server nonce is required' : 'request proof is invalid or already used');
16
+ this.code = code;
17
+ this.nonce = nonce;
18
+ }
19
+ }
20
+ function ordinaryDirectory(path) {
21
+ mkdirSync(path, { recursive: true, mode: 0o700 });
22
+ const stat = lstatSync(path);
23
+ if (!stat.isDirectory() || stat.isSymbolicLink())
24
+ throw new Error('proof state must be an ordinary private directory');
25
+ if ((stat.mode & 0o777) !== 0o700)
26
+ chmodSync(path, 0o700);
27
+ }
28
+ function equal(a, b) { const x = Buffer.from(a), y = Buffer.from(b); return x.length === y.length && timingSafeEqual(x, y); }
29
+ function decode(segment) {
30
+ if (!/^[A-Za-z0-9_-]+$/.test(segment) || Buffer.from(segment, 'base64url').toString('base64url') !== segment)
31
+ throw new StateProofError('invalid_dpop_proof');
32
+ return JSON.parse(Buffer.from(segment, 'base64url').toString('utf8'));
33
+ }
34
+ const lastSweep = new Map();
35
+ export async function verifyStateProof(input) {
36
+ const now = input.now ?? Math.floor(Date.now() / 1000), thumbprint = proofThumbprint(input.key);
37
+ ordinaryDirectory(input.stateDir);
38
+ const secretFile = join(input.stateDir, 'nonce-key');
39
+ if (!existsSync(secretFile))
40
+ writeFileAtomicIfAbsent(secretFile, randomBytes(32).toString('hex'));
41
+ const stat = lstatSync(secretFile);
42
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size !== 64)
43
+ throw new Error('proof nonce key is invalid');
44
+ if ((stat.mode & 0o777) !== 0o600)
45
+ chmodSync(secretFile, 0o600);
46
+ const secret = readFileSync(secretFile, 'utf8');
47
+ if (!/^[a-f0-9]{64}$/.test(secret))
48
+ throw new Error('proof nonce key is invalid');
49
+ const epoch = Math.floor(now / 60), tokenHash = tokenProofHash(input.token);
50
+ const nonceAt = (period) => `${period}.${createHmac('sha256', Buffer.from(secret, 'hex')).update(`${period}:${thumbprint}:${tokenHash}`).digest('base64url')}`;
51
+ const nonce = nonceAt(epoch);
52
+ if (!input.proof)
53
+ throw new StateProofError('use_dpop_nonce', nonce);
54
+ let claims;
55
+ try {
56
+ if (input.proof.length > 8192)
57
+ throw new Error('oversized');
58
+ const parts = input.proof.split('.');
59
+ if (parts.length !== 3)
60
+ throw new Error('JWT');
61
+ const header = Header.parse(decode(parts[0]));
62
+ claims = Claims.parse(decode(parts[1]));
63
+ const signature = Buffer.from(parts[2], 'base64url');
64
+ if (signature.length !== 64 || signature.toString('base64url') !== parts[2])
65
+ throw new Error('signature');
66
+ if (!equal(proofThumbprint(header.jwk), thumbprint))
67
+ throw new Error('key');
68
+ if (!verify(null, Buffer.from(parts[0] + '.' + parts[1]), createPublicKey({ key: header.jwk, format: 'jwk' }), signature))
69
+ throw new Error('signature');
70
+ if (claims.htm !== input.method || claims.htu !== proofTarget(input.url) || !equal(claims.ath, tokenHash) || claims.iat < now - 60 || claims.iat > now + 5)
71
+ throw new Error('binding');
72
+ }
73
+ catch {
74
+ throw new StateProofError('invalid_dpop_proof');
75
+ }
76
+ if (!claims.nonce || (!equal(claims.nonce, nonce) && !equal(claims.nonce, nonceAt(epoch - 1))))
77
+ throw new StateProofError('use_dpop_nonce', nonce);
78
+ await withWriteLock(input.stateDir, () => {
79
+ const replayRoot = join(input.stateDir, 'used');
80
+ ordinaryDirectory(replayRoot);
81
+ // One atomic filename per key/jti, independent of iat: concurrent proofs with
82
+ // different timestamps cannot each win in a separate bucket.
83
+ if (lastSweep.get(replayRoot) !== epoch) {
84
+ for (const entry of readdirSync(replayRoot, { withFileTypes: true })) {
85
+ if (!/^[a-f0-9]{64}$/.test(entry.name) || !entry.isFile() || entry.isSymbolicLink())
86
+ continue;
87
+ const file = join(replayRoot, entry.name);
88
+ try {
89
+ const expires = Number(readFileSync(file, 'utf8'));
90
+ if (Number.isFinite(expires) && expires < now)
91
+ rmSync(file);
92
+ }
93
+ catch (error) {
94
+ if (error.code !== 'ENOENT')
95
+ throw error;
96
+ }
97
+ }
98
+ lastSweep.set(replayRoot, epoch);
99
+ }
100
+ const id = createHash('sha256').update(thumbprint + '\0' + claims.jti).digest('hex');
101
+ if (!writeFileAtomicIfAbsent(join(replayRoot, id), String(claims.iat + 60)))
102
+ throw new StateProofError('invalid_dpop_proof');
103
+ });
104
+ }
105
+ //# sourceMappingURL=stateProof.js.map
@@ -25,6 +25,11 @@ export declare const LedgerSchema: z.ZodObject<{
25
25
  head_seq: z.ZodNumber;
26
26
  floor_seq: z.ZodDefault<z.ZodNumber>;
27
27
  events: z.ZodArray<z.ZodObject<{
28
+ visibility: z.ZodOptional<z.ZodObject<{
29
+ owner: z.ZodString;
30
+ readers: z.ZodArray<z.ZodString>;
31
+ writers: z.ZodArray<z.ZodString>;
32
+ }, z.core.$strict>>;
28
33
  schema: z.ZodLiteral<"nuryel.state.subscribe/1">;
29
34
  seq: z.ZodNumber;
30
35
  at: z.ZodString;
@@ -47,6 +52,7 @@ export declare const LedgerSchema: z.ZodObject<{
47
52
  derived: "derived";
48
53
  entities: "entities";
49
54
  relationships: "relationships";
55
+ conventions: "conventions";
50
56
  }>;
51
57
  record_id: z.ZodString;
52
58
  record_hash: z.ZodString;
@@ -1,5 +1,5 @@
1
1
  import { type HunchPaths } from "../core/paths.js";
2
- import { type Component, type Constraint, type Bug, type Decision, type Symbol, type Edge, type Finding, type EntityKind, type EntityFor } from "../core/types.js";
2
+ import { type Component, type Constraint, type Bug, type Decision, type Symbol, type Edge, type Finding, type EntityKind, type EntityFor, type TaskRecord } from "../core/types.js";
3
3
  import { type DB } from "./db.js";
4
4
  import { type Embedder } from "./embedder.js";
5
5
  import { JsonStore } from "./jsonStore.js";
@@ -191,7 +191,7 @@ export declare class HunchStore {
191
191
  * relevance ordering (liveness/provenance/recency + topic-chain promotion)
192
192
  * go through hybridSearch/searchScoped, where rerankByPriors applies.
193
193
  * Falls back to LIKE if the query has no FTS-tokenizable terms. */
194
- search(query: string, limit?: number): SearchHit[];
194
+ search(query: string, limit?: number, allowedIds?: readonly string[]): SearchHit[];
195
195
  /** State-of-record ordering for nuryel.state/1 hits (superseded derived, done/cancelled
196
196
  * commitments, failed receipts, retired entities): indexed and findable, but ranked BELOW
197
197
  * the live record of the same subject. bm25 is negative (lower = better), so a history
@@ -341,6 +341,7 @@ export declare class HunchStore {
341
341
  * history-inclusive view (backward-compatible default). */
342
342
  why(target: string, opts?: {
343
343
  asOf?: string;
344
+ canRead?: (record: unknown) => boolean;
344
345
  }): WhyResult;
345
346
  /** Transitive blast radius: every symbol/component that (in)directly depends on
346
347
  * `id`, via a recursive CTE over the edges graph (hunch_get_dependents). We
@@ -404,6 +405,10 @@ export declare class HunchStore {
404
405
  * glob, and the queried scope may be either too. Advisory only — findings never
405
406
  * enter any block path. Sorted worst-first, then id for stable output. */
406
407
  liveFindingsFor(scope: string): Finding[];
408
+ /** Finished agent tasks that touched a file or glob (rule-checked changes and
409
+ * denied edits), newest first. Graph memory, so it spans machines and survives
410
+ * the local ledger's retention window. */
411
+ tasksFor(scope: string, limit?: number): TaskRecord[];
407
412
  /** The causal chain behind a constraint — the WHY a diff-only reviewer can't see.
408
413
  * Deterministic graph join: constraint → source_decision (the decision that
409
414
  * motivated the guard) → the bug whose root cause spawned it (via
@@ -488,7 +493,7 @@ export declare class HunchStore {
488
493
  * available at edit time, so this surfaces the risk as context, not a block. */
489
494
  retiredForFile(file: string): RetiredNote[];
490
495
  /** Bugs matching a symptom (FTS over bugs) or a symbol, with lineage (hunch_bug_lineage). */
491
- bugLineage(symptomOrSymbol: string): Bug[];
496
+ bugLineage(symptomOrSymbol: string, canRead?: (record: unknown) => boolean): Bug[];
492
497
  /** Ranked fragility report (hunch fragile). fragility = weighted churn + bugs + fan-in. */
493
498
  fragility(limit?: number): FragileNode[];
494
499
  /** Convenience: load a single entity from JSON by id (any kind). */
@@ -507,6 +512,7 @@ export declare class HunchStore {
507
512
  * why, then blast radius and bug history — trimmed to a rough token budget. */
508
513
  assembleContext(target: string, budget?: number, opts?: {
509
514
  asOf?: string;
515
+ canRead?: (record: unknown) => boolean;
510
516
  }): AssembledContext;
511
517
  }
512
518
  /** The graph-served repo shape (hunch_structure) — orient without grep rounds. */
@@ -491,6 +491,14 @@ export class HunchStore {
491
491
  fts(f.id, "findings", f.title, `${f.observation} ${f.evidence.join(" ")} ${f.affected_files.join(" ")} ${f.affected_symbols.join(" ")} ${f.triage}`);
492
492
  }
493
493
  counts.findings = fnds.length;
494
+ // Tasks (finished agent work as graph memory): same FTS-only ride. Title +
495
+ // the lesson/save/application record ids and touched files, so "what did
496
+ // an agent do around X" and a record id both hit.
497
+ const tasks = this.recs("tasks");
498
+ for (const t of tasks) {
499
+ fts(t.id, "tasks", t.title, `${t.lessons.map((l) => `${l.record_id} ${l.title}`).join(" ")} ${t.applied.map((a) => a.record_id).join(" ")} ${t.saved.map((s) => s.record_id).join(" ")} ${t.files.join(" ")} ${t.state} ${t.coverage}`);
500
+ }
501
+ counts.tasks = tasks.length;
494
502
  // nuryel.state/1 kinds (receipts, commitments, derived, entities, relationships):
495
503
  // advisory records on the same FTS-only ride as runbooks/findings — no dedicated
496
504
  // SQL table. kind = the store kind; title = the subject key; body = the human words
@@ -518,20 +526,20 @@ export class HunchStore {
518
526
  * relevance ordering (liveness/provenance/recency + topic-chain promotion)
519
527
  * go through hybridSearch/searchScoped, where rerankByPriors applies.
520
528
  * Falls back to LIKE if the query has no FTS-tokenizable terms. */
521
- search(query, limit = 12) {
529
+ search(query, limit = 12, allowedIds) {
522
530
  const match = toFtsQuery(query);
523
531
  // No FTS-tokenizable terms (e.g. a CJK-only query) — degrade to LIKE rather
524
532
  // than silently returning nothing (the documented fallback).
525
533
  if (!match)
526
- return this.likeSearch(query, limit);
534
+ return this.likeSearch(query, limit, undefined, allowedIds);
527
535
  try {
528
536
  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);
537
+ 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
538
  return this.demoteHistoricalState(rows.map((r) => ({ ref: r.ref, kind: r.kind, title: r.title, snippet: r.snip, score: r.score })));
531
539
  }
532
540
  catch {
533
541
  // Malformed FTS expression — degrade to a LIKE scan over titles/bodies.
534
- return this.likeSearch(query, limit);
542
+ return this.likeSearch(query, limit, undefined, allowedIds);
535
543
  }
536
544
  }
537
545
  /** State-of-record ordering for nuryel.state/1 hits (superseded derived, done/cancelled
@@ -635,7 +643,7 @@ export class HunchStore {
635
643
  * `hunchrecorddecision` and matched nothing — on precisely the runtimes with no FTS5,
636
644
  * where this fallback is the only search there is. Escaping keeps the term literal;
637
645
  * leaving `_` unescaped would silently over-match instead. */
638
- likeSearch(query, limit, kind) {
646
+ likeSearch(query, limit, kind, allowedIds) {
639
647
  const terms = (query.toLowerCase().match(/[\p{L}\p{N}_]+/gu)
640
648
  ?? [query.toLowerCase().trim()].filter(Boolean)).slice(0, 32);
641
649
  if (!terms.length)
@@ -645,8 +653,7 @@ export class HunchStore {
645
653
  const like = `%${term.replace(/[\\%_]/g, "\\$&")}%`;
646
654
  return [like, like];
647
655
  });
648
- const where = kind ? `kind = ? AND (${predicates})` : `(${predicates})`;
649
- const params = kind ? [kind, ...likes, limit] : [...likes, limit];
656
+ const where = (kind ? `kind = ? AND (${predicates})` : `(${predicates})`) + (allowedIds ? " AND ref IN (SELECT value FROM json_each(?))" : "");
650
657
  // Ordered so a TRUNCATING limit drops the least relevant row rather than an
651
658
  // arbitrary one: a title hit outranks a body-only hit, then shortest title
652
659
  // (a constraint's one-line statement beats a long decision body that merely
@@ -657,7 +664,7 @@ export class HunchStore {
657
664
  const rows = this.db.prepare(`SELECT ref, kind, title, substr(body,1,120) AS snip FROM search
658
665
  WHERE ${where}
659
666
  ORDER BY CASE WHEN ${titleLikes} THEN 0 ELSE 1 END, length(title), ref
660
- LIMIT ?`).all(...(kind ? [kind, ...likes, ...titleParams, limit] : [...likes, ...titleParams, limit]));
667
+ LIMIT ?`).all(...[...(kind ? [kind] : []), ...likes, ...(allowedIds ? [JSON.stringify(allowedIds)] : []), ...titleParams, limit]);
661
668
  return this.demoteHistoricalState(rows.map((r) => ({ ref: r.ref, kind: r.kind, title: r.title, snippet: r.snip, score: 0 })));
662
669
  }
663
670
  // ---- semantic search (opt-in embeddings) --------------------------------
@@ -1174,11 +1181,11 @@ export class HunchStore {
1174
1181
  * history-inclusive view (backward-compatible default). */
1175
1182
  why(target, opts = {}) {
1176
1183
  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");
1184
+ const decisions = this.recs("decisions").filter(opts.canRead ?? (() => true));
1185
+ const bugs = this.recs("bugs").filter(opts.canRead ?? (() => true));
1186
+ const constraints = this.recs("constraints").filter(opts.canRead ?? (() => true));
1187
+ const symbols = this.recs("symbols").filter(opts.canRead ?? (() => true));
1188
+ const components = this.recs("components").filter(opts.canRead ?? (() => true));
1182
1189
  const asOf = opts.asOf;
1183
1190
  // pathsRelated, not bare endsWith: "scenario.ts".endsWith("io.ts") is true,
1184
1191
  // so an unanchored suffix pulled unrelated files' records into why()/the
@@ -1436,6 +1443,16 @@ export class HunchStore {
1436
1443
  || f.affected_symbols.some((s) => s === scope))
1437
1444
  .sort((a, b) => (SEV_FINDING[b.severity] ?? 0) - (SEV_FINDING[a.severity] ?? 0) || a.id.localeCompare(b.id));
1438
1445
  }
1446
+ /** Finished agent tasks that touched a file or glob (rule-checked changes and
1447
+ * denied edits), newest first. Graph memory, so it spans machines and survives
1448
+ * the local ledger's retention window. */
1449
+ tasksFor(scope, limit = 8) {
1450
+ const t = toPosixTarget(scope);
1451
+ return this.recs("tasks")
1452
+ .filter((r) => r.files.some((f) => pathMatchesGlob(t, f) || pathMatchesGlob(f, t) || pathsRelated(toPosixTarget(f), t)))
1453
+ .sort((a, b) => b.finished_at.localeCompare(a.finished_at) || a.id.localeCompare(b.id))
1454
+ .slice(0, Math.max(1, limit));
1455
+ }
1439
1456
  /** The causal chain behind a constraint — the WHY a diff-only reviewer can't see.
1440
1457
  * Deterministic graph join: constraint → source_decision (the decision that
1441
1458
  * motivated the guard) → the bug whose root cause spawned it (via
@@ -1801,13 +1818,13 @@ export class HunchStore {
1801
1818
  return out;
1802
1819
  }
1803
1820
  /** Bugs matching a symptom (FTS over bugs) or a symbol, with lineage (hunch_bug_lineage). */
1804
- bugLineage(symptomOrSymbol) {
1805
- const bugs = this.recs("bugs");
1821
+ bugLineage(symptomOrSymbol, canRead) {
1822
+ const bugs = this.recs("bugs").filter(canRead ?? (() => true));
1806
1823
  const direct = bugs.filter((b) => b.affected_symbols.includes(symptomOrSymbol) || b.affected_files.includes(symptomOrSymbol));
1807
1824
  if (direct.length)
1808
1825
  return direct;
1809
1826
  // fall back to fts over bug titles/symptoms
1810
- const hits = this.search(symptomOrSymbol).filter((h) => h.kind === "bugs").map((h) => h.ref);
1827
+ const hits = this.search(symptomOrSymbol, 12, canRead ? bugs.map(b => b.id) : undefined).filter((h) => h.kind === "bugs").map((h) => h.ref);
1811
1828
  const byHit = bugs.filter((b) => hits.includes(b.id));
1812
1829
  if (byHit.length)
1813
1830
  return byHit;
@@ -1905,7 +1922,7 @@ export class HunchStore {
1905
1922
  blast.set(d.id, d); // keep the MIN depth across start symbols
1906
1923
  }
1907
1924
  }
1908
- const bugs = w.bugs.length ? w.bugs : this.bugLineage(target);
1925
+ const bugs = w.bugs.length ? w.bugs : this.bugLineage(target, opts.canRead);
1909
1926
  const ctx = {
1910
1927
  target,
1911
1928
  constraints: w.constraints.sort((a, b) => sev(b.severity) - sev(a.severity)),
@@ -1913,13 +1930,13 @@ export class HunchStore {
1913
1930
  bugs,
1914
1931
  blast_radius: [...blast.values()].sort((a, b) => a.depth - b.depth).slice(0, 12),
1915
1932
  components: w.components,
1916
- findings: this.liveFindingsFor(target).slice(0, 8),
1933
+ findings: this.liveFindingsFor(target).filter(opts.canRead ?? (() => true)).slice(0, 8),
1917
1934
  // Landscape records do not yet carry a valid-time window. A historical
1918
1935
  // query therefore withholds them instead of mixing current graph state
1919
1936
  // into an as-of memory envelope.
1920
1937
  landscape: opts.asOf
1921
1938
  ? undefined
1922
- : selectReviewedLandscape(this.recs("resources"), this.recs("edges"), target),
1939
+ : selectReviewedLandscape(this.recs("resources").filter(opts.canRead ?? (() => true)), this.recs("edges").filter(opts.canRead ?? (() => true)), target),
1923
1940
  budget_tokens: budget,
1924
1941
  };
1925
1942
  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;