@davesheffer/hunch 1.27.0 → 1.28.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.
package/dist/cli/index.js CHANGED
@@ -55,7 +55,8 @@ import { updateClaudeMd } from "../integrations/claudemd.js";
55
55
  import { writeMcpJson, writeSlashCommands, installClaudeHooks } from "../integrations/scaffold.js";
56
56
  import { scaffoldProviders, regenerateGrounding, refreshExistingGrounding, refreshCommittableGrounding } from "../integrations/providers.js";
57
57
  import { healClaudeConfigCaseSplit } from "../integrations/claudeConfig.js";
58
- import { formatContext, formatStructure } from "../core/format.js";
58
+ import { formatContext, formatSearchHit, formatStructure } from "../core/format.js";
59
+ import { isStateKind, renderStateLine, stateSupplements } from "../core/stateDelivery.js";
59
60
  import { diagnoseIssueCorrectionStage, formatCorrectionStageDiagnostic } from "../core/correctionStage.js";
60
61
  import { compileVerifiedEvidenceMap, formatVerifiedEvidenceMap } from "../core/evidenceMap.js";
61
62
  import { collectCorrectionStageSources } from "../extractors/correctionSources.js";
@@ -1320,7 +1321,7 @@ program
1320
1321
  else {
1321
1322
  console.log(`Top matches for "${q}"${how}:\n`);
1322
1323
  for (const h of hits)
1323
- console.log(`• [${h.kind}] ${h.ref} ${h.title}\n ${h.snippet}`);
1324
+ console.log(formatSearchHit(h, isStateKind(h.kind) ? store.resolve(h.ref)?.record : undefined));
1324
1325
  }
1325
1326
  store.close();
1326
1327
  });
@@ -4059,12 +4060,24 @@ program
4059
4060
  !ctx.findings.length &&
4060
4061
  !ctx.landscape?.resources.length &&
4061
4062
  !ctx.landscape?.relationships.length;
4063
+ // The "State" section (nuryel.state/1): current derived, in-force commitments, latest
4064
+ // receipts matching the target — the same slice and render as hunch_context.
4065
+ const slice = asOf ? null : store.stateSlice(target);
4066
+ const stateGrounding = slice ? stateSupplements(slice, target) : [];
4062
4067
  if (empty && !asOf) {
4063
- const hits = store.rankedSearch(target, 8);
4064
- if (hits.length) {
4068
+ const hits = store.rankedSearch(target, 8).filter((h) => !isStateKind(h.kind));
4069
+ if (hits.length || stateGrounding.length) {
4065
4070
  console.log(`No file/symbol resolves for "${target}" — closest graph matches instead:\n`);
4066
4071
  for (const h of hits)
4067
4072
  console.log(`• ${h.ref} — ${h.title}\n ${h.snippet}`);
4073
+ if (slice) {
4074
+ const stateHits = [...slice.derived, ...slice.commitments, ...slice.receipts];
4075
+ if (stateHits.length) {
4076
+ console.log(`${hits.length ? "\n" : ""}State (nuryel.state/1):`);
4077
+ for (const hit of stateHits)
4078
+ console.log(`• ${renderStateLine(hit.kind, hit.record)}`);
4079
+ }
4080
+ }
4068
4081
  console.log(`\n(For a file/symbol brief use a concrete target; for free-text this is what \`hunch query\` returns.)`);
4069
4082
  store.close();
4070
4083
  return;
@@ -4077,6 +4090,7 @@ program
4077
4090
  decisionCorpus: store.recs("decisions"),
4078
4091
  historical: !!asOf,
4079
4092
  profile: opts.profile,
4093
+ supplements: stateGrounding,
4080
4094
  }));
4081
4095
  store.close();
4082
4096
  });
package/dist/cli/serve.js CHANGED
@@ -57,7 +57,7 @@ export function registerServeCommands(program) {
57
57
  });
58
58
  serve.command("init")
59
59
  .description("Declare a partition directory and mint a principal token (printed once; only its hash is stored)")
60
- .requiredOption("--partition <kind:id>", "the scope this directory IS, e.g. user:david or organization:ylm")
60
+ .requiredOption("--partition <kind:id>", "the scope this directory IS, e.g. user:david or organization:acme")
61
61
  .requiredOption("--root <dir>", "directory whose .hunch/ holds the partition (created if missing)")
62
62
  .option("--config <file>", `serve config to create or extend; default ${DEFAULT_CONFIG}`)
63
63
  .option("--principal <id>", "principal to add or rotate, granted this partition")
@@ -1,7 +1,18 @@
1
1
  import { buildDeliveryEnvelope } from "./delivery.js";
2
+ import { isStateKind, renderStateLine } from "./stateDelivery.js";
2
3
  export function formatContext(ctx, options = {}) {
3
4
  return buildDeliveryEnvelope(ctx, options).text;
4
5
  }
6
+ /** One search hit as `hunch query` / hunch_query print it (headline + indented detail line).
7
+ * Graph records keep their `[kind] id — title` shape; a nuryel.state/1 hit renders through
8
+ * renderStateLine (`[commitment/in_force] customer:Site:7 — "send report" due … (owner …)`)
9
+ * with the record id on the detail line, so both readers say the same thing. */
10
+ export function formatSearchHit(hit, record) {
11
+ if (isStateKind(hit.kind) && record) {
12
+ return `• ${renderStateLine(hit.kind, record)}\n ${hit.ref}`;
13
+ }
14
+ return `• [${hit.kind}] ${hit.ref} — ${hit.title}\n ${hit.snippet}`;
15
+ }
5
16
  /** Render a StructureView as a compact orientation brief (hunch_structure). */
