@davesheffer/hunch 1.27.0 → 1.29.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/README.md CHANGED
@@ -1,25 +1,27 @@
1
1
  # Hunch
2
2
 
3
- ## Your repo remembers why and teaches every coding agent how the project works.
3
+ ## Agents are probabilistic. Organizations need deterministic state. Hunch is the state layer between them.
4
4
 
5
5
  [![npm version](https://img.shields.io/npm/v/@davesheffer/hunch?color=2742ff&label=npm)](https://www.npmjs.com/package/@davesheffer/hunch)
6
6
  [![GitHub stars](https://img.shields.io/github/stars/davesheffer/hunch?color=2742ff&label=%E2%98%85%20star)](https://github.com/davesheffer/hunch)
7
7
  [![license](https://img.shields.io/npm/l/@davesheffer/hunch?color=2742ff)](LICENSE)
8
8
 
9
- Every new AI coding session can read your code. It cannot automatically see why your team chose this design, which alternative already failed, what an odd-looking line protects, or how this repository expects work to be explained and reviewed.
9
+ Every employee is getting an agent. Each one reads the code, the CRM, the mail thread, the chat, and forms its own opinion about what was decided, what was done and what is still owed. Two agents, two slightly different realities. Neither is wrong. Both are guesses, made fresh, from raw material.
10
10
 
11
- That is how settled decisions get reopened, fixed bugs return, and technically plausible changes arrive feeling foreign to the project.
11
+ An organization cannot run on guesses. It runs on state: this was decided, this rule is in force, this action happened and was verified, this promise is due Thursday, this summary is current and rests on these sources. Hunch holds that state in git, refuses it when it contradicts, and delivers it to Claude, Codex, Cursor, Copilot, Windsurf, Antigravity and any other MCP client before the agent answers or edits.
12
12
 
13
- **Hunch is evidence-backed deterministic project intelligence for the AI coding tools you already use.** It gives Claude, Codex, Cursor, Copilot, Windsurf, Antigravity and other MCP clients the same durable understanding of your codebase:
13
+ **Hunch started as engineering memory for coding agents** and still is: why the code is shaped this way, which alternative already failed, what an odd-looking line protects, what depends on the code about to change, and which trusted decisions, fixes and architectural boundaries a change must preserve. The same graph now holds organizational state for operations agents too (see [Deterministic organizational state](#deterministic-organizational-state)).
14
14
 
15
- - why the code is shaped this way;
16
- - how the repository communicates, reviews and builds;
17
- - what depends on the code about to change; and
18
- - which trusted decisions, fixes and architectural boundaries the result must preserve.
15
+ ## Why Hunch, not another memory layer
19
16
 
20
- For precise rules your team has explicitly trusted, the promise is **Never Twice**: an agent may propose a different direction, but it cannot quietly re-make a decided decision or re-introduce a fixed failure without Hunch surfacing the conflict and its evidence.
17
+ Memory optimizes recall. State optimizes refusal. Hunch competes for the deterministic state layer from the organizational side, and every point below is a property you can verify in this repository rather than a claim:
21
18
 
22
- Memory starts advisory. Nothing blocks until a human deliberately trusts a precise rule and opts into strict enforcement.
19
+ - **Git is the source of truth.** Every fact is a JSON file under `.hunch/`, every change a commit: diffable, PR-reviewable, revertable, mergeable, never locked in a vendor database. SQLite is only a derived index.
20
+ - **Refusal, not convergence.** One live decision per topic. A second contradicting record is refused at write time with the incumbent named, and a supersede target must still be open. Diverging writes are not merged later.
21
+ - **Organization drawers with a key per agent.** Repository, user, team and organization partitions; the bearer key resolves the principal and decides visibility before anything is looked up.
22
+ - **Receipts and commitments are facts.** What was actually done in an external system, verified or not, and who owes what by when, readable by any agent with the key.
23
+ - **Never Twice.** A human correction becomes an enforced rule, not a one-session memory. Nothing blocks until a human deliberately trusts a precise rule and opts into strict enforcement.
24
+ - **The code spoke.** Decisions, constraints and bug lineage are checked against the code deterministically, with no model in the block path. No state-layer peer has it.
23
25
 
24
26
  ## Start in five minutes
25
27
 
@@ -144,19 +146,19 @@ hunch check --staged --strict
144
146
 
145
147
  Captured memory cannot silently hard-block on its own.
146
148
 
147
- ## Deterministic organizational state — next product direction
149
+ ## Deterministic organizational state
148
150
 
149
- Repository memory solves one version of a larger problem.
151
+ Repository memory solves one version of a larger problem, and since 1.25.0 Hunch ships the larger one.
150
152
 
151
153
  As organizations give every employee an agent that can work across CRM, email, messaging, repositories and other tools, the agents become probabilistic writers/readers of the same organization. If each one independently reconstructs what was decided, what was already done or what is still owed, the organization gets multiple conflicting realities.
152
154
 
153
- The active roadmap asks whether Hunch can become the deterministic state layer between those agents and the organization:
155
+ Hunch is the deterministic state layer between those agents and the organization:
154
156
 
155
157
  > **Agents are probabilistic. Organizations need deterministic state. Hunch is the state layer between them.**
156
158
 
157
159
  The target is **one product, one authorized state graph and one versioned state contract** across repository, user, team and organization scopes.
158
160
 
159
- Planned state includes:
161
+ The state it holds:
160
162
 
161
163
  - decisions currently in force;
162
164
  - verified action receipts / what was done;
@@ -203,7 +205,9 @@ user:david --root <dir> --principal sofia@david` declares a partition and mints
203
205
  client is `import { createStateClient } from "@davesheffer/hunch/state"`. This folds the separate
204
206
  Hunch Memory service into Hunch.
205
207
 
206
- Read [Deterministic organizational state](docs/deterministic-state.md) and the [roadmap](ROADMAP.md).
208
+ As of 1.27.0 a fourth verb, `records`, lists a subject's records for the first writers, and the per-scope ledger compacts and merges across clones. As of 1.28.0 reads are a union across writers, a supersede target must still be open (two racing writers can no longer leave two current records), state records are searchable and delivered by subject, and subjects are keyed by the external record rather than by the agent. Proven on an emulated organization: three agents over ten clinics and a generated year of mail, chat and CRM, one organization drawer, 96 cited summaries, 24 verified receipts, 24 commitments, zero contradictions.
209
+
210
+ Read [Deterministic organizational state](docs/deterministic-state.md), the [roadmap](ROADMAP.md) and the dated [competitive landscape](docs/competitive-landscape.md).
207
211
 
208
212
  ### Naming
209
213
 
@@ -216,7 +220,7 @@ The current release can keep a team's **repository-scoped** memory in a dedicate
216
220
  Today Hunch does not host that shared Git repository; teammates/CI use normal Git access and one maintainer connects it:
217
221
 
218
222
  ```bash
219
- npm i -g @davesheffer/hunch@1.23.3
223
+ npm i -g @davesheffer/hunch@1.28.0
220
224
  hunch shared --repo git@github.com:acme/project-hunch-memory.git
221
225
  git add .gitignore .hunch/team.json
222
226
  git commit -m "chore: connect shared Hunch memory"
@@ -226,7 +230,7 @@ git push
226
230
  Teammates then install the same version and run:
227
231
 
228
232
  ```bash
229
- npm i -g @davesheffer/hunch@1.23.3
233
+ npm i -g @davesheffer/hunch@1.28.0
230
234
  git pull
231
235
  hunch init
232
236
  hunch doctor
package/dist/cli/index.js CHANGED
@@ -45,17 +45,19 @@ import { deriveForbids, effectiveForbids } from "../core/constraintmatch.js";
45
45
  import { extractInlineIntent } from "../extractors/comments.js";
46
46
  import { renderText, renderMarkdown, renderSarif, renderImpact, reportFailsStrict } from "../core/checkreport.js";
47
47
  import { partitionReview, isReviewDraft, READY_MIN_GROUNDED } from "../core/reviewqueue.js";
48
- import { installPostCommitHook, installPreCommitHook } from "../integrations/hooks.js";
48
+ import { installPostCommitHook, installPreCommitHook, installPostMergeHook } from "../integrations/hooks.js";
49
49
  import { ensureSharedOverlayPointer } from "../integrations/worktree.js";
50
50
  import { flushCapture, flushMemoryHome, flushMemoryHomes, pinSharedRemote, sharedRemoteFor } from "../integrations/sync.js";
51
51
  import { installMergeDriver } from "../integrations/mergeDriver.js";
52
52
  import { ensureGitignore, ignoreHunchMemory, HUNCH_MEMORY_DIRS } from "../integrations/gitignore.js";
53
53
  import { writeCiWorkflow } from "../integrations/ciAction.js";
54
- import { updateClaudeMd } from "../integrations/claudemd.js";
54
+ import { updateClaudeMd, renderHunchSection } from "../integrations/claudemd.js";
55
+ import { classifyGroundingBlock, describeGroundingFreshness } from "../core/groundingLag.js";
55
56
  import { writeMcpJson, writeSlashCommands, installClaudeHooks } from "../integrations/scaffold.js";
56
- import { scaffoldProviders, regenerateGrounding, refreshExistingGrounding, refreshCommittableGrounding } from "../integrations/providers.js";
57
+ import { scaffoldProviders, regenerateGrounding, refreshExistingGrounding, refreshCommittableGrounding, GROUNDING_DOC_PATHS } from "../integrations/providers.js";
57
58
  import { healClaudeConfigCaseSplit } from "../integrations/claudeConfig.js";
58
- import { formatContext, formatStructure } from "../core/format.js";
59
+ import { formatContext, formatSearchHit, formatStructure } from "../core/format.js";
60
+ import { isStateKind, renderStateLine, stateSupplements } from "../core/stateDelivery.js";
59
61
  import { diagnoseIssueCorrectionStage, formatCorrectionStageDiagnostic } from "../core/correctionStage.js";
60
62
  import { compileVerifiedEvidenceMap, formatVerifiedEvidenceMap } from "../core/evidenceMap.js";
61
63
  import { collectCorrectionStageSources } from "../extractors/correctionSources.js";
@@ -281,6 +283,8 @@ program
281
283
  console.log(` ✓ post-commit hook ${h.action} (learning loop)${syncToOverlay ? " — syncs to the shared overlay" : ""}${opts.autoCommit ? " — auto-commit on" : ""}`);
282
284
  const m = installMergeDriver(root, inv.shell);
283
285
  console.log(` ✓ team merge driver ${m.action}`);
286
+ const pm = installPostMergeHook(root, inv.shell);
287
+ console.log(` ✓ post-merge hook ${pm.action} (re-syncs grounding docs after a merge that brought memory in)`);
284
288
  // Auto-install the pre-commit guard by default (advisory: flags invariants
285
289
  // touched directly OR via blast radius, never blocks). Opt out with
286
290
  // --no-enforce; --enforce-strict makes blocking near/direct hits fail the commit.
@@ -1320,7 +1324,7 @@ program
1320
1324
  else {
1321
1325
  console.log(`Top matches for "${q}"${how}:\n`);
1322
1326
  for (const h of hits)
1323
- console.log(`• [${h.kind}] ${h.ref} ${h.title}\n ${h.snippet}`);
1327
+ console.log(formatSearchHit(h, isStateKind(h.kind) ? store.resolve(h.ref)?.record : undefined));
1324
1328
  }
1325
1329
  store.close();
1326
1330
  });
@@ -4059,12 +4063,24 @@ program
4059
4063
  !ctx.findings.length &&
4060
4064
  !ctx.landscape?.resources.length &&
4061
4065
  !ctx.landscape?.relationships.length;
4066
+ // The "State" section (nuryel.state/1): current derived, in-force commitments, latest
4067
+ // receipts matching the target — the same slice and render as hunch_context.
4068
+ const slice = asOf ? null : store.stateSlice(target);
4069
+ const stateGrounding = slice ? stateSupplements(slice, target) : [];
4062
4070
  if (empty && !asOf) {
4063
- const hits = store.rankedSearch(target, 8);
4064
- if (hits.length) {
4071
+ const hits = store.rankedSearch(target, 8).filter((h) => !isStateKind(h.kind));
4072
+ if (hits.length || stateGrounding.length) {
4065
4073
  console.log(`No file/symbol resolves for "${target}" — closest graph matches instead:\n`);
4066
4074
  for (const h of hits)
4067
4075
  console.log(`• ${h.ref} — ${h.title}\n ${h.snippet}`);
4076
+ if (slice) {
4077
+ const stateHits = [...slice.derived, ...slice.commitments, ...slice.receipts];
4078
+ if (stateHits.length) {
4079
+ console.log(`${hits.length ? "\n" : ""}State (nuryel.state/1):`);
4080
+ for (const hit of stateHits)
4081
+ console.log(`• ${renderStateLine(hit.kind, hit.record)}`);
4082
+ }
4083
+ }
4068
4084
  console.log(`\n(For a file/symbol brief use a concrete target; for free-text this is what \`hunch query\` returns.)`);
4069
4085
  store.close();
4070
4086
  return;
@@ -4077,6 +4093,7 @@ program
4077
4093
  decisionCorpus: store.recs("decisions"),
4078
4094
  historical: !!asOf,
4079
4095
  profile: opts.profile,
4096
+ supplements: stateGrounding,
4080
4097
  }));
4081
4098
  store.close();
4082
4099
  });
@@ -5454,6 +5471,79 @@ program
5454
5471
  store.close();
5455
5472
  }
5456
5473
  });
