@davesheffer/hunch 1.28.0 → 1.30.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,15 @@ 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
+ <<<<<<< HEAD
211
+ As of 1.30.0 subject identity is by external reference: one active entity per external record per partition, a subject written as an entity's external key refused with the entity id named, reads resolving one explicit hop — so two agents over one CRM record land on one subject. Replay determinism is a check, not a claim: `hunch serve replay --partition <kind:id>` (or `--root <dir>`) folds a partition's ledger into the state it implies and compares it hash for hash to the records on file, exits 1 on any divergence, and runs on every agent-farm run; and a human correction outranks later agent writes — a record a human confirmed is never overwritten or superseded by an agent or service principal (replay, stale-with-cause and closure by receipt are the only agent moves, each keeping the human's provenance).
212
+ =======
213
+ As of 1.30.0 replay determinism is a check, not a claim: `hunch serve replay --partition <kind:id>` (or `--root <dir>`) folds a partition's ledger into the state it implies and compares it hash for hash to the records on file, exits 1 on any divergence, runs inside `hunch drift` when the partition has a ledger, and runs on every agent-farm run; and a human correction outranks later agent writes — a record a human confirmed is never overwritten or superseded by an agent or service principal (replay, stale-with-cause and closure by receipt are the only agent moves, each keeping the human's provenance).
214
+ >>>>>>> feat/replay-determinism
215
+
216
+ Read [Deterministic organizational state](docs/deterministic-state.md), the [roadmap](ROADMAP.md) and the dated [competitive landscape](docs/competitive-landscape.md).
207
217
 
208
218
  ### Naming
209
219
 
@@ -216,7 +226,7 @@ The current release can keep a team's **repository-scoped** memory in a dedicate
216
226
  Today Hunch does not host that shared Git repository; teammates/CI use normal Git access and one maintainer connects it:
217
227
 
218
228
  ```bash
219
- npm i -g @davesheffer/hunch@1.23.3
229
+ npm i -g @davesheffer/hunch@1.28.0
220
230
  hunch shared --repo git@github.com:acme/project-hunch-memory.git
221
231
  git add .gitignore .hunch/team.json
222
232
  git commit -m "chore: connect shared Hunch memory"
@@ -226,7 +236,7 @@ git push
226
236
  Teammates then install the same version and run:
227
237
 
228
238
  ```bash
229
- npm i -g @davesheffer/hunch@1.23.3
239
+ npm i -g @davesheffer/hunch@1.28.0
230
240
  git pull
231
241
  hunch init
232
242
  hunch doctor