6
17
  export function formatStructure(v) {
7
18
  const NL = "\n";
@@ -56,10 +56,15 @@ export const PrincipalSchema = z.object({
56
56
  }).strict();
57
57
  export const STATE_FACETS = ["decisions", "constraints", "bugs", "findings", "receipts", "commitments", "derived", "entities", "relationships"];
58
58
  // ---- verbs ------------------------------------------------------------------------------
59
+ /** Union read: the partitions a principal wants in ONE answer. `scope` stays required (it is the
60
+ * primary partition; its envelope and receipt lead the response). An entry the principal is not
61
+ * granted is NAMED in `denied_scopes` — it never refuses the whole call, and is never described. */
62
+ export const ReadScopesSchema = z.array(ScopeSchema).min(1).max(64);
59
63
  export const ReadRequestSchema = z.object({
60
64
  schema: z.literal(STATE_READ_VERSION),
61
65
  principal: PrincipalSchema,
62
66
  scope: ScopeSchema,
67
+ scopes: ReadScopesSchema.optional(),
63
68
  subject: z.string().max(512).optional(),
64
69
  task: z.string().max(4096).optional(),
65
70
  profile: z.enum(DELIVERY_PROFILES).optional(),
@@ -92,6 +97,12 @@ export const ReadResponseSchema = z.object({
92
97
  /** The records behind every ref in `state_of_record`, by id, so a consumer can answer from
93
98
  * the drawer without a second lookup. Additive; absent when there is no subject. */
94
99
  records: z.record(z.string(), z.record(z.string(), z.unknown())).optional(),
100
+ /** Union read (additive): the partitions actually read, primary first. Absent on a
101
+ * single-partition read. */
102
+ scopes: z.array(ScopeSchema).max(64).optional(),
103
+ /** Union read (additive): one delivery receipt per partition read; `receipt_id` above stays
104
+ * the primary's. */
105
+ receipts: z.array(z.object({ scope: ScopeSchema, receipt_id: z.string().regex(/^hdr_[a-f0-9]{24}$/) }).strict()).max(64).optional(),
95
106
  }).strict();
96
107
  export const WriteRequestSchema = z.object({
97
108
  schema: z.literal(STATE_WRITE_VERSION),
@@ -236,6 +247,14 @@ export function assertReadWithinGrants(principal, response) {
236
247
  if (granted.has(grantKey(denied)))
237
248
  throw new Error(`denied scope ${grantKey(denied)} is actually granted — the response is inconsistent`);
238
249
  }
250
+ for (const read of response.scopes ?? []) {
251
+ if (!granted.has(grantKey(read)))
252
+ throw new Error(`read scope ${grantKey(read)} is outside the principal's grants`);
253
+ }
254
+ for (const receipt of response.receipts ?? []) {
255
+ if (!granted.has(grantKey(receipt.scope)))
256
+ throw new Error(`receipt for scope ${grantKey(receipt.scope)} is outside the principal's grants`);
257
+ }
239
258
  }
240
259
  /** provenance-on-every-write + scope agreement between the envelope and the record. */
241
260
  export function assertWriteWellFormed(request) {
@@ -0,0 +1,179 @@
1
+ export const STATE_KINDS = ["receipts", "commitments", "derived", "entities", "relationships"];
2
+ const STATE_KIND_SET = new Set(STATE_KINDS);
3
+ export function isStateKind(kind) {
4
+ return STATE_KIND_SET.has(kind);
5
+ }
6
+ /** Singular facet label used in renders: `[commitment/in_force]`, `[derived/current]`. */
7
+ const FACET_LABEL = {
8
+ receipts: "receipt",
9
+ commitments: "commitment",
10
+ derived: "derived",
11
+ entities: "entity",
12
+ relationships: "relationship",
13
+ };
14
+ /** Liveness per kind, mirroring readState's state_of_record predicates exactly
15
+ * (derived `current` with an open window; commitment open/waiting with an open window;
16
+ * receipt succeeded/verified; entity active; a relationship is always current). */
17
+ export function stateLiveness(kind, record) {
18
+ switch (kind) {
19
+ case "derived": {
20
+ const d = record;
21
+ if (d.valid_to != null)
22
+ return { label: "superseded", live: false };
23
+ return { label: d.state, live: d.state === "current" };
24
+ }
25
+ case "commitments": {
26
+ const c = record;
27
+ if (c.status === "open" || c.status === "waiting") {
28
+ return c.valid_to == null ? { label: "in_force", live: true } : { label: "superseded", live: false };
29
+ }
30
+ return { label: c.status, live: false };
31
+ }
32
+ case "receipts": {
33
+ const r = record;
34
+ return { label: r.state, live: r.state === "succeeded" || r.state === "verified" };
35
+ }
36
+ case "entities": {
37
+ const e = record;
38
+ return { label: e.lifecycle, live: e.lifecycle === "active" };
39
+ }
40
+ case "relationships":
41
+ return { label: "current", live: true };
42
+ }
43
+ }
44
+ /** The subject key a reader would type: the commitment/derived subject, the receipt's target
45
+ * object (`event:10042`), the entity id, or the relationship's `from` endpoint. */
46
+ export function stateSubject(kind, record) {
47
+ switch (kind) {
48
+ case "derived": return record.subject;
49
+ case "commitments": return record.subject;
50
+ case "receipts": {
51
+ const r = record;
52
+ return `${r.target.object_type}:${r.target.object_key}`;
53
+ }
54
+ case "entities": return record.id;
55
+ case "relationships": return record.from;
56
+ }
57
+ }
58
+ /** The instant that orders "latest first": computed_at, valid_from, verified_at ?? occurred_at,
59
+ * updated_at. Relationships carry no clock and sort last among equals. */
60
+ export function stateObservedAt(kind, record) {
61
+ switch (kind) {
62
+ case "derived": return record.computed_at;
63
+ case "commitments": return record.valid_from;
64
+ case "receipts": {
65
+ const r = record;
66
+ return r.verified_at ?? r.occurred_at;
67
+ }
68
+ case "entities": return record.updated_at;
69
+ case "relationships": return "";
70
+ }
71
+ }
72
+ /** The FTS document for a state record: title = the subject key (so an id query hits the
73
+ * title column, which the snippet and LIKE fallback both prefer), body = the human words plus
74
+ * the actor/owner, the status label and the dates. Every reader's query — a subject id, a
75
+ * phrase from a summary, an action kind, a principal — lands on one of these. */
76
+ export function stateSearchDoc(kind, record) {
77
+ const { label } = stateLiveness(kind, record);
78
+ const subject = stateSubject(kind, record);
79
+ switch (kind) {
80
+ case "derived": {
81
+ const d = record;
82
+ return { title: subject, body: `${d.content} ${label} ${d.transform_version} ${d.computed_at.slice(0, 10)}` };
83
+ }
84
+ case "commitments": {
85
+ const c = record;
86
+ return { title: subject, body: `${c.title} ${c.evidence_excerpt ?? ""} ${label} ${c.status} owner ${c.owner} due ${c.due}` };
87
+ }
88
+ case "receipts": {
89
+ const r = record;
90
+ return {
91
+ title: subject,
92
+ body: `${r.action_kind} ${r.actor} ${label} ${r.target.system} ${r.target.object_type} ${r.target.object_key} ${r.occurred_at.slice(0, 10)} ${r.invalidates.join(" ")}`,
93
+ };
94
+ }
95
+ case "entities": {
96
+ const e = record;
97
+ const attrs = Object.entries(e.attributes).map(([k, v]) => `${k} ${v ?? ""}`).join(" ");
98
+ return { title: subject, body: `${e.name} ${e.kind} ${label} ${attrs}` };
99
+ }
100
+ case "relationships": {
101
+ const r = record;
102
+ return { title: subject, body: `${r.type} ${r.to} ${r.reason}` };
103
+ }
104
+ }
105
+ }
106
+ const DERIVED_HEADLINE_CHARS = 120;
107
+ function oneLine(value, max) {
108
+ const flat = value.replace(/\s+/g, " ").trim();
109
+ return flat.length <= max ? flat : `${flat.slice(0, Math.max(0, max - 1)).trimEnd()}…`;
110
+ }
111
+ /** The one-line render shared by hunch_query, `hunch query` and the context "State" section:
112
+ * [commitment/in_force] customer:Site:7 — "send report" due 2026-09-11 (owner sofia)
113
+ * [derived/current] customer:Site:7 — <first 120 chars of the summary>
114
+ * [receipt/verified] event:10042 — events_add_actions by sofia@david 2026-09-08 */
115
+ export function renderStateLine(kind, record) {
116
+ const { label } = stateLiveness(kind, record);
117
+ const head = `[${FACET_LABEL[kind]}/${label}] ${stateSubject(kind, record)} — `;
118
+ switch (kind) {
119
+ case "derived":
120
+ return `${head}${oneLine(record.content, DERIVED_HEADLINE_CHARS)}`;
121
+ case "commitments": {
122
+ const c = record;
123
+ return `${head}"${oneLine(c.title, 100)}" due ${c.due} (owner ${c.owner})`;
124
+ }
125
+ case "receipts": {
126
+ const r = record;
127
+ return `${head}${r.action_kind} by ${r.actor} ${(r.verified_at ?? r.occurred_at).slice(0, 10)}`;
128
+ }
129
+ case "entities": {
130
+ const e = record;
131
+ return `${head}${oneLine(e.name, 100)} (${e.kind})`;
132
+ }
133
+ case "relationships": {
134
+ const r = record;
135
+ return `${head}${r.type} → ${r.to}${r.reason ? ` (${oneLine(r.reason, 80)})` : ""}`;
136
+ }
137
+ }
138
+ }
139
+ /** Bounded caps for the context "State" section: current derived, in-force commitments,
140
+ * latest receipts. Deliberately small — a brief, not a dump; `nuryel_read` is the full view. */
141
+ export const STATE_SLICE_CAPS = { derived: 3, commitments: 5, receipts: 3 };
142
+ /** Deterministic order for a state slice: score (best first), then observed_at DESC (latest
143
+ * first), then id ASC. Applied after liveness filtering, before the cap. */
144
+ export function compareStateHits(a, b) {
145
+ if (a.score !== b.score)
146
+ return a.score - b.score;
147
+ const at = stateObservedAt(b.kind, b.record).localeCompare(stateObservedAt(a.kind, a.record));
148
+ if (at !== 0)
149
+ return at;
150
+ return a.record.id.localeCompare(b.record.id);
151
+ }
152
+ /** Supplement priority band for the State section: above Project DNA (425), below
153
+ * decision-grounding (1000) and the ranked memory records (which are not supplements). */
154
+ const STATE_SUPPLEMENT_PRIORITY = 500;
155
+ /** Render a state slice as delivery supplements (one header + one line per record) so the
156
+ * section shares the context brief's hard budget and receipt like every other grounding.
157
+ * Empty slice → no supplements at all: a store with zero state records is byte-identical. */
158
+ export function stateSupplements(slice, target) {
159
+ const hits = [...slice.derived, ...slice.commitments, ...slice.receipts];
160
+ if (!hits.length)
161
+ return [];
162
+ const out = [{
163
+ id: "state-of-record",
164
+ kind: "state",
165
+ text: `STATE (nuryel.state/1) for "${target}": ${slice.derived.length} current derived, ${slice.commitments.length} in-force commitment(s), ${slice.receipts.length} latest receipt(s). Follow the state of record; nuryel_read(subject) returns the full records.`,
166
+ priority: STATE_SUPPLEMENT_PRIORITY,
167
+ }];
168
+ hits.forEach((hit, index) => {
169
+ out.push({
170
+ id: hit.record.id,
171
+ kind: `state-${FACET_LABEL[hit.kind]}`,
172
+ text: renderStateLine(hit.kind, hit.record),
173
+ // Strictly descending so the sort in buildDeliveryEnvelope keeps slice order.
174
+ priority: STATE_SUPPLEMENT_PRIORITY - 1 - index,
175
+ });
176
+ });
177
+ return out;
178
+ }
179
+ //# sourceMappingURL=stateDelivery.js.map
@@ -24,7 +24,8 @@ import { revParse, asOfDate, revExists, lastChangeDate, rangeFiles, rangeDiff, c
24
24
  import { flushCapture, flushMemoryHome, pinSharedRemote } from "../integrations/sync.js";
25
25
  import { withWriteLock } from "../serve/writelock.js";
26
26
  import { advertisedTeamRemoteContract, ensureTeamOverlay, overlayMatchesTeamRemote, readTeamConfig, teamRemoteContract, teamSharedRef } from "../integrations/team.js";
27
- import { formatStructure } from "../core/format.js";
27
+ import { formatSearchHit, formatStructure } from "../core/format.js";
28
+ import { isStateKind, stateSupplements } from "../core/stateDelivery.js";
28
29
  import { diagnoseIssueCorrectionStage, formatCorrectionStageDiagnostic } from "../core/correctionStage.js";
29
30
  import { compileVerifiedEvidenceMap, EvidenceExecutionSchema, EvidenceInterventionSchema, EvidenceProbeSchema, formatVerifiedEvidenceMap, VerifiedEvidenceReceiptSchema, } from "../core/evidenceMap.js";
30
31
  import { collectCorrectionStageSources } from "../extractors/correctionSources.js";
@@ -754,7 +755,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
754
755
  return ok(`No matches for "${query}".`);
755
756
  const lines = hits.map((h) => {
756
757
  const r = store.resolve(h.ref);
757
- return `• [${h.kind}] ${h.ref}${h.title}\n ${h.snippet}${provLine(r?.record)}`;
758
+ return `${formatSearchHit(h, r?.record)}${provLine(r?.record)}`;
758
759
  });
759
760
  return ok(`Top matches for "${query}":\n\n${lines.join("\n")}`);
760
761
  });
@@ -1034,6 +1035,10 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
1034
1035
  // Git checkout cannot provide DNA; the dedicated DNA tool reports the
1035
1036
  // exact derivation error when a caller needs diagnostics.
1036
1037
  }
1038
+ // The "State" section (nuryel.state/1): current derived, in-force commitments and the
1039
+ // latest receipts whose subject/text matches the target — bounded, ordered, sharing the
1040
+ // brief's budget as supplements. Withheld on time-travel: state records carry no as-of view.
1041
+ const stateGrounding = asOf ? [] : stateSupplements(store.stateSlice(target), target);
1037
1042
  const options = {
1038
1043
  root,
1039
1044
  symbols: store.recs("symbols"),
@@ -1041,7 +1046,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
1041
1046
  decisionCorpus: store.recs("decisions"),
1042
1047
  historical: !!asOf,
1043
1048
  profile: profile ?? "builder",
1044
- supplements: dnaSupplement ? [dnaSupplement] : [],
1049
+ supplements: [...(dnaSupplement ? [dnaSupplement] : []), ...stateGrounding],
1045
1050
  };
1046
1051
  // Task-phrase input ("improve retrieval ranking") resolves no file/symbol and
1047
1052
  // used to return an empty brief while the graph held the answer — fall back to
@@ -1068,8 +1073,10 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
1068
1073
  ...options,
1069
1074
  supplements: [
1070
1075
  ...(dnaSupplement ? [dnaSupplement] : []),
1076
+ ...stateGrounding,
1071
1077
  ...hits
1072
- .filter((hit) => !["constraints", "decisions", "bugs", "findings"].includes(hit.kind))
1078
+ // State hits are delivered through the State section above, not as raw search lines.
1079
+ .filter((hit) => !["constraints", "decisions", "bugs", "findings"].includes(hit.kind) && !isStateKind(hit.kind))
1073
1080
  .map((hit, index) => ({
1074
1081
  id: hit.ref,
1075
1082
  kind: `search-${hit.kind}`,
package/dist/serve/app.js CHANGED
@@ -8,7 +8,8 @@
8
8
  *
9
9
  * Every rule lives in src/store/stateBinding.ts; this file only maps HTTP to it:
10
10
  * GET /nuryel/v1/capabilities → capabilities of the partition named by ?scope=kind:id (default: first granted)
11
- * POST /nuryel/v1/read → readState
11
+ * POST /nuryel/v1/read → readState; with `scopes` a UNION read: readState per granted
12
+ * served partition, merged by mergeReadResponses (primary's envelope)
12
13
  * POST /nuryel/v1/write → writeState (under the partition's write lock)
13
14
  * POST /nuryel/v1/subscribe → subscribeState
14
15
  * POST /nuryel/v1/records → recordsState (by id, grants first)
@@ -18,8 +19,8 @@ import { createServer } from "node:http";
18
19
  import { HunchStore } from "../store/hunchStore.js";
19
20
  import { hunchPaths } from "../core/paths.js";
20
21
  import { flushCapture } from "../integrations/sync.js";
21
- import { StateRefusal, capabilities, readState, recordsState, subscribeState, writeState } from "../store/stateBinding.js";
22
- import { STATE_READ_VERSION, STATE_RECORDS_VERSION, STATE_SUBSCRIBE_VERSION, STATE_WRITE_VERSION, ScopeSchema, scopePath } from "../core/stateContract.js";
22
+ import { StateRefusal, capabilities, mergeReadResponses, readState, recordsState, subscribeState, writeState } from "../store/stateBinding.js";
23
+ import { STATE_READ_VERSION, STATE_RECORDS_VERSION, STATE_SUBSCRIBE_VERSION, STATE_WRITE_VERSION, ReadScopesSchema, ScopeSchema, scopePath } from "../core/stateContract.js";
23
24
  import { partitionFor, resolvePrincipal } from "./config.js";
24
25
  import { WriteLockTimeout, withWriteLock } from "./writelock.js";
25
26
  import { HUNCH_VERSION } from "../core/version.js";
@@ -148,8 +149,26 @@ export function createServeApp(config, opts = {}) {
148
149
  if (url.pathname === "/nuryel/v1/read") {
149
150
  const scope = requireScope(principal, body);
150
151
  const { store } = storeFor(scope);
151
- const { response, envelope } = readState(store, { schema: STATE_READ_VERSION, principal, ...body });
152
- return send(res, 200, { ...response, envelope });
152
+ if (body.scopes === undefined) {
153
+ const { response, envelope } = readState(store, { schema: STATE_READ_VERSION, principal, ...body });
154
+ return send(res, 200, { ...response, envelope });
155
+ }
156
+ // Union read. The primary `scope` was gated above as always; every extra scope is
157
+ // either granted (read from ITS partition — 404 no-partition if this server lacks it)
158
+ // or named in denied_scopes. One ungranted extra never refuses the whole call.
159
+ const requested = ReadScopesSchema.safeParse(body.scopes);
160
+ if (!requested.success)
161
+ throw problem(400, "invalid-scope", "scopes must be 1..64 entries of { kind, id }");
162
+ const { scopes: _scopes, ...rest } = body;
163
+ const isGranted = (s) => principal.grants.some((g) => scopePath(g) === scopePath(s));
164
+ const ungranted = requested.data.filter((s) => !isGranted(s));
165
+ const others = new Map();
166
+ for (const s of requested.data)
167
+ if (isGranted(s) && scopePath(s) !== scopePath(scope) && !others.has(scopePath(s)))
168
+ others.set(scopePath(s), s);
169
+ const primary = readState(store, { schema: STATE_READ_VERSION, principal, ...rest, scope });
170
+ const merged = mergeReadResponses(primary.response, [...others.values()].map((other) => readState(storeFor(other).store, { schema: STATE_READ_VERSION, principal, ...rest, scope: other }).response), ungranted);
171
+ return send(res, 200, { ...merged, envelope: primary.envelope });
153
172
  }
154
173
  if (url.pathname === "/nuryel/v1/write") {
155
174
  const scope = requireScope(principal, body);
@@ -26,6 +26,7 @@ import { isStrictBlocker, isVetoBlocker } from "../core/strictgate.js";
26
26
  import { effectiveForbids, matchForbids } from "../core/constraintmatch.js";
27
27
  import { analyzeDiff } from "../extractors/diff.js";
28
28
  import { selectReviewedLandscape, } from "../core/landscapeDelivery.js";
29
+ import { STATE_KINDS, STATE_SLICE_CAPS, compareStateHits, isStateKind, stateLiveness, stateObservedAt, stateSearchDoc, stateSubject, } from "../core/stateDelivery.js";
29
30
  /** Git cannot resolve repository identity from a cwd that does not exist yet.
30
31
  * Probe the nearest real directory so a planned nested overlay cannot evade the
31
32
  * public-repository boundary merely by deferring mkdir until its first write. */
@@ -485,6 +486,20 @@ export class HunchStore {
485
486
  fts(f.id, "findings", f.title, `${f.observation} ${f.evidence.join(" ")} ${f.affected_files.join(" ")} ${f.affected_symbols.join(" ")} ${f.triage}`);
486
487
  }
487
488
  counts.findings = fnds.length;
489
+ // nuryel.state/1 kinds (receipts, commitments, derived, entities, relationships):
490
+ // advisory records on the same FTS-only ride as runbooks/findings — no dedicated
491
+ // SQL table. kind = the store kind; title = the subject key; body = the human words
492
+ // + actor/owner + status label (stateSearchDoc), so a subject id and a phrase both
493
+ // hit. History (superseded/done/failed/retired) is indexed too and demoted at
494
+ // query time (demoteHistoricalState / priorMeta), never dropped.
495
+ for (const kind of STATE_KINDS) {
496
+ const records = this.recs(kind);
497
+ for (const record of records) {
498
+ const doc = stateSearchDoc(kind, record);
499
+ fts(record.id, kind, doc.title, doc.body);
500
+ }
501
+ counts[kind] = records.length;
502
+ }
488
503
  void j;
489
504
  });
490
505
  // Reconcile embeddings AFTER the FTS rebuild (model-free): drop vectors whose
@@ -507,13 +522,104 @@ export class HunchStore {
507
522
  try {
508
523
  const rows = this.db.prepare(`SELECT ref, kind, title, snippet(search, 3, '[', ']', '…', 12) AS snip, bm25(search) AS score
509
524
  FROM search WHERE search MATCH ? ORDER BY score LIMIT ?`).all(match, limit);
510
- return rows.map((r) => ({ ref: r.ref, kind: r.kind, title: r.title, snippet: r.snip, score: r.score }));
525
+ return this.demoteHistoricalState(rows.map((r) => ({ ref: r.ref, kind: r.kind, title: r.title, snippet: r.snip, score: r.score })));
511
526
  }
512
527
  catch {
513
528
  // Malformed FTS expression — degrade to a LIKE scan over titles/bodies.
514
529
  return this.likeSearch(query, limit);
515
530
  }
516
531
  }
532
+ /** State-of-record ordering for nuryel.state/1 hits (superseded derived, done/cancelled
533
+ * commitments, failed receipts, retired entities): indexed and findable, but ranked BELOW
534
+ * the live record of the same subject. bm25 is negative (lower = better), so a history
535
+ * hit's score is scaled toward 0 by STATE_HISTORY_SCORE_FACTOR and the pool is re-sorted
536
+ * STABLY by score — a store with no state history returns the exact SQL order, and a
537
+ * LIKE-fallback pool (all scores 0) is partitioned live-first in its existing order.
538
+ * Bounded (a dimmer, not an exclusion) so the previous summary stays reachable one query
539
+ * away; hybridSearch/rankedSearch additionally apply the liveness prior via priorMeta. */
540
+ demoteHistoricalState(hits) {
541
+ if (!hits.some((h) => isStateKind(h.kind)))
542
+ return hits;
543
+ const scored = hits.map((h) => {
544
+ const meta = this.stateMeta(h.ref, h.kind);
545
+ if (!meta || meta.live)
546
+ return h;
547
+ return { ...h, score: h.score * STATE_HISTORY_SCORE_FACTOR };
548
+ });
549
+ const allZero = scored.every((h) => h.score === 0);
550
+ if (allZero) {
551
+ const live = scored.filter((h) => { const m = this.stateMeta(h.ref, h.kind); return !m || m.live; });
552
+ const history = scored.filter((h) => !live.includes(h));
553
+ return [...live, ...history];
554
+ }
555
+ return scored.sort((a, b) => a.score - b.score);
556
+ }
557
+ /** Liveness + clock for a state hit (null for every non-state kind). */
558
+ stateMeta(ref, kind) {
559
+ if (!isStateKind(kind))
560
+ return null;
561
+ const record = this.recs(kind).find((r) => r.id === ref);
562
+ if (!record)
563
+ return null;
564
+ const { live, label } = stateLiveness(kind, record);
565
+ return { live, label, at: stateObservedAt(kind, record), provenance: record.provenance.source };
566
+ }
567
+ /** The bounded "State" slice for a context brief (hunch_context / `hunch context`): the
568
+ * current derived summaries, in-force commitments and latest verified receipts whose
569
+ * subject or text matches `target`. Matching is AND over the target's tokens (every token
570
+ * must appear, prefix-tolerant) so a file path such as src/store/x.ts never drags in a
571
+ * summary that merely mentions "store"; an exact subject match always qualifies. Order is
572
+ * deterministic: score (best first), then observed_at DESC, then id. Caps per kind are
573
+ * STATE_SLICE_CAPS. A store with no state records returns three empty lists. */
574
+ stateSlice(target) {
575
+ const empty = { derived: [], commitments: [], receipts: [] };
576
+ const needle = toPosixTarget(target).trim();
577
+ if (!needle)
578
+ return empty;
579
+ const tokens = needle.toLowerCase().match(/[\p{L}\p{N}_]+/gu) ?? [];
580
+ const pick = (kind) => {
581
+ const records = this.recs(kind);
582
+ if (!records.length)
583
+ return [];
584
+ const scoreByRef = new Map();
585
+ for (const hit of this.stateFts(tokens, kind, Math.max(STATE_SLICE_CAPS[kind] * 8, 24)))
586
+ scoreByRef.set(hit.ref, hit.score);
587
+ const hits = [];
588
+ for (const record of records) {
589
+ if (!stateLiveness(kind, record).live)
590
+ continue;
591
+ const id = record.id;
592
+ const exact = stateSubject(kind, record) === needle || id === needle;
593
+ let score = scoreByRef.get(id);
594
+ if (score === undefined) {
595
+ if (!exact)
596
+ continue;
597
+ score = 0;
598
+ }
599
+ else if (!exact && !allTokensPresent(tokens, stateSearchDoc(kind, record))) {
600
+ continue; // the LIKE fallback is OR-shaped; keep the AND contract on every runtime
601
+ }
602
+ hits.push({ kind, record, score: exact ? Math.min(score, STATE_EXACT_SUBJECT_SCORE) : score });
603
+ }
604
+ return hits.sort(compareStateHits).slice(0, STATE_SLICE_CAPS[kind]);
605
+ };
606
+ return { derived: pick("derived"), commitments: pick("commitments"), receipts: pick("receipts") };
607
+ }
608
+ /** AND-shaped FTS over one state kind (every token required, prefix-tolerant); degrades to
609
+ * the kind-scoped LIKE scan (OR-shaped — the caller re-checks AND) without FTS5. */
610
+ stateFts(tokens, kind, limit) {
611
+ if (!tokens.length)
612
+ return [];
613
+ const match = tokens.map((t) => `"${t}"*`).join(" ");
614
+ try {
615
+ const rows = this.db.prepare(`SELECT ref, kind, title, '' AS snip, bm25(search) AS score
616
+ FROM search WHERE search MATCH ? AND kind = ? ORDER BY score, ref LIMIT ?`).all(match, kind, limit);
617
+ return rows.map((r) => ({ ref: r.ref, kind: r.kind, title: r.title, snippet: r.snip, score: r.score }));
618
+ }
619
+ catch {
620
+ return this.likeSearch(tokens.join(" "), limit, kind);
621
+ }
622
+ }
517
623
  /** Portable bounded fallback over titles/bodies. Each natural-language token
518
624
  * is an OR candidate, mirroring the high-recall FTS query closely enough for
519
625
  * runtimes whose SQLite build omits the optional FTS5 module.
@@ -547,7 +653,7 @@ export class HunchStore {
547
653
  WHERE ${where}
548
654
  ORDER BY CASE WHEN ${titleLikes} THEN 0 ELSE 1 END, length(title), ref
549
655
  LIMIT ?`).all(...(kind ? [kind, ...likes, ...titleParams, limit] : [...likes, ...titleParams, limit]));
550
- return rows.map((r) => ({ ref: r.ref, kind: r.kind, title: r.title, snippet: r.snip, score: 0 }));
656
+ return this.demoteHistoricalState(rows.map((r) => ({ ref: r.ref, kind: r.kind, title: r.title, snippet: r.snip, score: 0 })));
551
657
  }
552
658
  // ---- semantic search (opt-in embeddings) --------------------------------
553
659
  /** The exact (ref, kind, title, body) docs that feed FTS — and thus embeddings.
@@ -825,6 +931,12 @@ export class HunchStore {
825
931
  // A fixed bug is not "dead" — lineage is the point of keeping it findable.
826
932
  return { dead: false, provenance: b.provenance.source };
827
933
  }
934
+ // nuryel.state/1 kinds: history (superseded / done / failed / retired) dims exactly like a
935
+ // superseded decision; the record's own clock (computed_at, valid_from, verified_at …)
936
+ // drives recency so the latest summary of a subject outranks last month's.
937
+ const state = this.stateMeta(ref, kind);
938
+ if (state)
939
+ return { dead: !state.live, provenance: state.provenance, at: state.at || undefined };
828
940
  return null;
829
941
  }
830
942
  /** Hybrid search (hunch_query / `hunch query --semantic`): FTS bm25 fused with
@@ -908,7 +1020,7 @@ export class HunchStore {
908
1020
  try {
909
1021
  const rows = this.db.prepare(`SELECT ref, kind, title, snippet(search, 3, '[', ']', '…', 12) AS snip, bm25(search) AS score
910
1022
  FROM search WHERE search MATCH ? AND kind = ? ORDER BY score LIMIT ?`).all(match, kind, limit);
911
- return rows.map((r) => ({ ref: r.ref, kind: r.kind, title: r.title, snippet: r.snip, score: r.score }));
1023
+ return this.demoteHistoricalState(rows.map((r) => ({ ref: r.ref, kind: r.kind, title: r.title, snippet: r.snip, score: r.score })));
912
1024
  }
913
1025
  catch {
914
1026
  return this.likeSearch(query, limit, kind);
@@ -1878,7 +1990,22 @@ const DECISION_FRESHNESS_PATH_CACHE_CAP = 4_096;
1878
1990
  * intent. Measured on bench/golden-retrieval.json: Recall@10 70% -> 90%, MRR
1879
1991
  * 0.402 -> 0.575. Set HUNCH_MEMORY_PRIOR_SHIFT=0 to disable. */
1880
1992
  const MEMORY_PRIOR_SHIFT = numEnv("HUNCH_MEMORY_PRIOR_SHIFT", 12);
1881
- const MEMORY_KINDS = new Set(["decisions", "constraints", "bugs", "runbooks", "policies"]);
1993
+ const MEMORY_KINDS = new Set(["decisions", "constraints", "bugs", "runbooks", "policies", ...STATE_KINDS]);
1994
+ /** State-of-record ordering in the RAW search path: a nuryel.state/1 history hit (superseded
1995
+ * derived, done/cancelled commitment, failed receipt, retired entity) keeps this fraction of
1996
+ * its bm25 score (bm25 is negative, so scaling toward 0 demotes). 0.5 keeps the previous
1997
+ * summary of a subject one query away while the current one leads; hybridSearch adds the
1998
+ * bounded liveness prior on top. Set HUNCH_STATE_HISTORY_SCORE_FACTOR=1 to disable. */
1999
+ const STATE_HISTORY_SCORE_FACTOR = Math.max(0, Math.min(1, numEnv("HUNCH_STATE_HISTORY_SCORE_FACTOR", 0.5)));
2000
+ /** An exact subject match in stateSlice() leads regardless of bm25 (which is never below this). */
2001
+ const STATE_EXACT_SUBJECT_SCORE = -1_000_000;
2002
+ /** AND contract for stateSlice(): every query token appears (as a prefix) in the record's doc. */
2003
+ function allTokensPresent(tokens, doc) {
2004
+ if (!tokens.length)
2005
+ return false;
2006
+ const words = `${doc.title} ${doc.body}`.toLowerCase().match(/[\p{L}\p{N}_]+/gu) ?? [];
2007
+ return tokens.every((t) => words.some((w) => w.startsWith(t)));
2008
+ }
1882
2009
  function safeFreshnessScope(value) {
1883
2010
  const normalized = toPosixTarget(value.trim());
1884
2011
  if (!normalized || normalized.length > 1_024 || normalized.includes("\0")
@@ -113,6 +113,7 @@ export const FTS_SEARCH_SCHEMA_SQL = /* sql */ `
113
113
  CREATE VIRTUAL TABLE IF NOT EXISTS search USING fts5(
114
114
  ref UNINDEXED, -- entity id
115
115
  kind UNINDEXED, -- components | resources | edges | symbols | decisions | bugs | constraints | runbooks | findings
116
+ -- | receipts | commitments | derived | entities | relationships (nuryel.state/1; title = subject key)
116
117
  title,
117
118
  body,
118
119
  tokenize = 'porter unicode61'
@@ -125,6 +125,12 @@ export function readState(store, input) {
125
125
  let stateOfRecord = null;
126
126
  const records = {};
127
127
  const denied = new Map();
128
+ // Union read against ONE store: every requested scope the principal lacks is named up front;
129
+ // the partitions actually read are declared so a caller never mistakes this for the union
130
+ // (a multi-partition host merges per-store answers with mergeReadResponses).
131
+ for (const s of request.scopes ?? [])
132
+ if (!granted(request.principal, s))
133
+ denied.set(scopePath(s), s);
128
134
  if (request.subject !== undefined) {
129
135
  const subject = request.subject;
130
136
  const current = [];
@@ -228,10 +234,86 @@ export function readState(store, input) {
228
234
  state_of_record: stateOfRecord,
229
235
  denied_scopes: [...denied.values()],
230
236
  ...(stateOfRecord ? { records } : {}),
237
+ ...(request.scopes ? { scopes: [request.scope], receipts: [{ scope: request.scope, receipt_id: envelope.receipt_id }] } : {}),
231
238
  });
232
239
  assertReadWithinGrants(request.principal, response);
233
240
  return { response, envelope };
234
241
  }
242
+ /** Union read — one state_of_record across several partitions, each read by `readState` against
243
+ * its own store. Pure: no store, no grants decided here (every input already passed its own
244
+ * grant check). The primary's receipt, scope and envelope lead; refs concatenate (each already
245
+ * carries its partition), `depends_on` concatenates, `invalidated_by` is a sorted union, `records`
246
+ * merge by id (first writer wins — ids are identity, two copies are the same record),
247
+ * `denied_scopes` is the union of every partition's denied plus `extraDenied` (requested-but-
248
+ * ungranted scopes the host refused to open), `scopes` names the partitions read and `receipts`
249
+ * carries one delivery receipt per partition. Reusable by any host (HTTP today; MCP or CLI
250
+ * fronting several roots later). */
251
+ export function mergeReadResponses(primary, others, extraDenied = []) {
252
+ const all = [primary, ...others];
253
+ const scopes = new Map();
254
+ const receipts = new Map();
255
+ for (const r of all) {
256
+ for (const s of r.scopes ?? [r.scope])
257
+ if (!scopes.has(scopePath(s)))
258
+ scopes.set(scopePath(s), s);
259
+ for (const x of r.receipts ?? [{ scope: r.scope, receipt_id: r.receipt_id }])
260
+ if (!receipts.has(scopePath(x.scope)))
261
+ receipts.set(scopePath(x.scope), x);
262
+ }
263
+ const denied = new Map();
264
+ for (const s of [...all.flatMap((r) => r.denied_scopes), ...extraDenied])
265
+ if (!scopes.has(scopePath(s)) && !denied.has(scopePath(s)))
266
+ denied.set(scopePath(s), s);
267
+ const sors = all.map((r) => r.state_of_record).filter((s) => s !== null);
268
+ let stateOfRecord = null;
269
+ const records = {};
270
+ if (sors.length) {
271
+ const refKey = (ref) => `${ref.facet}|${scopePath(ref.scope)}|${ref.id}`;
272
+ const dedupeRefs = (pick) => {
273
+ const seen = new Set();
274
+ const out = [];
275
+ for (const ref of sors.flatMap(pick)) {
276
+ const k = refKey(ref);
277
+ if (!seen.has(k)) {
278
+ seen.add(k);
279
+ out.push(ref);
280
+ }
281
+ }
282
+ return out;
283
+ };
284
+ const seenDeps = new Set();
285
+ const dependsOn = [];
286
+ for (const dep of sors.flatMap((s) => s.depends_on)) {
287
+ const k = stateHash(dep);
288
+ if (!seenDeps.has(k)) {
289
+ seenDeps.add(k);
290
+ dependsOn.push(dep);
291
+ }
292
+ }
293
+ stateOfRecord = {
294
+ subject: sors[0].subject,
295
+ current: dedupeRefs((s) => s.current),
296
+ in_force: dedupeRefs((s) => s.in_force),
297
+ done: dedupeRefs((s) => s.done),
298
+ depends_on: dependsOn,
299
+ invalidated_by: [...new Set(sors.flatMap((s) => s.invalidated_by))].sort(),
300
+ };
301
+ for (const r of all)
302
+ for (const [id, record] of Object.entries(r.records ?? {}))
303
+ if (!(id in records))
304
+ records[id] = record;
305
+ }
306
+ return ReadResponseSchema.parse({
307
+ schema: STATE_READ_VERSION,
308
+ receipt_id: primary.receipt_id,
309
+ scope: primary.scope,
310
+ state_of_record: stateOfRecord,
311
+ denied_scopes: [...denied.values()],
312
+ ...(stateOfRecord ? { records } : {}),
313
+ scopes: [...scopes.values()],
314
+ receipts: [...receipts.values()],
315
+ });
316
+ }
235
317
  /** What a record is ABOUT, for subscribers filtering by subject. Mirrors the read verb's matching. */
236
318
  function subjectOf(facet, record) {
237
319
  const r = record;
@@ -382,6 +464,24 @@ export function writeState(store, input, opts = {}) {
382
464
  }
383
465
  if (supersedes === id)
384
466
  supersedes = null;
467
+ // A supersede target must still be open. Two writers racing to replace the same incumbent
468
+ // would otherwise both succeed and leave two current records for one subject (fnd_eeb8bf3cb8);
469
+ // the loser is told which record is current now, so it can re-read and supersede that one.
470
+ // The writer that closed the incumbent itself (same id, new key) is not a loser.
471
+ if (supersedes && facet !== "decisions") {
472
+ const incumbent = store.getRec(facet, supersedes);
473
+ if (incumbent && "valid_to" in incumbent && incumbent.valid_to !== null) {
474
+ const subject = subjectOf(facet, incumbent);
475
+ const open = store.recsInHome(facet, home)
476
+ .filter((r) => subjectOf(facet, r) === subject && r.valid_to === null)
477
+ .map((r) => r.id).sort();
478
+ if (!open.includes(id)) {
479
+ const current = open.length ? `the current ${facet} record for ${subject ?? "that subject"} is ${open.join(", ")}` : `no ${facet} record for ${subject ?? "that subject"} is open now`;
480
+ throw new StateRefusal("conflict", `supersedes ${supersedes} was already superseded (window closed ${String(incumbent.valid_to)}); ${current}: re-read and supersede that one`, { incumbent_id: open[0] ?? supersedes, reason: "supersede target already closed" });
481
+ }
482
+ supersedes = null; // already closed by this record: nothing to close again, no second "superseded" event
483
+ }
484
+ }
385
485
  store.putCapture(facet, record, isPrivate);
386
486
  const changes = [];
387
487
  const cause = { kind: "write", principal: request.principal.id };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davesheffer/hunch",
3
- "version": "1.27.0",
3
+ "version": "1.28.0",
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.27.0",
10
+ "version": "1.28.0",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "npm",
14
14
  "registryBaseUrl": "https://registry.npmjs.org",
15
15
  "identifier": "@davesheffer/hunch",
16
- "version": "1.27.0",
16
+ "version": "1.28.0",
17
17
  "runtimeHint": "npx",
18
18
  "packageArguments": [
19
19
  {