5474
+ // ---- grounding (the committed grounding docs vs the PUBLIC graph) -------------
5475
+ const GROUNDING_START = "<!-- HUNCH:START — auto-generated, do not edit by hand -->";
5476
+ const GROUNDING_END = "<!-- HUNCH:END -->";
5477
+ function groundingBlockContent(text) {
5478
+ const i = text.indexOf(GROUNDING_START);
5479
+ const j = text.indexOf(GROUNDING_END);
5480
+ if (i === -1 || j === -1 || j < i)
5481
+ return null;
5482
+ return text.slice(i + GROUNDING_START.length, j).trim();
5483
+ }
5484
+ program
5485
+ .command("grounding")
5486
+ .description("Check the committed grounding docs (CLAUDE.md, AGENTS.md, copilot-instructions, hunch.mdc, hunch.md) against what the PUBLIC graph generates — direction-aware: counts that merely LAG a merge are reported, counts AHEAD of the store (a record never committed) or divergent prose fail. --refresh regenerates every existing doc from the public store (never the overlay union, never a doc the project lacks). Exits 1 on ahead/diverged unless refreshed.")
5487
+ .option("--refresh", "regenerate the existing grounding docs from the public store (what the post-merge hook and the release remedy run)")
5488
+ .option("--json", "machine-readable verdicts")
5489
+ .option("--quiet", "print nothing on success")
5490
+ .action((opts) => {
5491
+ const root = findRoot();
5492
+ // PUBLIC-ONLY by construction, exactly as the release gate and the freshness test
5493
+ // read it: HUNCH_PRIVATE_DIR at an empty overlay beats .hunch/local.json and the
5494
+ // shared pointer, so a dev machine with an overlay attached can never write union
5495
+ // counts into a committed public doc.
5496
+ const emptyPrivate = mkdtempSync(join(tmpdir(), "hunch-grounding-public-"));
5497
+ const prior = process.env.HUNCH_PRIVATE_DIR;
5498
+ process.env.HUNCH_PRIVATE_DIR = emptyPrivate;
5499
+ const store = new HunchStore(hunchPaths(root));
5500
+ try {
5501
+ const rendered = renderHunchSection(store, root);
5502
+ const generated = groundingBlockContent(rendered) ?? rendered.trim();
5503
+ const verdicts = GROUNDING_DOC_PATHS.map((rel) => {
5504
+ const file = join(root, ...rel.split("/"));
5505
+ if (!existsSync(file))
5506
+ return { doc: rel, verdict: { kind: "absent" } };
5507
+ const committed = groundingBlockContent(readFileSync(file, "utf8"));
5508
+ if (committed === null)
5509
+ return { doc: rel, verdict: { kind: "diverged", reason: "no managed HUNCH block" } };
5510
+ return { doc: rel, verdict: classifyGroundingBlock(committed, generated) };
5511
+ });
5512
+ const refreshed = opts.refresh ? refreshExistingGrounding(root, store) : [];
5513
+ const failing = verdicts.filter((v) => v.verdict.kind === "ahead" || v.verdict.kind === "diverged");
5514
+ const lagging = verdicts.filter((v) => v.verdict.kind === "lagging");
5515
+ if (opts.json) {
5516
+ console.log(JSON.stringify({ docs: verdicts, refreshed, ok: opts.refresh ? true : failing.length === 0 }, null, 2));
5517
+ }
5518
+ else if (opts.refresh) {
5519
+ if (!opts.quiet)
5520
+ console.log(refreshed.length ? `grounding refreshed: ${refreshed.join(", ")}` : "grounding already fresh — nothing to regenerate");
5521
+ }
5522
+ else {
5523
+ for (const v of verdicts) {
5524
+ if (v.verdict.kind === "absent")
5525
+ continue;
5526
+ if (v.verdict.kind === "fresh" && opts.quiet)
5527
+ continue;
5528
+ console.log(`${v.verdict.kind === "fresh" ? "✓" : v.verdict.kind === "lagging" ? "·" : "✗"} ${describeGroundingFreshness(v.doc, v.verdict)}`);
5529
+ }
5530
+ if (!opts.quiet && !failing.length) {
5531
+ console.log(lagging.length
5532
+ ? `\n${lagging.length} doc(s) lag a merge — transient; the next capture commit or \`hunch grounding --refresh\` heals it.`
5533
+ : "✓ grounding docs are fresh.");
5534
+ }
5535
+ }
5536
+ if (!opts.refresh && failing.length)
5537
+ process.exitCode = 1;
5538
+ }
5539
+ finally {
5540
+ store.close();
5541
+ process.env.HUNCH_PRIVATE_DIR = prior;
5542
+ if (prior === undefined)
5543
+ delete process.env.HUNCH_PRIVATE_DIR;
5544
+ rmSync(emptyPrivate, { recursive: true, force: true });
5545
+ }
5546
+ });
5457
5547
  // ---- findings (the open-observations ledger) --------------------------------
