@davesheffer/hunch 1.29.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 +8 -0
- package/dist/cli/index.js +43 -6
- package/dist/cli/reviewMemory.js +32 -0
- package/dist/cli/serve.js +44 -0
- package/dist/core/reviewMemory.js +100 -0
- package/dist/core/stateContract.js +9 -0
- package/dist/core/stateRecords.js +30 -0
- package/dist/extractors/diff.js +26 -21
- package/dist/extractors/git.js +4 -0
- package/dist/extractors/languages.js +8 -0
- package/dist/mcp/server.js +51 -43
- package/dist/store/replay.js +153 -0
- package/dist/store/stateBinding.js +160 -12
- package/dist/synthesis/provider.js +6 -2
- package/dist/synthesis/synthesize.js +36 -15
- package/package.json +1 -1
- package/server.json +2 -2
package/README.md
CHANGED
|
@@ -207,6 +207,12 @@ Hunch Memory service into Hunch.
|
|
|
207
207
|
|
|
208
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
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
|
+
|
|
210
216
|
Read [Deterministic organizational state](docs/deterministic-state.md), the [roadmap](ROADMAP.md) and the dated [competitive landscape](docs/competitive-landscape.md).
|
|
211
217
|
|
|
212
218
|
### Naming
|
|
@@ -268,9 +274,11 @@ See the [changelog](CHANGELOG.md) for release detail and the [roadmap](ROADMAP.m
|
|
|
268
274
|
|
|
269
275
|
- [Full documentation](https://www.hunchmemory.com/docs)
|
|
270
276
|
- [Copy-paste cookbook](https://www.hunchmemory.com/cookbook)
|
|
277
|
+
- [Turn PR review threads into scoped review rules](docs/review-memory.md)
|
|
271
278
|
- [Deterministic organizational state](docs/deterministic-state.md)
|
|
272
279
|
- [Project DNA](docs/project-dna.md)
|
|
273
280
|
- [Native change proof](docs/change-proof.md)
|
|
281
|
+
- [The autonomy ladder](docs/autonomy-ladder.md)
|
|
274
282
|
- [Engineering Landscape Graph](docs/engineering-landscape.md)
|
|
275
283
|
- [Hunch roadmap](ROADMAP.md)
|
|
276
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";
|
|
@@ -108,6 +109,10 @@ import { draftTripwires, knownRepoDeps } from "../synthesis/tripwires.js";
|
|
|
108
109
|
import { constraintId } from "../core/ids.js";
|
|
109
110
|
import { readManifest, writeManifest, SCHEMA_VERSION } from "../core/migrate.js";
|
|
110
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";
|
|
111
116
|
import { movePublicMemoryToPrivate } from "../store/privateMigrate.js";
|
|
112
117
|
import { ENTITY_KINDS } from "../core/types.js";
|
|
113
118
|
import { planCompaction } from "../store/compact.js";
|
|
@@ -118,6 +123,28 @@ program.name("hunch").description("Hunch — engineering memory and a determinis
|
|
|
118
123
|
registerIntegrationCommands(program);
|
|
119
124
|
registerServeCommands(program);
|
|
120
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
|
+
});
|
|
121
148
|
let openStore = null;
|
|
122
149
|
function openTeamStore(root, opts = {}) {
|
|
123
150
|
// A committed team.json is an explicit declaration that this checkout belongs
|
|
@@ -469,7 +496,7 @@ program
|
|
|
469
496
|
pumpMemoryHome(store, root, home, `hunch: backfill ${written} decision(s)`);
|
|
470
497
|
// Honest tally of where the tokens went: trivial commits are seeded by the
|
|
471
498
|
// free deterministic heuristic, only substantive ones spend the LLM.
|
|
472
|
-
console.log(`Done: ${written} decision(s) seeded (${llm} via LLM, ${heuristic} heuristic), ${skipped} skipped (trivial/
|
|
499
|
+
console.log(`Done: ${written} decision(s) seeded (${llm} via LLM, ${heuristic} heuristic), ${skipped} skipped (trivial/not substantive/already-captured).`);
|
|
473
500
|
store.close();
|
|
474
501
|
});
|
|
475
502
|
// ---- sync (post-commit hook) ----------------------------------------------
|
|
@@ -5448,23 +5475,33 @@ program
|
|
|
5448
5475
|
// ---- drift (doc≠graph detector; advisory + CI-gateable) -------------------
|
|
5449
5476
|
program
|
|
5450
5477
|
.command("drift")
|
|
5451
|
-
.description("Detect memory drift: dead refs, dangling supersedes, stale 'proposed' docs, doc≠graph anchor-stale (a file still anchored to a superseded decision),
|
|
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.")
|
|
5452
5479
|
.action(() => {
|
|
5453
5480
|
const { store, root } = storeFor();
|
|
5454
5481
|
try {
|
|
5455
5482
|
const { findings } = computeDrift(store, root);
|
|
5456
5483
|
const collisions = topicCollisions(store.recs("decisions"));
|
|
5457
|
-
|
|
5458
|
-
|
|
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.` : ""}`);
|
|
5459
5492
|
return;
|
|
5460
5493
|
}
|
|
5461
5494
|
for (const f of findings.slice(0, 50))
|
|
5462
5495
|
console.log(`· [${f.kind}] ${f.id} — ${f.detail}`);
|
|
5463
5496
|
for (const [topic, decs] of collisions)
|
|
5464
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}`);
|
|
5465
5502
|
const anchor = findings.filter((f) => f.kind === "anchor-stale" || f.kind === "doc-anchor-stale").length;
|
|
5466
|
-
console.log(`\n${findings.length} finding(s)${anchor ? `, ${anchor} doc≠graph (anchor-stale)` : ""}${collisions.size ? `, ${collisions.size} topic-collision(s)` : ""}.`);
|
|
5467
|
-
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)
|
|
5468
5505
|
process.exitCode = 1;
|
|
5469
5506
|
}
|
|
5470
5507
|
finally {
|
|
@@ -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,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";
|
|
@@ -234,9 +235,17 @@ export const STATE_INVARIANTS = [
|
|
|
234
235
|
{ id: "one-live-decision-per-topic", statement: "A second live decision on a topic is refused with the incumbent named; supersession is explicit." },
|
|
235
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." },
|
|
236
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." },
|
|
237
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." },
|
|
238
241
|
];
|
|
239
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
|
+
}
|
|
240
249
|
/** authorization-before-retrieval, checked on the way OUT as well: nothing in a read response
|
|
241
250
|
* may sit outside the principal's grants. Bindings must also filter on the way in. */
|
|
242
251
|
export function assertReadWithinGrants(principal, response) {
|
|
@@ -44,6 +44,24 @@ export const ExternalRefSchema = z.object({
|
|
|
44
44
|
observed_at: z.string().regex(ISO),
|
|
45
45
|
locator: credentialFree("external locator").optional(),
|
|
46
46
|
}).strict();
|
|
47
|
+
/** The external system's own key, canonicalized so two writers that copied it from the same
|
|
48
|
+
* system agree byte for byte: Unicode NFC, trimmed, internal whitespace collapsed. Case is
|
|
49
|
+
* preserved — the key belongs to the external system, and folding it could merge two of its
|
|
50
|
+
* records. No other guessing: identity here is explicit refs, never similarity. */
|
|
51
|
+
export function canonicalObjectKey(key) {
|
|
52
|
+
return key.normalize("NFC").trim().replace(/\s+/g, " ");
|
|
53
|
+
}
|
|
54
|
+
/** The identity of an external record across writers: system, type and canonical key. Two
|
|
55
|
+
* entities in one partition that carry the same external key are the same thing. */
|
|
56
|
+
export function externalKey(ref) {
|
|
57
|
+
return `${ref.system}/${ref.object_type}/${canonicalObjectKey(ref.object_key)}`;
|
|
58
|
+
}
|
|
59
|
+
/** The subject an external record is known by, the convention the read verb already uses for
|
|
60
|
+
* receipts (`event:26904`): the object type and the canonical key. When an entity in the
|
|
61
|
+
* partition carries the ref, that entity's id is the subject and this form resolves to it. */
|
|
62
|
+
export function subjectOfRef(ref) {
|
|
63
|
+
return `${ref.object_type}:${canonicalObjectKey(ref.object_key)}`;
|
|
64
|
+
}
|
|
47
65
|
/** What a derived statement rests on. Exactly what a currentness check re-validates. */
|
|
48
66
|
export const DependencyRefSchema = z.discriminatedUnion("kind", [
|
|
49
67
|
/** `scope` (additive) points into ANOTHER partition — the repository decision an
|
|
@@ -124,10 +142,22 @@ export const ExternalEntitySchema = z.object({
|
|
|
124
142
|
refs: z.array(ExternalRefSchema).min(1).max(64),
|
|
125
143
|
attributes: z.record(z.string().max(128), AttributeValue).default({}),
|
|
126
144
|
lifecycle: z.enum(["active", "deprecated", "retired"]).default("active"),
|
|
145
|
+
/** Audited merge (additive): this entity was folded into another. Set only on a retired
|
|
146
|
+
* entity; the survivor must be an active entity on record. Nothing under the retired id is
|
|
147
|
+
* rewritten — reads resolve the old id and its keys to the survivor, and the ledger holds
|
|
148
|
+
* the `retired` event with its provenance. A split is the explicit reverse: retire or
|
|
149
|
+
* re-key the survivor, then write the entity active again without `merged_into`. */
|
|
150
|
+
merged_into: z.string().min(3).max(2048).optional(),
|
|
127
151
|
provenance: ProvenanceSchema,
|
|
128
152
|
created_at: z.string().regex(ISO),
|
|
129
153
|
updated_at: z.string().regex(ISO),
|
|
130
154
|
}).strict().superRefine((entity, ctx) => {
|
|
155
|
+
if (entity.merged_into !== undefined) {
|
|
156
|
+
if (entity.lifecycle !== "retired")
|
|
157
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["merged_into"], message: "a merged entity is retired; the survivor stays active" });
|
|
158
|
+
if (entity.merged_into === entity.id)
|
|
159
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["merged_into"], message: "an entity cannot merge into itself" });
|
|
160
|
+
}
|
|
131
161
|
const prefix = `${entity.kind}:`;
|
|
132
162
|
const key = entity.id.startsWith(prefix) ? entity.id.slice(prefix.length) : "";
|
|
133
163
|
if (!key.trim() || entity.id !== resourceId(entity.kind, key)) {
|
package/dist/extractors/diff.js
CHANGED
|
@@ -26,7 +26,7 @@ const DECL_PATTERNS = [
|
|
|
26
26
|
// `class Foo(Bar):` header too, and — since declOf() returns on the first match —
|
|
27
27
|
// always wins for Python class lines before any Python-specific pattern would run.
|
|
28
28
|
];
|
|
29
|
-
import { languageFor } from "./languages.js";
|
|
29
|
+
import { languageFor, isSubstantive } from "./languages.js";
|
|
30
30
|
const IMPORT_RE = /^\s*import\s+(?:[^'"]*from\s+)?['"]([^'"]+)['"]/;
|
|
31
31
|
const CONT_IMPORT_RE = /^\s*\}?\s*from\s+['"]([^'"]+)['"]/; // multi-line: "} from 'x'"
|
|
32
32
|
const REQUIRE_RE = /\brequire\(\s*['"]([^'"]+)['"]\s*\)/;
|
|
@@ -114,7 +114,7 @@ export function analyzeDiff(diff) {
|
|
|
114
114
|
else if (raw.startsWith("rename to ") || raw.startsWith("copy to ")) {
|
|
115
115
|
const to = raw.slice(raw.indexOf(" to ") + 4).trim();
|
|
116
116
|
curFile = to;
|
|
117
|
-
if (
|
|
117
|
+
if (isSubstantive(to))
|
|
118
118
|
filesRenamed.push({ from: renameFrom, to });
|
|
119
119
|
}
|
|
120
120
|
else if (raw.startsWith("--- ")) {
|
|
@@ -126,7 +126,7 @@ export function analyzeDiff(diff) {
|
|
|
126
126
|
const p = raw.slice(4).trim();
|
|
127
127
|
if (p !== "/dev/null")
|
|
128
128
|
curFile = stripAB(p); // new path preferred
|
|
129
|
-
if (
|
|
129
|
+
if (isSubstantive(curFile)) {
|
|
130
130
|
if (curAdded)
|
|
131
131
|
filesAdded.add(curFile);
|
|
132
132
|
else if (curDeleted)
|
|
@@ -138,45 +138,50 @@ export function analyzeDiff(diff) {
|
|
|
138
138
|
// ---- inside a hunk: content lines ----
|
|
139
139
|
if (raw.startsWith("+")) {
|
|
140
140
|
const body = raw.slice(1);
|
|
141
|
-
// Raw added lines are captured for EVERY file, before the
|
|
141
|
+
// Raw added lines are captured for EVERY file, before the substantive gate:
|
|
142
142
|
// content-matched constraints and Veto tripwires are not code-only rules
|
|
143
143
|
// (a blocking invariant legitimately scopes .github/workflows/**, *.sql,
|
|
144
144
|
// Dockerfile). Skipping them here left `scopedAdded` empty, which
|
|
145
145
|
// buildCheckReport reads as "cannot prove a violation ⇒ complies" — so the
|
|
146
146
|
// pre-edit hook denied the edit while `hunch check --strict` passed the
|
|
147
|
-
// very commit that landed it.
|
|
148
|
-
//
|
|
147
|
+
// very commit that landed it. The churn counters below now also include
|
|
148
|
+
// prose (isSubstantive, issue #12); symbol/import extraction stays
|
|
149
|
+
// code-only (isCode/languageFor) since declarations are a code concept.
|
|
149
150
|
let lines = addedLinesBy.get(curFile);
|
|
150
151
|
if (!lines) {
|
|
151
152
|
lines = [];
|
|
152
153
|
addedLinesBy.set(curFile, lines);
|
|
153
154
|
}
|
|
154
155
|
lines.push(body);
|
|
155
|
-
if (!
|
|
156
|
+
if (!isSubstantive(curFile))
|
|
156
157
|
continue;
|
|
157
158
|
addedLines++;
|
|
158
159
|
if (!curAdded && !curDeleted)
|
|
159
160
|
filesModified.add(curFile);
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
161
|
+
if (isCode(curFile)) {
|
|
162
|
+
const d = declOf(body);
|
|
163
|
+
if (d)
|
|
164
|
+
declsFor(curFile)?.added.set(d.name, d);
|
|
165
|
+
const imp = importOf(body);
|
|
166
|
+
if (imp && !imp.startsWith("."))
|
|
167
|
+
addedImports.add(imp);
|
|
168
|
+
}
|
|
166
169
|
}
|
|
167
170
|
else if (raw.startsWith("-")) {
|
|
168
|
-
if (!
|
|
171
|
+
if (!isSubstantive(curFile))
|
|
169
172
|
continue;
|
|
170
173
|
removedLines++;
|
|
171
174
|
if (!curAdded && !curDeleted)
|
|
172
175
|
filesModified.add(curFile);
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
176
|
+
if (isCode(curFile)) {
|
|
177
|
+
const body = raw.slice(1);
|
|
178
|
+
const d = declOf(body);
|
|
179
|
+
if (d)
|
|
180
|
+
declsFor(curFile)?.removed.set(d.name, d);
|
|
181
|
+
const imp = importOf(body);
|
|
182
|
+
if (imp && !imp.startsWith("."))
|
|
183
|
+
removedImports.add(imp);
|
|
184
|
+
}
|
|
180
185
|
}
|
|
181
186
|
}
|
|
182
187
|
// per-file symbol classification (added/removed/changed within the same file)
|
package/dist/extractors/git.js
CHANGED
|
@@ -748,6 +748,10 @@ export function headFileContent(root, rel) {
|
|
|
748
748
|
encoding: "utf8",
|
|
749
749
|
env: foreignRepoEnv(process.env),
|
|
750
750
|
maxBuffer: 16 * 1024 * 1024,
|
|
751
|
+
// An untracked/absent-at-HEAD path is an expected, silently-handled case
|
|
752
|
+
// (falls through to the catch below) — don't let git's "fatal: path ...
|
|
753
|
+
// does not exist in 'HEAD'" leak onto the caller's stderr for it.
|
|
754
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
751
755
|
});
|
|
752
756
|
}
|
|
753
757
|
catch {
|
|
@@ -281,4 +281,12 @@ export function languageFor(file) {
|
|
|
281
281
|
}
|
|
282
282
|
return null;
|
|
283
283
|
}
|
|
284
|
+
/** Prose formats worth drafting a decision from even though no LanguageSpec parses
|
|
285
|
+
* them — no grammar, no symbols/edges, just eligible input to synthesis (issue #12). */
|
|
286
|
+
export const PROSE_EXTENSIONS = [".md"];
|
|
287
|
+
/** Broader than languageFor: "is this worth reasoning about" (synthesis input)
|
|
288
|
+
* rather than "can tree-sitter parse this" (symbol/dependency extraction). */
|
|
289
|
+
export function isSubstantive(file) {
|
|
290
|
+
return languageFor(file) !== null || PROSE_EXTENSIONS.some((ext) => file.endsWith(ext));
|
|
291
|
+
}
|
|
284
292
|
//# sourceMappingURL=languages.js.map
|