@davesheffer/hunch 1.28.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 +21 -17
- package/dist/cli/index.js +79 -3
- package/dist/core/groundingLag.js +82 -0
- package/dist/core/stateContract.js +5 -0
- package/dist/core/stateRecords.js +12 -1
- package/dist/integrations/hooks.js +41 -0
- package/dist/integrations/providers.js +8 -0
- package/dist/mcp/server.js +28 -6
- package/dist/store/changeLedger.js +5 -0
- package/dist/store/stateBinding.js +101 -7
- package/package.json +2 -2
- package/server.json +2 -2
- package/tooling/competitive-watch.mjs +1 -0
package/README.md
CHANGED
|
@@ -1,25 +1,27 @@
|
|
|
1
1
|
# Hunch
|
|
2
2
|
|
|
3
|
-
##
|
|
3
|
+
## Agents are probabilistic. Organizations need deterministic state. Hunch is the state layer between them.
|
|
4
4
|
|
|
5
5
|
[](https://www.npmjs.com/package/@davesheffer/hunch)
|
|
6
6
|
[](https://github.com/davesheffer/hunch)
|
|
7
7
|
[](LICENSE)
|
|
8
8
|
|
|
9
|
-
Every
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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,15 +45,16 @@ 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
59
|
import { formatContext, formatSearchHit, formatStructure } from "../core/format.js";
|
|
59
60
|
import { isStateKind, renderStateLine, stateSupplements } from "../core/stateDelivery.js";
|
|
@@ -282,6 +283,8 @@ program
|
|
|
282
283
|
console.log(` ✓ post-commit hook ${h.action} (learning loop)${syncToOverlay ? " — syncs to the shared overlay" : ""}${opts.autoCommit ? " — auto-commit on" : ""}`);
|
|
283
284
|
const m = installMergeDriver(root, inv.shell);
|
|
284
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)`);
|
|
285
288
|
// Auto-install the pre-commit guard by default (advisory: flags invariants
|
|
286
289
|
// touched directly OR via blast radius, never blocks). Opt out with
|
|
287
290
|
// --no-enforce; --enforce-strict makes blocking near/direct hits fail the commit.
|
|
@@ -5468,6 +5471,79 @@ program
|
|
|
5468
5471
|
store.close();
|
|
5469
5472
|
}
|
|
5470
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
|
+
});
|
|
5471
5547
|
// ---- findings (the open-observations ledger) --------------------------------
|
|
5472
5548
|
program
|
|
5473
5549
|
.command("findings")
|
|
@@ -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
|
|
@@ -113,6 +113,10 @@ export const WriteRequestSchema = z.object({
|
|
|
113
113
|
idempotency_key: z.string().min(8).max(256),
|
|
114
114
|
expected_version: z.union([z.string().max(256), z.number().int().nonnegative()]).nullable().default(null),
|
|
115
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(),
|
|
116
120
|
}).strict();
|
|
117
121
|
export const DURABILITY = ["pushed", "committed", "local"];
|
|
118
122
|
export const WriteResultSchema = z.object({
|
|
@@ -230,6 +234,7 @@ export const STATE_INVARIANTS = [
|
|
|
230
234
|
{ id: "one-live-decision-per-topic", statement: "A second live decision on a topic is refused with the incumbent named; supersession is explicit." },
|
|
231
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." },
|
|
232
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." },
|
|
233
238
|
];
|
|
234
239
|
const grantKey = (scope) => scopePath(scope);
|
|
235
240
|
/** authorization-before-retrieval, checked on the way OUT as well: nothing in a read response
|
|
@@ -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
|
-
|
|
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,
|
|
@@ -97,4 +97,45 @@ export function installPreCommitHook(root, invocation, strict = false) {
|
|
|
97
97
|
chmodSync(hookPath, 0o755);
|
|
98
98
|
return { path: hookPath, action: "appended" };
|
|
99
99
|
}
|
|
100
|
+
const MERGE_MARK = "# >>> hunch post-merge >>>";
|
|
101
|
+
const MERGE_END = "# <<< hunch post-merge <<<";
|
|
102
|
+
/** Install a post-merge hook that re-syncs the committed grounding docs when a merge
|
|
103
|
+
* brought memory in behind them (fnd_c402046ac7). Two branches that each captured a
|
|
104
|
+
* record regenerate the same "N+1" counts line; git merges identical lines silently
|
|
105
|
+
* and the doc ends up one behind the store. The hook regenerates the existing docs
|
|
106
|
+
* from the PUBLIC store right after a local merge/pull that touched .hunch/, so the
|
|
107
|
+
* next commit carries them. Foreground (it rewrites five files), loop-guarded via
|
|
108
|
+
* HUNCH_SYNC, and it can never fail the merge. Preserves any existing hook. */
|
|
109
|
+
export function installPostMergeHook(root, invocation) {
|
|
110
|
+
const dir = hooksDir(root);
|
|
111
|
+
const abs = isAbsolute(dir) ? dir : join(root, dir);
|
|
112
|
+
mkdirSync(abs, { recursive: true });
|
|
113
|
+
const hookPath = join(abs, "post-merge");
|
|
114
|
+
const blk = [
|
|
115
|
+
MERGE_MARK,
|
|
116
|
+
'if [ -z "$HUNCH_SYNC" ]; then',
|
|
117
|
+
" if ! git diff --quiet ORIG_HEAD HEAD -- .hunch 2>/dev/null; then",
|
|
118
|
+
` ( HUNCH_SYNC=1 ${invocation} grounding --refresh 2>/dev/null || true )`,
|
|
119
|
+
" fi",
|
|
120
|
+
"fi",
|
|
121
|
+
MERGE_END,
|
|
122
|
+
].join("\n");
|
|
123
|
+
if (!existsSync(hookPath)) {
|
|
124
|
+
writeFileSync(hookPath, `#!/bin/sh\n${blk}\n`);
|
|
125
|
+
chmodSync(hookPath, 0o755);
|
|
126
|
+
return { path: hookPath, action: "created" };
|
|
127
|
+
}
|
|
128
|
+
const cur = readFileSync(hookPath, "utf8");
|
|
129
|
+
if (cur.includes(MERGE_MARK)) {
|
|
130
|
+
const updated = cur.replace(new RegExp(`${escapeRe(MERGE_MARK)}[\\s\\S]*?${escapeRe(MERGE_END)}`), blk);
|
|
131
|
+
if (updated === cur)
|
|
132
|
+
return { path: hookPath, action: "unchanged" };
|
|
133
|
+
writeFileSync(hookPath, updated);
|
|
134
|
+
chmodSync(hookPath, 0o755);
|
|
135
|
+
return { path: hookPath, action: "updated" };
|
|
136
|
+
}
|
|
137
|
+
writeFileSync(hookPath, cur.endsWith("\n") ? `${cur}${blk}\n` : `${cur}\n${blk}\n`);
|
|
138
|
+
chmodSync(hookPath, 0o755);
|
|
139
|
+
return { path: hookPath, action: "appended" };
|
|
140
|
+
}
|
|
100
141
|
//# sourceMappingURL=hooks.js.map
|
|
@@ -360,6 +360,14 @@ export function regenerateGrounding(root, store) {
|
|
|
360
360
|
writeWindsurfRule(root, store),
|
|
361
361
|
];
|
|
362
362
|
}
|
|
363
|
+
/** The five grounding docs, repo-relative (POSIX separators, as git prints them). */
|
|
364
|
+
export const GROUNDING_DOC_PATHS = Object.freeze([
|
|
365
|
+
"CLAUDE.md",
|
|
366
|
+
"AGENTS.md",
|
|
367
|
+
".github/copilot-instructions.md",
|
|
368
|
+
".cursor/rules/hunch.mdc",
|
|
369
|
+
".windsurf/rules/hunch.md",
|
|
370
|
+
]);
|
|
363
371
|
function groundingTargets(root, store) {
|
|
364
372
|
return [
|
|
365
373
|
["CLAUDE.md", () => updateClaudeMd(root, store)],
|
package/dist/mcp/server.js
CHANGED
|
@@ -13,8 +13,8 @@ import { z } from "zod";
|
|
|
13
13
|
import { hunchPaths, findRoot, toPosixTarget } from "../core/paths.js";
|
|
14
14
|
import { canonicalRootPath, resolveActiveRoot } from "./roots.js";
|
|
15
15
|
import { HunchStore } from "../store/hunchStore.js";
|
|
16
|
-
import { StateRefusal, SubscribeResponseSchema, capabilities, readState, recordsState, subscribeState, writeState } from "../store/stateBinding.js";
|
|
17
|
-
import { ReadRequestSchema, ReadResponseSchema, WriteRequestSchema, WriteResultSchema, SubscribeRequestSchema, RecordsRequestSchema, RecordsResponseSchema, STATE_READ_VERSION, STATE_WRITE_VERSION, STATE_SUBSCRIBE_VERSION, STATE_RECORDS_VERSION } from "../core/stateContract.js";
|
|
16
|
+
import { StateRefusal, SubscribeResponseSchema, capabilities, partitionOf, readState, recordsState, subscribeState, writeState } from "../store/stateBinding.js";
|
|
17
|
+
import { ReadRequestSchema, ReadResponseSchema, WriteRequestSchema, WriteResultSchema, SubscribeRequestSchema, RecordsRequestSchema, RecordsResponseSchema, STATE_READ_VERSION, STATE_WRITE_VERSION, STATE_SUBSCRIBE_VERSION, STATE_RECORDS_VERSION, stateHash } from "../core/stateContract.js";
|
|
18
18
|
import { selectEmbedder } from "../store/embedder.js";
|
|
19
19
|
import { decisionId, findingId } from "../core/ids.js";
|
|
20
20
|
import { buildCorrectionConstraint } from "../core/correction.js";
|
|
@@ -931,7 +931,9 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
931
931
|
return {
|
|
932
932
|
content: [{
|
|
933
933
|
type: "text",
|
|
934
|
-
text: `${proof.proof_id} — ${proof.verdict.toUpperCase()}; ${proof.changed_file_count} exact file delta(s), ${proof.blast_radius_count} dependent path(s), ${proof.omissions.length + proof.unknowns.length} explicit gap(s); sealed ${proof.content_hash}. Evidence only; no execution or merge authority
|
|
934
|
+
text: `${proof.proof_id} — ${proof.verdict.toUpperCase()}; ${proof.changed_file_count} exact file delta(s), ${proof.blast_radius_count} dependent path(s), ${proof.omissions.length + proof.unknowns.length} explicit gap(s); sealed ${proof.content_hash}. Evidence only; no execution or merge authority.`
|
|
935
|
+
// The chain: a `shipped` receipt rests on this proof as a credential-free pointer.
|
|
936
|
+
+ `\n\nrests_on ref (for a nuryel receipt that shipped this change): ${JSON.stringify({ kind: "external", ref: { system: "hunch", object_type: "change_proof", object_key: proof.proof_id, content_hash: proof.content_hash, observed_at: new Date().toISOString().replace(/\.\d{3}Z$/, "Z") } })}`,
|
|
935
937
|
}],
|
|
936
938
|
structuredContent: proof,
|
|
937
939
|
};
|
|
@@ -1532,7 +1534,13 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
1532
1534
|
? ` [PRIVATE overlay — not committed to this repo]${flushed}`
|
|
1533
1535
|
: home === "private" ? ` [SHARED store — one source of truth for the whole team]${flushed}` : flushed;
|
|
1534
1536
|
const dest = destinationNote(resolveDestRoot(home, store, root));
|
|
1535
|
-
|
|
1537
|
+
// The chain (nuryel.state/1): a `shipped` receipt in an organization drawer rests on
|
|
1538
|
+
// this decision by id + the hash ON FILE + this repository's partition. Hand the ref
|
|
1539
|
+
// over now so the agent never rests on a pre-store hash or re-derives the scope.
|
|
1540
|
+
const onFile = store.getRec("decisions", id) ?? rec;
|
|
1541
|
+
const restsOn = JSON.stringify({ kind: "record", id, record_hash: stateHash(onFile), scope: partitionOf(store) });
|
|
1542
|
+
const chainNote = `\n\nrests_on ref (for a nuryel receipt that implements this decision): ${restsOn}`;
|
|
1543
|
+
return ok(`Recorded decision ${id}: "${rec.title}" (status ${rec.status}, ${source}).${where}${dest}${supNote}${note}${chainNote}${captureNote}${quality}`);
|
|
1536
1544
|
}
|
|
1537
1545
|
catch (e) {
|
|
1538
1546
|
return err(`Failed to record decision: ${e.message}`);
|
|
@@ -1728,10 +1736,24 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
1728
1736
|
if (ref.facet === "derived")
|
|
1729
1737
|
return `- ${label} derived ${ref.id} · computed ${g("computed_at")} · ${r.dependencies?.length ?? 0} dependencies\n ${g("content").slice(0, 1200)}`;
|
|
1730
1738
|
if (ref.facet === "commitments")
|
|
1731
|
-
return `- ${label} commitment ${ref.id} · ${g("status")} · due ${g("due")} · owner ${g("owner")}: ${g("title")}`;
|
|
1739
|
+
return `- ${label} commitment ${ref.id} · ${g("status")} · due ${g("due")} · owner ${g("owner")}: ${g("title")}${r.closed_by ? ` · closed by ${g("closed_by")}` : ""}`;
|
|
1732
1740
|
if (ref.facet === "receipts") {
|
|
1733
1741
|
const t = (r.target ?? {});
|
|
1734
|
-
|
|
1742
|
+
// The chain: what the action rested on, one pointer per line, so a reader follows
|
|
1743
|
+
// incident → decision → change proof → closure without a second call.
|
|
1744
|
+
const rests = (Array.isArray(r.rests_on) ? r.rests_on : []);
|
|
1745
|
+
const restLines = rests.map((d) => {
|
|
1746
|
+
if (d.kind === "record") {
|
|
1747
|
+
const sc = d.scope;
|
|
1748
|
+
return `\n rests on record ${String(d.id)}${sc ? ` in ${String(sc.kind)}/${String(sc.id)}` : ""}`;
|
|
1749
|
+
}
|
|
1750
|
+
if (d.kind === "external") {
|
|
1751
|
+
const x = (d.ref ?? {});
|
|
1752
|
+
return `\n rests on ${String(x.system ?? "")} ${String(x.object_type ?? "")}:${String(x.object_key ?? "")}`;
|
|
1753
|
+
}
|
|
1754
|
+
return `\n rests on ${String(d.kind)} ${String(d.name ?? "")}`;
|
|
1755
|
+
}).join("");
|
|
1756
|
+
return `- ${label} receipt ${ref.id} · ${g("action_kind")} on ${String(t.system ?? "")} ${String(t.object_type ?? "")}:${String(t.object_key ?? "")} · ${g("state")} at ${g("occurred_at")} by ${g("actor")}${restLines}`;
|
|
1735
1757
|
}
|
|
1736
1758
|
if (ref.facet === "decisions")
|
|
1737
1759
|
return `- ${label} decision ${ref.id} · ${g("status")}: ${g("title")}`;
|
|
@@ -19,7 +19,12 @@ export const LEDGER_SCHEMA_VERSION = "nuryel.ledger/1";
|
|
|
19
19
|
export const CHANGES_DIR = "changes";
|
|
20
20
|
const IdempotencyEntrySchema = z.object({
|
|
21
21
|
record_id: z.string().min(1),
|
|
22
|
+
/** Hash of the record ON FILE (what reads, events and refs see). */
|
|
22
23
|
record_hash: z.string(),
|
|
24
|
+
/** Hash of the normalized payload as the writer sent it (additive). The store may enrich a
|
|
25
|
+
* record on put (a private-mode decision gains `valid_from`), so a replay is recognized by
|
|
26
|
+
* the payload it re-sends, while `record_hash` stays the truth a reader can verify. */
|
|
27
|
+
payload_hash: z.string().optional(),
|
|
23
28
|
facet: z.string(),
|
|
24
29
|
seq: z.number().int().nonnegative(),
|
|
25
30
|
at: z.string(),
|
|
@@ -179,8 +179,10 @@ export function readState(store, input) {
|
|
|
179
179
|
const scope = admit("receipts", r);
|
|
180
180
|
if (!scope)
|
|
181
181
|
continue;
|
|
182
|
-
if (r.state === "succeeded" || r.state === "verified")
|
|
182
|
+
if (r.state === "succeeded" || r.state === "verified") {
|
|
183
183
|
done.push(keep("receipts", r, scope));
|
|
184
|
+
dependsOn.push(...(r.rests_on ?? []));
|
|
185
|
+
}
|
|
184
186
|
if (r.invalidates.includes(subject))
|
|
185
187
|
invalidatedBy.add(r.id);
|
|
186
188
|
}
|
|
@@ -193,6 +195,10 @@ export function readState(store, input) {
|
|
|
193
195
|
continue;
|
|
194
196
|
if ((c.status === "open" || c.status === "waiting") && c.valid_to == null)
|
|
195
197
|
inForce.push(keep("commitments", c, scope));
|
|
198
|
+
// A commitment fulfilled by a receipt is part of what HAPPENED for the subject: it
|
|
199
|
+
// leaves in_force and joins done beside the receipt that closed it (the chain's last link).
|
|
200
|
+
else if (c.status === "done" && c.closed_by)
|
|
201
|
+
done.push(keep("commitments", c, scope));
|
|
196
202
|
}
|
|
197
203
|
if (facets.has("derived"))
|
|
198
204
|
for (const d of store.recs("derived")) {
|
|
@@ -330,6 +336,77 @@ function subjectOf(facet, record) {
|
|
|
330
336
|
default: return undefined;
|
|
331
337
|
}
|
|
332
338
|
}
|
|
339
|
+
/** Which facet a record id belongs to, from its prefix; `null` for a kind-qualified entity id
|
|
340
|
+
* or an unknown shape (those are looked up across every facet). */
|
|
341
|
+
function facetOfId(id) {
|
|
342
|
+
const prefix = /^([a-z]+)_/.exec(id)?.[1];
|
|
343
|
+
switch (prefix) {
|
|
344
|
+
case "dec": return "decisions";
|
|
345
|
+
case "con": return "constraints";
|
|
346
|
+
case "bug": return "bugs";
|
|
347
|
+
case "fnd": return "findings";
|
|
348
|
+
case "nrc": return "receipts";
|
|
349
|
+
case "ncm": return "commitments";
|
|
350
|
+
case "nds": return "derived";
|
|
351
|
+
case "edge": return "relationships";
|
|
352
|
+
default: return null;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
/** Find a record by id in this store, with the facet it lives in. */
|
|
356
|
+
function findRecord(store, id) {
|
|
357
|
+
const facets = facetOfId(id) ? [facetOfId(id)] : [...STATE_FACETS];
|
|
358
|
+
for (const facet of facets) {
|
|
359
|
+
const record = store.getRec(facet, id);
|
|
360
|
+
if (record)
|
|
361
|
+
return { facet, record };
|
|
362
|
+
}
|
|
363
|
+
return null;
|
|
364
|
+
}
|
|
365
|
+
/** A receipt's `rests_on` record refs: one in a partition this store holds must exist there
|
|
366
|
+
* with the hash the writer saw (a stale hash means the decision moved — re-read); one in a
|
|
367
|
+
* partition the store does not hold is a pointer for the reader to resolve. Grants first: a
|
|
368
|
+
* ref into a partition the principal is not granted is refused by scope, never by content. */
|
|
369
|
+
function assertRestsOn(store, principal, scope, restsOn) {
|
|
370
|
+
const repo = partitionOf(store);
|
|
371
|
+
for (const dep of restsOn) {
|
|
372
|
+
if (dep.kind !== "record")
|
|
373
|
+
continue;
|
|
374
|
+
const refScope = dep.scope ?? scope;
|
|
375
|
+
if (!granted(principal, refScope))
|
|
376
|
+
throw new StateRefusal("outside-grants", `rests_on ${dep.id} points into ${scopePath(refScope)}, which is outside the principal's grants`);
|
|
377
|
+
const found = findRecord(store, dep.id);
|
|
378
|
+
if (!found) {
|
|
379
|
+
const held = scopePath(refScope) === scopePath(scope) || scopePath(refScope) === scopePath(repo) || (store.hasPrivate && refScope.kind !== "repository");
|
|
380
|
+
if (held)
|
|
381
|
+
throw new StateRefusal("conflict", `rests_on ${dep.id} is not on record in ${scopePath(refScope)}: a receipt rests on state that exists; write or re-read it first`, { incumbent_id: dep.id, reason: "rests_on target absent" });
|
|
382
|
+
continue; // a partition this store does not hold: a pointer, resolved by the reader
|
|
383
|
+
}
|
|
384
|
+
const actualScope = recordScope(found.record, repo);
|
|
385
|
+
if (scopePath(actualScope) !== scopePath(refScope))
|
|
386
|
+
throw new StateRefusal("conflict", `rests_on ${dep.id} lives in ${scopePath(actualScope)}, not ${scopePath(refScope)}`, { incumbent_id: dep.id, reason: "rests_on scope mismatch" });
|
|
387
|
+
const actualHash = stateHash(found.record);
|
|
388
|
+
if (actualHash !== dep.record_hash)
|
|
389
|
+
throw new StateRefusal("conflict", `rests_on ${dep.id} has moved: the record on file hashes ${actualHash}, not ${dep.record_hash} — re-read it and rest on what is current`, { incumbent_id: dep.id, reason: "rests_on hash mismatch" });
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
/** A commitment closed by a receipt: `closed_by` must name a succeeded/verified receipt the
|
|
393
|
+
* principal can see, and the status must be done — a closure is a fact that happened, never
|
|
394
|
+
* an opinion. Returns the receipt id when the closure is well-formed. */
|
|
395
|
+
function assertClosedBy(store, principal, commitment) {
|
|
396
|
+
if (!commitment.closed_by)
|
|
397
|
+
return null;
|
|
398
|
+
if (commitment.status !== "done")
|
|
399
|
+
throw new StateRefusal("malformed", `closed_by names a receipt but status is ${commitment.status}: a commitment closed by a receipt is done`);
|
|
400
|
+
const receipt = store.getRec("receipts", commitment.closed_by);
|
|
401
|
+
const scope = receipt ? recordScope(receipt, partitionOf(store)) : null;
|
|
402
|
+
if (!receipt || !scope || !granted(principal, scope)) {
|
|
403
|
+
throw new StateRefusal("conflict", `closed_by ${commitment.closed_by} is not a receipt on record within the principal's grants: a commitment is closed by an action that happened — write the receipt first, then close with its id`, { incumbent_id: commitment.closed_by, reason: "closed_by receipt absent" });
|
|
404
|
+
}
|
|
405
|
+
if (receipt.state !== "succeeded" && receipt.state !== "verified") {
|
|
406
|
+
throw new StateRefusal("conflict", `closed_by ${commitment.closed_by} is ${receipt.state}, not succeeded or verified: only an action that happened closes a commitment`, { incumbent_id: commitment.closed_by, reason: `closed_by receipt ${receipt.state}` });
|
|
407
|
+
}
|
|
408
|
+
return commitment.closed_by;
|
|
409
|
+
}
|
|
333
410
|
/** Top-level fields whose canonical hash differs between two records, sorted. */
|
|
334
411
|
function differingFields(a, b) {
|
|
335
412
|
const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
|
|
@@ -418,15 +495,22 @@ export function writeState(store, input, opts = {}) {
|
|
|
418
495
|
throw new StateRefusal("unsupported", `facet ${facet} is not a store kind`);
|
|
419
496
|
const record = normalizeRecord(facet, request.scope, request.record, request.principal);
|
|
420
497
|
const id = record.id;
|
|
498
|
+
/** The normalized PAYLOAD hash: what idempotency recognizes on a re-send. */
|
|
421
499
|
const hash = stateHash(record);
|
|
422
500
|
const ledger = readLedger(hunchDir, request.scope);
|
|
423
501
|
const durability = () => opts.flush?.(isPrivate, `nuryel: write ${id}`) ?? "local";
|
|
424
|
-
|
|
502
|
+
/** The result reports the record ON FILE and its hash — the store may enrich a record on put
|
|
503
|
+
* (a private-mode decision gains `valid_from`), and a writer that goes on to rest a receipt
|
|
504
|
+
* on this record must hold the hash a reader will verify, never a pre-store one. */
|
|
505
|
+
const result = (outcome, conflict = null, rid = id) => {
|
|
506
|
+
const onFile = store.getRec(facet, rid) ?? record;
|
|
507
|
+
return WriteResultSchema.parse({ schema: STATE_WRITE_VERSION, record_id: rid, record_hash: stateHash(onFile), durability: durability(), outcome, conflict, record: onFile });
|
|
508
|
+
};
|
|
425
509
|
// Idempotency: the same key replays the original; the same key with a different payload
|
|
426
510
|
// is a refusal, never a second record.
|
|
427
511
|
const seen = ledger.idempotency[request.idempotency_key];
|
|
428
512
|
if (seen) {
|
|
429
|
-
if (seen.record_hash === hash
|
|
513
|
+
if (seen.record_id === id && (seen.record_hash === hash || seen.payload_hash === hash))
|
|
430
514
|
return result("replayed");
|
|
431
515
|
// Say WHAT differs and what to do: a stable key with a varying payload (a timestamp, new
|
|
432
516
|
// wording) is the trap every writer falls into once; the refusal must teach the way out.
|
|
@@ -437,7 +521,7 @@ export function writeState(store, input, opts = {}) {
|
|
|
437
521
|
}
|
|
438
522
|
const existing = store.recsInHome(facet, home).find((r) => r.id === id);
|
|
439
523
|
if (existing && stateHash(existing) === hash) {
|
|
440
|
-
appendChanges(hunchDir, request.scope, [], { key: request.idempotency_key, entry: { record_id: id, record_hash: hash, facet } }, now);
|
|
524
|
+
appendChanges(hunchDir, request.scope, [], { key: request.idempotency_key, entry: { record_id: id, record_hash: hash, payload_hash: hash, facet } }, now);
|
|
441
525
|
return result("replayed");
|
|
442
526
|
}
|
|
443
527
|
if (existing && request.expected_version !== null) {
|
|
@@ -482,9 +566,19 @@ export function writeState(store, input, opts = {}) {
|
|
|
482
566
|
supersedes = null; // already closed by this record: nothing to close again, no second "superseded" event
|
|
483
567
|
}
|
|
484
568
|
}
|
|
569
|
+
// The chain (Gate 4): a receipt names what it rested on, a closure names the receipt.
|
|
570
|
+
// Both are checked against the drawer, grants first, before anything lands.
|
|
571
|
+
if (facet === "receipts")
|
|
572
|
+
assertRestsOn(store, request.principal, request.scope, record.rests_on ?? []);
|
|
573
|
+
const closedBy = facet === "commitments" ? assertClosedBy(store, request.principal, record) : null;
|
|
485
574
|
store.putCapture(facet, record, isPrivate);
|
|
575
|
+
/** What is on file now — the hash every event, ref and result carries. */
|
|
576
|
+
const onFileHash = stateHash(store.getRec(facet, id) ?? record);
|
|
486
577
|
const changes = [];
|
|
487
|
-
const cause = { kind: "write", principal: request.principal.id };
|
|
578
|
+
const cause = closedBy ? { kind: "receipt", receipt_id: closedBy } : request.cause ?? { kind: "write", principal: request.principal.id };
|
|
579
|
+
// A current derived statement written back as stale is an INVALIDATION, not an update: the
|
|
580
|
+
// ledger says so, and names the external pointer that moved when the writer gives one.
|
|
581
|
+
const invalidated = facet === "derived" && !!existing && existing.state === "current" && record.state === "stale";
|
|
488
582
|
const invalidates = facet === "receipts" ? record.invalidates : [];
|
|
489
583
|
const subject = subjectOf(facet, record);
|
|
490
584
|
if (supersedes) {
|
|
@@ -494,8 +588,8 @@ export function writeState(store, input, opts = {}) {
|
|
|
494
588
|
changes.push({ facet, record_id: supersedes, record_hash: stateHash(old), change: "superseded", subject: subjectOf(facet, old), invalidates: [], cause });
|
|
495
589
|
}
|
|
496
590
|
}
|
|
497
|
-
changes.push({ facet, record_id: id, record_hash:
|
|
498
|
-
appendChanges(hunchDir, request.scope, changes, { key: request.idempotency_key, entry: { record_id: id, record_hash: hash, facet } }, now);
|
|
591
|
+
changes.push({ facet, record_id: id, record_hash: onFileHash, change: invalidated ? "invalidated" : existing ? "updated" : "created", subject, invalidates: invalidated && subject ? [subject] : invalidates, cause });
|
|
592
|
+
appendChanges(hunchDir, request.scope, changes, { key: request.idempotency_key, entry: { record_id: id, record_hash: onFileHash, payload_hash: hash, facet } }, now);
|
|
499
593
|
store.reindex();
|
|
500
594
|
return result(supersedes ? "superseded" : existing ? "updated" : "created");
|
|
501
595
|
}
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@davesheffer/hunch",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.29.0",
|
|
4
4
|
"mcpName": "io.github.davesheffer/hunch",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Dave Sheffer <dave.sheffer1@gmail.com>",
|
|
7
|
-
"description": "
|
|
7
|
+
"description": "Deterministic state for organizations that run many probabilistic agents: decisions, receipts, commitments, constraints and bug lineage held in git, refused when they contradict, and delivered to every MCP assistant before it answers or edits code.",
|
|
8
8
|
"homepage": "https://www.hunchmemory.com",
|
|
9
9
|
"repository": {
|
|
10
10
|
"type": "git",
|
package/server.json
CHANGED
|
@@ -7,13 +7,13 @@
|
|
|
7
7
|
"source": "github"
|
|
8
8
|
},
|
|
9
9
|
"websiteUrl": "https://www.hunchmemory.com",
|
|
10
|
-
"version": "1.
|
|
10
|
+
"version": "1.29.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.
|
|
16
|
+
"version": "1.29.0",
|
|
17
17
|
"runtimeHint": "npx",
|
|
18
18
|
"packageArguments": [
|
|
19
19
|
{
|