5458
5548
  program
5459
5549
  .command("findings")
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";
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Grounding-block freshness classification (fnd_c402046ac7).
3
+ *
4
+ * The committed grounding docs (CLAUDE.md, AGENTS.md, copilot-instructions,
5
+ * hunch.mdc, hunch.md) carry a managed block whose first sentence states the
6
+ * public store's RECORD COUNTS. Those counts are a pure function of .hunch/*.json,
7
+ * so two branches that each capture one decision both regenerate the very same
8
+ * "N+1 decisions" line. Git merges identical lines silently, the merged store holds
9
+ * N+2, and the committed doc is one behind — with no conflict, no hook (the merge
10
+ * happened on the forge) and no human error. Every release-gate red of that class
11
+ * (PR #128, #135, v1.26.2's first run) was this lag.
12
+ *
13
+ * The lag is transient and self-healing: the next capture commit folds the
14
+ * regenerated docs in (refreshCommittableGrounding), and the release gate's
15
+ * repository-index stage regenerates them in the worktree and already treats the
16
+ * dirt as memory churn. What must STILL fail is a doc that is genuinely wrong:
17
+ *
18
+ * - prose in the block differs (a hand edit, a stale renderer, a template change);
19
+ * - an append-only count is AHEAD of the store: the doc counted a record the
20
+ * repository does not carry — the never-committed Video component of
21
+ * fnd_6391b4242f, the only defect the counts ever caught.
22
+ *
23
+ * So the rule is direction-aware and deterministic: decisions, bugs, constraints,
24
+ * components and policies only accrue, so a doc may lag behind them (merge) but never
25
+ * run ahead (missing record). Open findings move both ways (a finding resolved on one
26
+ * branch, a finding recorded on another), so a differing findings count alone is lag.
27
+ */
28
+ const COUNTS_RE = /\*\*(\d+) decisions?, (\d+) bugs?, (\d+) constraints?, (\d+) components?, (\d+) polic(?:y|ies)(?:, (\d+) open findings?)?\*\*/;
29
+ /** Record kinds whose committed count may only ever lag behind the store. */
30
+ export const APPEND_ONLY_COUNT_KINDS = ["decisions", "bugs", "constraints", "components", "policies"];
31
+ export function parseGroundingCounts(block) {
32
+ const m = COUNTS_RE.exec(block);
33
+ if (!m)
34
+ return null;
35
+ return {
36
+ match: m[0],
37
+ counts: {
38
+ decisions: Number(m[1]),
39
+ bugs: Number(m[2]),
40
+ constraints: Number(m[3]),
41
+ components: Number(m[4]),
42
+ policies: Number(m[5]),
43
+ findings: m[6] === undefined ? 0 : Number(m[6]),
44
+ },
45
+ };
46
+ }
47
+ /** Classify a committed managed block against the one the graph generates NOW.
48
+ * Both inputs are block CONTENT (markers stripped, trimmed). */
49
+ export function classifyGroundingBlock(committed, generated) {
50
+ if (committed === generated)
51
+ return { kind: "fresh" };
52
+ const c = parseGroundingCounts(committed);
53
+ const g = parseGroundingCounts(generated);
54
+ if (!c)
55
+ return { kind: "diverged", reason: "the committed block carries no record-counts sentence" };
56
+ if (!g)
57
+ return { kind: "diverged", reason: "the generated block carries no record-counts sentence" };
58
+ const withoutCounts = (text, match) => text.replace(match, "<counts>");
59
+ if (withoutCounts(committed, c.match) !== withoutCounts(generated, g.match)) {
60
+ return { kind: "diverged", reason: "the block differs outside the record-counts sentence" };
61
+ }
62
+ const ahead = APPEND_ONLY_COUNT_KINDS.filter((k) => c.counts[k] > g.counts[k]);
63
+ if (ahead.length)
64
+ return { kind: "ahead", committed: c.counts, generated: g.counts, ahead };
65
+ const behind = Object.keys(g.counts).filter((k) => c.counts[k] !== g.counts[k]);
66
+ return { kind: "lagging", committed: c.counts, generated: g.counts, behind };
67
+ }
68
+ /** One human line per verdict — shared by the freshness test and `hunch grounding`. */
69
+ export function describeGroundingFreshness(doc, verdict) {
70
+ const delta = (a, b, kinds) => kinds.map((k) => `${k} ${a[k]} → ${b[k]}`).join(", ");
71
+ switch (verdict.kind) {
72
+ case "fresh":
73
+ return `${doc}: fresh`;
74
+ case "lagging":
75
+ return `${doc}: counts lag the store (${delta(verdict.committed, verdict.generated, verdict.behind)}) — records merged in behind the doc; heals on the next capture or \`hunch grounding --refresh\``;
76
+ case "ahead":
77
+ return `${doc}: counts run AHEAD of the store (${delta(verdict.committed, verdict.generated, verdict.ahead)}) — the doc counted a record this repository does not carry; commit the missing .hunch/ record or regenerate`;
78
+ case "diverged":
79
+ return `${doc}: stale — ${verdict.reason}; regenerate with \`hunch grounding --refresh\` and commit`;
80
+ }
81
+ }
82
+ //# sourceMappingURL=groundingLag.js.map
@@ -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),
@@ -102,6 +113,10 @@ export const WriteRequestSchema = z.object({
102
113
  idempotency_key: z.string().min(8).max(256),
103
114
  expected_version: z.union([z.string().max(256), z.number().int().nonnegative()]).nullable().default(null),
104
115
  supersedes: z.string().max(2048).optional(),
116
+ /** Why this write happened, when it is not the writer's own doing (additive): an external
117
+ * source moved. A derived record written back as `stale` with this cause emits an
118
+ * `invalidated` change naming the pointer — the `changed` facet, finally written by someone. */
119
+ cause: z.object({ kind: z.literal("external"), ref: ExternalRefSchema }).strict().optional(),
105
120
  }).strict();