@@ -264,9 +274,11 @@ See the [changelog](CHANGELOG.md) for release detail and the [roadmap](ROADMAP.m
264
274
 
265
275
  - [Full documentation](https://www.hunchmemory.com/docs)
266
276
  - [Copy-paste cookbook](https://www.hunchmemory.com/cookbook)
277
+ - [Turn PR review threads into scoped review rules](docs/review-memory.md)
267
278
  - [Deterministic organizational state](docs/deterministic-state.md)
268
279
  - [Project DNA](docs/project-dna.md)
269
280
  - [Native change proof](docs/change-proof.md)
281
+ - [The autonomy ladder](docs/autonomy-ladder.md)
270
282
  - [Engineering Landscape Graph](docs/engineering-landscape.md)
271
283
  - [Hunch roadmap](ROADMAP.md)
272
284
  - [VS Code extension](vscode-extension/README.md)
package/dist/cli/index.js CHANGED
@@ -27,6 +27,7 @@ import { HUNCH_VERSION } from "../core/version.js";
27
27
  import { registerIntegrationCommands } from "./integrations.js";
28
28
  import { registerServeCommands } from "./serve.js";
29
29
  import { registerUpdateCommand } from "./update.js";
30
+ import { registerReviewMemoryCommands } from "./reviewMemory.js";
30
31
  import { inspectIntegrations, formatIntegrationHealth, integrationHealthFails, integrationSessionWarning } from "../integrations/health.js";
31
32
  import { HunchStore } from "../store/hunchStore.js";
32
33
  import { JsonStore } from "../store/jsonStore.js";
@@ -45,15 +46,16 @@ import { deriveForbids, effectiveForbids } from "../core/constraintmatch.js";
45
46
  import { extractInlineIntent } from "../extractors/comments.js";
46
47
  import { renderText, renderMarkdown, renderSarif, renderImpact, reportFailsStrict } from "../core/checkreport.js";
47
48
  import { partitionReview, isReviewDraft, READY_MIN_GROUNDED } from "../core/reviewqueue.js";
48
- import { installPostCommitHook, installPreCommitHook } from "../integrations/hooks.js";
49
+ import { installPostCommitHook, installPreCommitHook, installPostMergeHook } from "../integrations/hooks.js";
49
50
  import { ensureSharedOverlayPointer } from "../integrations/worktree.js";
50
51
  import { flushCapture, flushMemoryHome, flushMemoryHomes, pinSharedRemote, sharedRemoteFor } from "../integrations/sync.js";
51
52
  import { installMergeDriver } from "../integrations/mergeDriver.js";
52
53
  import { ensureGitignore, ignoreHunchMemory, HUNCH_MEMORY_DIRS } from "../integrations/gitignore.js";
53
54
  import { writeCiWorkflow } from "../integrations/ciAction.js";
54
- import { updateClaudeMd } from "../integrations/claudemd.js";
55
+ import { updateClaudeMd, renderHunchSection } from "../integrations/claudemd.js";
56
+ import { classifyGroundingBlock, describeGroundingFreshness } from "../core/groundingLag.js";
55
57
  import { writeMcpJson, writeSlashCommands, installClaudeHooks } from "../integrations/scaffold.js";
56
- import { scaffoldProviders, regenerateGrounding, refreshExistingGrounding, refreshCommittableGrounding } from "../integrations/providers.js";
58
+ import { scaffoldProviders, regenerateGrounding, refreshExistingGrounding, refreshCommittableGrounding, GROUNDING_DOC_PATHS } from "../integrations/providers.js";
57
59
  import { healClaudeConfigCaseSplit } from "../integrations/claudeConfig.js";
58
60
  import { formatContext, formatSearchHit, formatStructure } from "../core/format.js";
59
61
  import { isStateKind, renderStateLine, stateSupplements } from "../core/stateDelivery.js";
@@ -107,6 +109,10 @@ import { draftTripwires, knownRepoDeps } from "../synthesis/tripwires.js";
107
109
  import { constraintId } from "../core/ids.js";
108
110
  import { readManifest, writeManifest, SCHEMA_VERSION } from "../core/migrate.js";
109
111
  import { mergeHunchJson } from "../store/merge.js";
112
+ import { ledgerFile } from "../store/changeLedger.js";
113
+ import { verifyReplay } from "../store/replay.js";
114
+ import { partitionOf, stateHomeFor } from "../store/stateBinding.js";
115
+ import { scopePath } from "../core/stateContract.js";
110
116
  import { movePublicMemoryToPrivate } from "../store/privateMigrate.js";
111
117
  import { ENTITY_KINDS } from "../core/types.js";
112
118
  import { planCompaction } from "../store/compact.js";
@@ -117,6 +123,28 @@ program.name("hunch").description("Hunch — engineering memory and a determinis
117
123
  registerIntegrationCommands(program);
118
124
  registerServeCommands(program);
119
125
  registerUpdateCommand(program);
126
+ registerReviewMemoryCommands(program, (records, repository, privateOnly) => {
127
+ const { store, root } = storeFor();
128
+ if (!repositoryUsesRemote(root, `https://github.com/${repository}.git`)) {
129
+ throw new Error("review packet repository does not match this checkout's remotes");
130
+ }
131
+ const home = store.captureHome(privateOnly);
132
+ // Preflight the whole batch. A repeated import must never revive a retired rule,
133
+ // replace a countersigned constraint, or change its scope/evidence silently.
134
+ for (const record of records) {
135
+ if (!existsSync(join(root, record.scope[0])))
136
+ throw new Error(`review scope ${record.scope[0]} no longer exists; review the current code before capturing this rule`);
137
+ const existing = store.recs("constraints").find(r => r.id === record.id);
138
+ if (existing)
139
+ throw new Error(`constraint ${record.id} already exists; use the existing correction review flow to change it`);
140
+ }
141
+ for (const record of records)
142
+ store.putCapture("constraints", record, privateOnly);
143
+ store.reindex();
144
+ if (home === "public" && !store.autoCommit)
145
+ refreshExistingGrounding(root, store);
146
+ pumpMemoryHome(store, root, home, `hunch: capture ${records.length} sourced review rule(s)`);
147
+ });
120
148
  let openStore = null;
121
149
  function openTeamStore(root, opts = {}) {
122
150
  // A committed team.json is an explicit declaration that this checkout belongs
@@ -282,6 +310,8 @@ program
282
310
  console.log(` ✓ post-commit hook ${h.action} (learning loop)${syncToOverlay ? " — syncs to the shared overlay" : ""}${opts.autoCommit ? " — auto-commit on" : ""}`);
283
311
  const m = installMergeDriver(root, inv.shell);
284
312
  console.log(` ✓ team merge driver ${m.action}`);
313
+ const pm = installPostMergeHook(root, inv.shell);
314
+ console.log(` ✓ post-merge hook ${pm.action} (re-syncs grounding docs after a merge that brought memory in)`);
285
315
  // Auto-install the pre-commit guard by default (advisory: flags invariants
286
316
  // touched directly OR via blast radius, never blocks). Opt out with
287
317
  // --no-enforce; --enforce-strict makes blocking near/direct hits fail the commit.
@@ -466,7 +496,7 @@ program
466
496
  pumpMemoryHome(store, root, home, `hunch: backfill ${written} decision(s)`);
467
497
  // Honest tally of where the tokens went: trivial commits are seeded by the
468
498
  // free deterministic heuristic, only substantive ones spend the LLM.
469
- console.log(`Done: ${written} decision(s) seeded (${llm} via LLM, ${heuristic} heuristic), ${skipped} skipped (trivial/non-code/already-captured).`);
499
+ console.log(`Done: ${written} decision(s) seeded (${llm} via LLM, ${heuristic} heuristic), ${skipped} skipped (trivial/not substantive/already-captured).`);
470
500
  store.close();
471
501
  });
472
502
  // ---- sync (post-commit hook) ----------------------------------------------
@@ -5445,27 +5475,110 @@ program
5445
5475
  // ---- drift (doc≠graph detector; advisory + CI-gateable) -------------------
5446
5476
  program
5447
5477
  .command("drift")
5448
- .description("Detect memory drift: dead refs, dangling supersedes, stale 'proposed' docs, doc≠graph anchor-stale (a file still anchored to a superseded decision), and markdown sections whose <!-- hunch:topic … dec_id --> pin points at a superseded or missing decision (AGENTS.md/CLAUDE.md as a drift surface). Exits non-zero on any anchor-stale drift or topic collision — the doc≠graph gate.")
5478
+ .description("Detect memory drift: dead refs, dangling supersedes, stale 'proposed' docs, doc≠graph anchor-stale (a file still anchored to a superseded decision), markdown sections whose <!-- hunch:topic … dec_id --> pin points at a superseded or missing decision (AGENTS.md/CLAUDE.md as a drift surface), and ledger≠records replay divergence when this partition has a change ledger. Exits non-zero on any anchor-stale drift, topic collision or replay divergence — the doc≠graph and ledger≠records gate.")
5449
5479
  .action(() => {
5450
5480
  const { store, root } = storeFor();
5451
5481
  try {
5452
5482
  const { findings } = computeDrift(store, root);
5453
5483
  const collisions = topicCollisions(store.recs("decisions"));
5454
- if (!findings.length && collisions.size === 0) {
5455
- console.log("✓ No drift memory is in sync with the code/docs.");
5484
+ // ledger≠records: when the partition this store IS has a change ledger, its records must be
5485
+ // exactly what the ledger implies (nuryel.replay/1). No ledger, nothing to check.
5486
+ const own = partitionOf(store);
5487
+ const replay = existsSync(ledgerFile(stateHomeFor(store, own).hunchDir, own)) ? verifyReplay(store, own) : null;
5488
+ const replayFailing = replay ? replay.divergences.filter((d) => d.kind !== "legacy-drift") : [];
5489
+ const replayCount = replay && !replay.ok ? Math.max(1, replayFailing.length) : 0;
5490
+ if (!findings.length && collisions.size === 0 && !replayCount) {
5491
+ console.log(`✓ No drift — memory is in sync with the code/docs.${replay ? ` Replay OK: ${scopePath(own)} ledger head ${replay.ledger.head_seq}, ${replay.records.verified + replay.records.verified_by_idempotency} record(s) verified.` : ""}`);
5456
5492
  return;
5457
5493
  }
5458
5494
  for (const f of findings.slice(0, 50))
5459
5495
  console.log(`· [${f.kind}] ${f.id} — ${f.detail}`);
5460
5496
  for (const [topic, decs] of collisions)
5461
5497
  console.log(`· [topic-collision] "${topic}" has ${decs.length} live decisions: ${decs.map((d) => d.id).join(", ")} — run \`hunch reconcile-topics\``);
5498
+ for (const d of replay?.divergences ?? [])
5499
+ console.log(`· [replay-${d.kind}] ${d.record_id} — ${d.detail}`);
5500
+ if (replayCount && !replayFailing.length)
5501
+ console.log(`· [replay-fingerprint] ${scopePath(own)}: ledger fold ${replay.replay_hash} ≠ stored ${replay.stored_hash}`);
5462
5502
  const anchor = findings.filter((f) => f.kind === "anchor-stale" || f.kind === "doc-anchor-stale").length;
5463
- console.log(`\n${findings.length} finding(s)${anchor ? `, ${anchor} doc≠graph (anchor-stale)` : ""}${collisions.size ? `, ${collisions.size} topic-collision(s)` : ""}.`);
5464
- if (anchor || collisions.size)
5503
+ console.log(`\n${findings.length + replayCount} finding(s)${anchor ? `, ${anchor} doc≠graph (anchor-stale)` : ""}${collisions.size ? `, ${collisions.size} topic-collision(s)` : ""}${replayCount ? `, ${replayCount} ledger≠records (replay: hunch serve replay --root .)` : ""}.`);
5504
+ if (anchor || collisions.size || replayCount)
5505
+ process.exitCode = 1;
5506
+ }
5507
+ finally {
5508
+ store.close();
5509
+ }
5510
+ });
5511
+ // ---- grounding (the committed grounding docs vs the PUBLIC graph) -------------
5512
+ const GROUNDING_START = "<!-- HUNCH:START — auto-generated, do not edit by hand -->";
5513
+ const GROUNDING_END = "<!-- HUNCH:END -->";
5514
+ function groundingBlockContent(text) {
5515
+ const i = text.indexOf(GROUNDING_START);
5516
+ const j = text.indexOf(GROUNDING_END);
5517
+ if (i === -1 || j === -1 || j < i)
5518
+ return null;
5519
+ return text.slice(i + GROUNDING_START.length, j).trim();
5520
+ }
5521
+ program
5522
+ .command("grounding")
5523
+ .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.")
5524
+ .option("--refresh", "regenerate the existing grounding docs from the public store (what the post-merge hook and the release remedy run)")
5525
+ .option("--json", "machine-readable verdicts")
5526
+ .option("--quiet", "print nothing on success")
5527
+ .action((opts) => {
5528
+ const root = findRoot();
5529
+ // PUBLIC-ONLY by construction, exactly as the release gate and the freshness test
5530
+ // read it: HUNCH_PRIVATE_DIR at an empty overlay beats .hunch/local.json and the
5531
+ // shared pointer, so a dev machine with an overlay attached can never write union
5532
+ // counts into a committed public doc.
5533
+ const emptyPrivate = mkdtempSync(join(tmpdir(), "hunch-grounding-public-"));
5534
+ const prior = process.env.HUNCH_PRIVATE_DIR;
5535
+ process.env.HUNCH_PRIVATE_DIR = emptyPrivate;
5536
+ const store = new HunchStore(hunchPaths(root));
5537
+ try {
5538
+ const rendered = renderHunchSection(store, root);
5539
+ const generated = groundingBlockContent(rendered) ?? rendered.trim();
5540
+ const verdicts = GROUNDING_DOC_PATHS.map((rel) => {
5541
+ const file = join(root, ...rel.split("/"));
5542
+ if (!existsSync(file))
5543
+ return { doc: rel, verdict: { kind: "absent" } };
5544
+ const committed = groundingBlockContent(readFileSync(file, "utf8"));
5545
+ if (committed === null)
5546
+ return { doc: rel, verdict: { kind: "diverged", reason: "no managed HUNCH block" } };
5547
+ return { doc: rel, verdict: classifyGroundingBlock(committed, generated) };
5548
+ });
5549
+ const refreshed = opts.refresh ? refreshExistingGrounding(root, store) : [];
5550
+ const failing = verdicts.filter((v) => v.verdict.kind === "ahead" || v.verdict.kind === "diverged");
5551
+ const lagging = verdicts.filter((v) => v.verdict.kind === "lagging");
5552
+ if (opts.json) {
5553
+ console.log(JSON.stringify({ docs: verdicts, refreshed, ok: opts.refresh ? true : failing.length === 0 }, null, 2));
5554
+ }
5555
+ else if (opts.refresh) {
5556
+ if (!opts.quiet)
5557
+ console.log(refreshed.length ? `grounding refreshed: ${refreshed.join(", ")}` : "grounding already fresh — nothing to regenerate");
5558
+ }
5559
+ else {
5560
+ for (const v of verdicts) {
5561
+ if (v.verdict.kind === "absent")
5562
+ continue;
5563
+ if (v.verdict.kind === "fresh" && opts.quiet)
5564
+ continue;
5565
+ console.log(`${v.verdict.kind === "fresh" ? "✓" : v.verdict.kind === "lagging" ? "·" : "✗"} ${describeGroundingFreshness(v.doc, v.verdict)}`);
5566
+ }
5567
+ if (!opts.quiet && !failing.length) {
5568
+ console.log(lagging.length
5569
+ ? `\n${lagging.length} doc(s) lag a merge — transient; the next capture commit or \`hunch grounding --refresh\` heals it.`
5570
+ : "✓ grounding docs are fresh.");
5571
+ }
5572
+ }
5573
+ if (!opts.refresh && failing.length)
5465
5574
  process.exitCode = 1;
5466
5575
  }
5467
5576
  finally {
5468
5577
  store.close();
5578
+ process.env.HUNCH_PRIVATE_DIR = prior;
5579
+ if (prior === undefined)
5580
+ delete process.env.HUNCH_PRIVATE_DIR;
5581
+ rmSync(emptyPrivate, { recursive: true, force: true });
5469
5582
  }
5470
5583
  });
5471
5584
  // ---- findings (the open-observations ledger) --------------------------------
@@ -0,0 +1,32 @@
1
+ import { readFileSync, statSync } from "node:fs";
2
+ import { prepareReviewMemory, compileReviewRules, validateReviewPacket } from "../core/reviewMemory.js";
3
+ function readJson(file) {
4
+ if (statSync(file).size > 16 * 1024 * 1024)
5
+ throw new Error("review input exceeds 16 MiB");
6
+ return JSON.parse(readFileSync(file, "utf8"));
7
+ }
8
+ export function registerReviewMemoryCommands(program, capture) {
9
+ const command = program.command("review-memory").description("Turn sourced PR review threads into scoped review rules");
10
+ command.command("prepare").requiredOption("--from <file>", "GitHub REST review comments JSON")
11
+ .requiredOption("--repository <owner/repo>", "repository that owns every comment")
12
+ .description("Print a deterministic evidence packet; no rules are activated")
13
+ .action((opts) => {
14
+ console.log(JSON.stringify(prepareReviewMemory(opts.repository, readJson(opts.from)), null, 2));
15
+ });
16
+ command.command("capture").requiredOption("--from <file>", "prepared evidence packet")
17
+ .requiredOption("--rules <file>", "explicit selections: candidate_id, evidence_hash, rule, check")
18
+ .option("--apply", "persist the previewed rules as advisory constraints")
19
+ .option("--private", "keep rules and source links in the configured private overlay")
20
+ .option("--public", "allow rules and source links into repository-visible memory")
21
+ .description("Preview selected rules; --apply records them without blocking authority")
22
+ .action((opts) => {
23
+ if (opts.apply && !!opts.private === !!opts.public)
24
+ throw new Error("--apply requires exactly one of --private or --public");
25
+ const packet = validateReviewPacket(readJson(opts.from));
26
+ const records = compileReviewRules(packet, readJson(opts.rules), new Date().toISOString());
27
+ if (opts.apply)
28
+ capture(records, packet.repository, !!opts.private);
29
+ console.log(JSON.stringify({ applied: !!opts.apply, authority: "advisory", rules: records }, null, 2));
30
+ });
31
+ }
32
+ //# sourceMappingURL=reviewMemory.js.map
package/dist/cli/serve.js CHANGED
@@ -2,6 +2,10 @@ import { resolve } from "node:path";
2
2
  import { createServeApp } from "../serve/app.js";
3
3
  import { initServeConfig, partitionFor, readServeConfig } from "../serve/config.js";
4
4
  import { compactLedger } from "../store/changeLedger.js";
5
+ import { HunchStore } from "../store/hunchStore.js";
6
+ import { hunchPaths } from "../core/paths.js";
7
+ import { partitionOf } from "../store/stateBinding.js";
8
+ import { formatReplayReport, verifyReplay } from "../store/replay.js";
5
9
  import { join } from "node:path";
6
10
  import { ScopeSchema, scopePath } from "../core/stateContract.js";
7
11
  import { HUNCH_VERSION } from "../core/version.js";
@@ -55,6 +59,46 @@ export function registerServeCommands(program) {
55
59
  }
56
60
  console.log(result.dropped ? `${scopePath(scope)}: dropped ${result.dropped} event(s); floor ${result.floor_seq}, head ${result.head_seq}` : `${scopePath(scope)}: nothing to compact (${result.head_seq - result.floor_seq} events retained)`);
57
61
  });
62
+ serve.command("replay")
63
+ .description("Replay determinism check: fold a partition's change ledger into the state it implies and compare it, hash for hash, to the records on file. Exits 1 on any divergence — wire into CI.")
64
+ .option("--partition <kind:id>", "the served partition to verify (from --config); omit with --root")
65
+ .option("--root <dir>", "verify the partition a directory IS (its .hunch/partition.json, or the repository) without a serve config")
66
+ .option("--json", "machine-readable report (nuryel.replay/1)")
67
+ .action((opts) => {
68
+ let root;
69
+ let scope;
70
+ if (opts.root) {
71
+ root = resolve(opts.root);
72
+ const probe = new HunchStore(hunchPaths(root));
73
+ try {
74
+ scope = opts.partition ? parseScopeArg(opts.partition) : partitionOf(probe);
75
+ }
76
+ finally {
77
+ probe.close();
78
+ }
79
+ }
80
+ else {
81
+ if (!opts.partition)
82
+ throw new Error("pass --partition kind:id (with a serve config) or --root <dir>");
83
+ const parent = serve.opts();
84
+ const config = readServeConfig(resolve(parent.config ?? DEFAULT_CONFIG));
85
+ scope = parseScopeArg(opts.partition);
86
+ const partition = partitionFor(config, scope);
87
+ if (!partition)
88
+ throw new Error(`this config does not serve ${scopePath(scope)}`);
89
+ root = partition.root;
90
+ }
91
+ const store = new HunchStore(hunchPaths(root));
92
+ try {
93
+ const report = verifyReplay(store, scope);
94
+ console.log(opts.json ? JSON.stringify(report) : formatReplayReport(report));
95
+ if (!report.ok)
96
+ process.exitCode = 1;
97
+ }
98
+ finally {
99
+ store.close();
100
+ }
101
+ });
58
102
  serve.command("init")
59
103
  .description("Declare a partition directory and mint a principal token (printed once; only its hash is stored)")
60
104
  .requiredOption("--partition <kind:id>", "the scope this directory IS, e.g. user:david or organization:acme")
@@ -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
@@ -0,0 +1,100 @@
1
+ /** Review text is evidence, never executable instructions or policy authority. */
2
+ import { z } from "zod";
3
+ import { canonicalHash } from "../constitution/canonical.js";
4
+ import { buildCorrectionConstraint } from "./correction.js";
5
+ const repositorySchema = z.string().regex(/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/);
6
+ const pathSchema = z.string().min(1).max(500).refine(p => !p.startsWith("/") && !/[\\:*?\[\]{}\x00-\x1f]/.test(p)
7
+ && p.split("/").every(part => part !== ".." && part !== "." && part !== ""), "expected a literal repository-relative file");
8
+ const commentSchema = z.object({
9
+ id: z.number().int().positive().max(Number.MAX_SAFE_INTEGER),
10
+ body: z.string().trim().min(1).max(32000),
11
+ path: pathSchema,
12
+ html_url: z.string().url(),
13
+ commit_id: z.string().regex(/^[a-f0-9]{40,64}$/),
14
+ created_at: z.string().datetime({ offset: true }),
15
+ updated_at: z.string().datetime({ offset: true }),
16
+ in_reply_to_id: z.number().int().positive().max(Number.MAX_SAFE_INTEGER).optional(),
17
+ user: z.object({ login: z.string().min(1).max(100), type: z.enum(["User", "Bot"]) }),
18
+ });
19
+ /** Accept GitHub REST review-comment exports, including gh --paginate --slurp pages. */
20
+ export function prepareReviewMemory(repository, input) {
21
+ repositorySchema.parse(repository);
22
+ if (!Array.isArray(input))
23
+ throw new Error("expected a GitHub review-comment array");
24
+ const comments = z.array(commentSchema).max(10000).parse(input.flat());
25
+ const unique = new Map();
26
+ for (const comment of comments) {
27
+ const url = new URL(comment.html_url);
28
+ if (url.origin !== "https://github.com" || url.username || url.password || url.search
29
+ || !new RegExp(`^/${repository.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/pull/[1-9][0-9]*$`, "i").test(url.pathname)
30
+ || url.hash !== `#discussion_r${comment.id}`)
31
+ throw new Error(`comment ${comment.id} does not belong to ${repository}`);
32
+ const previous = unique.get(comment.id);
33
+ if (previous && canonicalHash(previous) !== canonicalHash(comment))
34
+ throw new Error(`conflicting versions of comment ${comment.id}`);
35
+ unique.set(comment.id, comment);
36
+ }
37
+ const groups = new Map();
38
+ let excluded = 0;
39
+ for (const comment of unique.values()) {
40
+ if (comment.user.type === "Bot") {
41
+ excluded++;
42
+ continue;
43
+ }
44
+ const rootId = comment.in_reply_to_id ?? comment.id;
45
+ const group = groups.get(rootId) ?? [];
46
+ group.push(comment);
47
+ groups.set(rootId, group);
48
+ }
49
+ const candidates = [];
50
+ for (const [rootId, group] of groups) {
51
+ // Do not misrepresent a reply as the original request when an export is partial.
52
+ const root = group.find(comment => comment.id === rootId && !comment.in_reply_to_id);
53
+ if (!root)
54
+ throw new Error(`missing human root comment ${rootId}; export the complete thread`);
55
+ if (group.some(comment => comment.path !== root.path || new URL(comment.html_url).pathname !== new URL(root.html_url).pathname)) {
56
+ throw new Error(`inconsistent thread ${rootId}`);
57
+ }
58
+ group.sort((a, b) => a.id - b.id);
59
+ const evidenceHash = canonicalHash({ repository: repository.toLowerCase(), comments: group });
60
+ candidates.push({ id: `review_${rootId}`, evidence_hash: evidenceHash, file: root.path, comments: group });
61
+ }
62
+ candidates.sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
63
+ return { schema: "hunch.review-memory/1", repository: repository.toLowerCase(), authority: "none", candidates, excluded_bots: excluded };
64
+ }
65
+ export function validateReviewPacket(input) {
66
+ const packet = z.object({
67
+ schema: z.literal("hunch.review-memory/1"), repository: repositorySchema,
68
+ authority: z.literal("none"), excluded_bots: z.number().int().nonnegative(),
69
+ candidates: z.array(z.object({ id: z.string(), evidence_hash: z.string(), file: pathSchema, comments: z.array(commentSchema).min(1) }).strict()).max(10000),
70
+ }).strict().parse(input);
71
+ const rebuilt = prepareReviewMemory(packet.repository, packet.candidates.flatMap(c => c.comments));
72
+ if (canonicalHash(rebuilt.candidates) !== canonicalHash(packet.candidates))
73
+ throw new Error("review packet evidence hash or membership changed; prepare it again");
74
+ return packet;
75
+ }
76
+ /** A separate, explicit selection supplies the actual rule and how to check it. */
77
+ export function compileReviewRules(packetInput, selections, now) {
78
+ const packet = validateReviewPacket(packetInput);
79
+ const rules = z.array(z.object({
80
+ candidate_id: z.string(), evidence_hash: z.string(),
81
+ rule: z.string().trim().min(10).max(2000),
82
+ check: z.string().trim().min(10).max(4000),
83
+ }).strict()).min(1).max(100).parse(selections);
84
+ const results = rules.map(selection => {
85
+ const candidate = packet.candidates.find(c => c.id === selection.candidate_id);
86
+ if (!candidate || candidate.evidence_hash !== selection.evidence_hash)
87
+ throw new Error(`stale or missing review selection ${selection.candidate_id}`);
88
+ const record = buildCorrectionConstraint({ rule: selection.rule, scope_hint_file: candidate.file,
89
+ severity: "warning", vouched: false, rationale: `Review check: ${selection.check}` }, now);
90
+ // Prose cannot silently create a regex/import matcher or earn a human signature.
91
+ record.forbids = null;
92
+ record.provenance.evidence = [packet.repository, candidate.id, candidate.evidence_hash,
93
+ ...candidate.comments.flatMap(c => [c.html_url, `git:${c.commit_id}`])];
94
+ return record;
95
+ });
96
+ if (new Set(results.map(r => r.id)).size !== results.length)
97
+ throw new Error("duplicate rule statements; select one thread per rule");
98
+ return results;
99
+ }
100
+ //# sourceMappingURL=reviewMemory.js.map
@@ -28,6 +28,7 @@ import { createHash } from "node:crypto";
28
28
  import { z } from "zod";
29
29
  import { compareCodeUnits } from "./canonicalOrder.js";
30
30
  import { DELIVERY_PROFILES } from "./delivery.js";
31
+ import { isHumanConfirmed as sourceIsHumanConfirmed } from "./strictgate.js";
31
32
  import { ScopeSchema, scopePath, DependencyRefSchema, ExternalRefSchema, RECEIPT_SCHEMA_VERSION, COMMITMENT_SCHEMA_VERSION, DERIVED_SCHEMA_VERSION, ENTITY_SCHEMA_VERSION, RELATIONSHIP_SCHEMA_VERSION, } from "./stateRecords.js";
32
33
  export * from "./stateRecords.js";
33
34
  export const STATE_CONTRACT_VERSION = "nuryel.state/1";
@@ -113,6 +114,10 @@ export const WriteRequestSchema = z.object({
113
114
  idempotency_key: z.string().min(8).max(256),
114
115
  expected_version: z.union([z.string().max(256), z.number().int().nonnegative()]).nullable().default(null),
115
116
  supersedes: z.string().max(2048).optional(),
117
+ /** Why this write happened, when it is not the writer's own doing (additive): an external
118
+ * source moved. A derived record written back as `stale` with this cause emits an
119
+ * `invalidated` change naming the pointer — the `changed` facet, finally written by someone. */
120
+ cause: z.object({ kind: z.literal("external"), ref: ExternalRefSchema }).strict().optional(),
116
121
  }).strict();
117
122
  export const DURABILITY = ["pushed", "committed", "local"];
118
123
  export const WriteResultSchema = z.object({
@@ -230,8 +235,17 @@ export const STATE_INVARIANTS = [
230
235
  { id: "one-live-decision-per-topic", statement: "A second live decision on a topic is refused with the incumbent named; supersession is explicit." },
231
236
  { 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." },
232
237
  { id: "derived-state-carries-dependencies", statement: "A derived statement without dependencies cannot be invalidated and is therefore not state." },
238
+ { id: "one-entity-per-external-ref", statement: "One external record is one entity in a partition: a second active entity carrying an external key an incumbent already carries is refused with the incumbent named, and a subject written as that record's external key is refused with the entity's id named. Identity is explicit refs, never similarity; merge is explicit — a retired entity names the survivor in `merged_into`, the ledger holds the `retired` event, nothing under the old id is rewritten and reads resolve to the survivor — and split is the explicit reverse; never a silent rewrite." },
239
+ { id: "human-correction-outranks-agent-writes", statement: "A record a human confirmed is never overwritten or superseded by an agent or service principal: the agent may replay it, write derived state back stale with the external cause that moved, or close a commitment with a receipt on record. Changing what the human said takes a human." },
240
+ { 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." },
233
241
  ];
234
242
  const grantKey = (scope) => scopePath(scope);
243
+ /** The memory supply chain's top tier: a record whose provenance a human signed. Same tier rule
244
+ * as the strict gate's (strictgate.isHumanConfirmed), applied to a record instead of a source. */
245
+ export function isHumanConfirmed(record) {
246
+ const source = record?.provenance?.source;
247
+ return typeof source === "string" && sourceIsHumanConfirmed(source);
248
+ }
235
249
  /** authorization-before-retrieval, checked on the way OUT as well: nothing in a read response
236
250
  * may sit outside the principal's grants. Bindings must also filter on the way in. */
237
251
  export function assertReadWithinGrants(principal, response) {