@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 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/non-code/already-captured).`);
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), 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.")
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
- if (!findings.length && collisions.size === 0) {
5458
- 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.` : ""}`);
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)) {
@@ -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 (isCode(to))
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 (isCode(curFile)) {
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 code-only gate:
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. Symbol/import extraction and the churn
148
- // counters below stay code-only, unchanged.
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 (!isCode(curFile))
156
+ if (!isSubstantive(curFile))
156
157
  continue;
157
158
  addedLines++;
158
159
  if (!curAdded && !curDeleted)
159
160
  filesModified.add(curFile);
160
- const d = declOf(body);
161
- if (d)
162
- declsFor(curFile)?.added.set(d.name, d);
163
- const imp = importOf(body);
164
- if (imp && !imp.startsWith("."))
165
- addedImports.add(imp);
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 (!isCode(curFile))
171
+ if (!isSubstantive(curFile))
169
172
  continue;
170
173
  removedLines++;
171
174
  if (!curAdded && !curDeleted)
172
175
  filesModified.add(curFile);
173
- const body = raw.slice(1);
174
- const d = declOf(body);
175
- if (d)
176
- declsFor(curFile)?.removed.set(d.name, d);
177
- const imp = importOf(body);
178
- if (imp && !imp.startsWith("."))
179
- removedImports.add(imp);
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)
@@ -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