106
121
  export const DURABILITY = ["pushed", "committed", "local"];
107
122
  export const WriteResultSchema = z.object({
@@ -219,6 +234,7 @@ export const STATE_INVARIANTS = [
219
234
  { id: "one-live-decision-per-topic", statement: "A second live decision on a topic is refused with the incumbent named; supersession is explicit." },
220
235
  { id: "external-truth-stays-external", statement: "External systems remain authoritative for their own content; Nuryel holds credential-free pointers, versions and hashes, never mirrored bodies." },
221
236
  { id: "derived-state-carries-dependencies", statement: "A derived statement without dependencies cannot be invalidated and is therefore not state." },
237
+ { id: "derived-state-writer-owns-currentness", statement: "No source writes the drawer. The writer of a derived statement owns keeping its dependencies true: re-validate them on a schedule or on a source event, and write the statement back stale with the moved pointer as cause when one no longer holds. An agent that will not do this must not write derived state." },
222
238
  ];
223
239
  const grantKey = (scope) => scopePath(scope);
224
240
  /** authorization-before-retrieval, checked on the way OUT as well: nothing in a read response
@@ -236,6 +252,14 @@ export function assertReadWithinGrants(principal, response) {
236
252
  if (granted.has(grantKey(denied)))
237
253
  throw new Error(`denied scope ${grantKey(denied)} is actually granted — the response is inconsistent`);
238
254
  }
255
+ for (const read of response.scopes ?? []) {
256
+ if (!granted.has(grantKey(read)))
257
+ throw new Error(`read scope ${grantKey(read)} is outside the principal's grants`);
258
+ }
259
+ for (const receipt of response.receipts ?? []) {
260
+ if (!granted.has(grantKey(receipt.scope)))
261
+ throw new Error(`receipt for scope ${grantKey(receipt.scope)} is outside the principal's grants`);
262
+ }
239
263
  }
240
264
  /** provenance-on-every-write + scope agreement between the envelope and the record. */
241
265
  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
@@ -46,7 +46,9 @@ export const ExternalRefSchema = z.object({
46
46
  }).strict();
47
47
  /** What a derived statement rests on. Exactly what a currentness check re-validates. */
48
48
  export const DependencyRefSchema = z.discriminatedUnion("kind", [
49
- z.object({ kind: z.literal("record"), id: z.string().regex(TOKEN), record_hash: z.string().regex(SHA256) }).strict(),
49
+ /** `scope` (additive) points into ANOTHER partition — the repository decision an
50
+ * organization-drawer receipt rests on. Absent, the ref is in the record's own partition. */
51
+ z.object({ kind: z.literal("record"), id: z.string().regex(TOKEN), record_hash: z.string().regex(SHA256), scope: ScopeSchema.optional() }).strict(),
50
52
  z.object({ kind: z.literal("external"), ref: ExternalRefSchema }).strict(),
51
53
  z.object({ kind: z.literal("schema"), name: z.string().max(256), fingerprint: z.string().regex(SHA256) }).strict(),
52
54
  ]);
@@ -66,6 +68,12 @@ export const ActionReceiptSchema = z.object({
66
68
  verified_at: z.string().regex(ISO).optional(),
67
69
  result_fingerprint: z.string().regex(SHA256).optional(),
68
70
  invalidates: z.array(z.string().max(512)).max(64).default([]),
71
+ /** What the action rested on (additive): the decision it implements, the change proof for
72
+ * the shipped revision, the commitment or incident it answers. Same shape as a derived
73
+ * statement's dependencies, so "what does this closure rest on" is one read. A record ref
74
+ * in the receipt's own partition is verified by hash on write; a ref into another partition
75
+ * is a pointer the reader resolves with `records`, grants first. */
76
+ rests_on: z.array(DependencyRefSchema).max(64).optional(),
69
77
  provenance: ProvenanceSchema,
70
78
  }).strict();
71
79
  /** committed — an obligation with a due date and an in-force window. */
@@ -80,6 +88,9 @@ export const CommitmentSchema = z.object({
80
88
  status: z.enum(["open", "waiting", "done", "cancelled"]),
81
89
  source: ExternalRefSchema.optional(),
82
90
  evidence_excerpt: z.string().max(900).optional(),
91
+ /** The receipt that fulfilled this commitment (additive). A closure names what happened:
92
+ * the binding refuses a `closed_by` that is not a succeeded/verified receipt on record. */
93
+ closed_by: z.string().regex(/^nrc_[a-f0-9]{24}$/).optional(),
83
94
  valid_from: z.string().regex(ISO),
84
95
  valid_to: z.string().regex(ISO).nullable().default(null),
85
96
  provenance: ProvenanceSchema,