@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.
@@ -26,7 +26,7 @@ import { decisionId } from "../core/ids.js";
26
26
  import { ENTITY_KINDS, SCHEMAS } from "../core/types.js";
27
27
  import { captureConflicts, isLive } from "../core/topics.js";
28
28
  import { buildDeliveryEnvelope } from "../core/delivery.js";
29
- import { STATE_CAPABILITIES, STATE_CONTRACT_VERSION, STATE_FACETS, STATE_READ_VERSION, STATE_SUBSCRIBE_VERSION, STATE_WRITE_VERSION, ReadRequestSchema, ReadResponseSchema, WriteRequestSchema, WriteResultSchema, SubscribeRequestSchema, ChangeEventSchema, RecordsRequestSchema, RecordsResponseSchema, STATE_RECORDS_VERSION, ScopeSchema, scopePath, stateHash, actionReceiptId, commitmentId, derivedId, assertReadWithinGrants, assertWriteWellFormed, assertDerivedState, } from "../core/stateContract.js";
29
+ import { STATE_CAPABILITIES, STATE_CONTRACT_VERSION, STATE_FACETS, STATE_READ_VERSION, STATE_SUBSCRIBE_VERSION, STATE_WRITE_VERSION, ReadRequestSchema, ReadResponseSchema, WriteRequestSchema, WriteResultSchema, SubscribeRequestSchema, ChangeEventSchema, RecordsRequestSchema, RecordsResponseSchema, STATE_RECORDS_VERSION, ScopeSchema, scopePath, stateHash, actionReceiptId, commitmentId, derivedId, externalKey, subjectOfRef, assertReadWithinGrants, assertWriteWellFormed, assertDerivedState, isHumanConfirmed, } from "../core/stateContract.js";
30
30
  /** A typed refusal. `code` is stable for bindings; `conflict` names the incumbent when one exists. */
31
31
  export class StateRefusal extends Error {
32
32
  code;
@@ -81,7 +81,9 @@ export function capabilities(store) {
81
81
  }
82
82
  // ---- homing --------------------------------------------------------------------------------
83
83
  const granted = (principal, scope) => principal.grants.some((g) => scopePath(g) === scopePath(scope));
84
- function homeFor(store, scope) {
84
+ /** Where a scope's records and ledger live in this store (exported for the replay check, which
85
+ * must read the SAME home the write verb wrote — never a second routing rule). */
86
+ export function stateHomeFor(store, scope) {
85
87
  const own = partitionOf(store);
86
88
  if (scopePath(scope) === scopePath(own)) {
87
89
  // The store IS this partition: its capture home (public `.hunch/`, or the overlay in shared mode).
@@ -104,6 +106,62 @@ const recordScope = (record, repo) => {
104
106
  function refOf(facet, record, scope) {
105
107
  return { facet, id: record.id, record_hash: stateHash(record), scope };
106
108
  }
109
+ /** Follow `merged_into` to the entity that stands for this one now (cycle-safe, bounded). */
110
+ function survivorOf(byId, entity) {
111
+ let current = entity;
112
+ const seen = new Set([current.id]);
113
+ while (current.lifecycle === "retired" && current.merged_into) {
114
+ const next = byId.get(current.merged_into);
115
+ if (!next || seen.has(next.id))
116
+ break;
117
+ seen.add(next.id);
118
+ current = next;
119
+ }
120
+ return current;
121
+ }
122
+ /** The entities in the principal's grants that stand for an external key or an entity id — active
123
+ * ones directly, retired-and-merged ones through the survivor they name. */
124
+ function entityIndex(store, principal, repo) {
125
+ const byId = new Map();
126
+ for (const e of store.recs("entities"))
127
+ if (granted(principal, recordScope(e, repo)))
128
+ byId.set(e.id, e);
129
+ const survivor = (e) => survivorOf(byId, e);
130
+ const byKey = new Map();
131
+ const bySubject = new Map();
132
+ for (const e of byId.values()) {
133
+ const stands = e.lifecycle === "active" ? e : (e.lifecycle === "retired" && e.merged_into ? survivor(e) : null);
134
+ if (!stands || stands.lifecycle !== "active")
135
+ continue;
136
+ for (const ref of e.refs) {
137
+ if (!byKey.has(externalKey(ref)) || e.lifecycle === "active")
138
+ byKey.set(externalKey(ref), stands);
139
+ if (!bySubject.has(subjectOfRef(ref)) || e.lifecycle === "active")
140
+ bySubject.set(subjectOfRef(ref), stands);
141
+ }
142
+ }
143
+ return { byKey, bySubject, byId, survivor };
144
+ }
145
+ /** The names one subject is filed under: itself, the entity that stands for it (through merges),
146
+ * every entity merged into that one, and every key any of them carries. Explicit refs only. */
147
+ function subjectAliases(store, principal, repo, subject) {
148
+ const aliases = new Set([subject]);
149
+ const { bySubject, byId, survivor } = entityIndex(store, principal, repo);
150
+ const named = bySubject.get(subject) ?? byId.get(subject);
151
+ if (!named)
152
+ return aliases;
153
+ const stands = survivor(named);
154
+ if (stands.lifecycle !== "active")
155
+ return aliases;
156
+ for (const e of byId.values()) {
157
+ if (e.id !== stands.id && survivor(e).id !== stands.id)
158
+ continue;
159
+ aliases.add(e.id);
160
+ for (const ref of e.refs)
161
+ aliases.add(subjectOfRef(ref));
162
+ }
163
+ return aliases;
164
+ }
107
165
  /** read — the system-of-record answer for a subject, under the delivery envelope's receipt.
108
166
  * Grants are the first predicate on every candidate; a matching record in a scope the
109
167
  * principal lacks is NAMED in denied_scopes and never described. */
@@ -133,6 +191,11 @@ export function readState(store, input) {
133
191
  denied.set(scopePath(s), s);
134
192
  if (request.subject !== undefined) {
135
193
  const subject = request.subject;
194
+ // Subject identity by external reference: a read for an external record's key (`event:26904`,
195
+ // `customer:Site:7`) also finds what is filed under the entity that carries that ref, and a
196
+ // read for the entity id finds what was filed under its keys — one explicit hop, grants first.
197
+ const aliases = subjectAliases(store, request.principal, repo, subject);
198
+ const isSubject = (s) => s !== undefined && aliases.has(s);
136
199
  const current = [];
137
200
  const inForce = [];
138
201
  const done = [];
@@ -173,7 +236,7 @@ export function readState(store, input) {
173
236
  }
174
237
  if (facets.has("receipts"))
175
238
  for (const r of store.recs("receipts")) {
176
- const targets = r.id === subject || r.invalidates.includes(subject) || `${r.target.object_type}:${r.target.object_key}` === subject;
239
+ const targets = r.id === subject || r.invalidates.some(isSubject) || isSubject(subjectOfRef(r.target));
177
240
  if (!targets)
178
241
  continue;
179
242
  const scope = admit("receipts", r);
@@ -183,12 +246,12 @@ export function readState(store, input) {
183
246
  done.push(keep("receipts", r, scope));
184
247
  dependsOn.push(...(r.rests_on ?? []));
185
248
  }
186
- if (r.invalidates.includes(subject))
249
+ if (r.invalidates.some(isSubject))
187
250
  invalidatedBy.add(r.id);
188
251
  }
189
252
  if (facets.has("commitments"))
190
253
  for (const c of store.recs("commitments")) {
191
- if (c.subject !== subject && c.id !== subject)
254
+ if (!isSubject(c.subject) && c.id !== subject)
192
255
  continue;
193
256
  const scope = admit("commitments", c);
194
257
  if (!scope)
@@ -202,7 +265,7 @@ export function readState(store, input) {
202
265
  }
203
266
  if (facets.has("derived"))
204
267
  for (const d of store.recs("derived")) {
205
- if (d.subject !== subject && d.id !== subject)
268
+ if (!isSubject(d.subject) && d.id !== subject)
206
269
  continue;
207
270
  const scope = admit("derived", d);
208
271
  if (!scope)
@@ -214,7 +277,7 @@ export function readState(store, input) {
214
277
  }
215
278
  if (facets.has("entities"))
216
279
  for (const e of store.recs("entities")) {
217
- if (e.id !== subject)
280
+ if (!isSubject(e.id))
218
281
  continue;
219
282
  const scope = admit("entities", e);
220
283
  if (!scope)
@@ -224,7 +287,7 @@ export function readState(store, input) {
224
287
  }
225
288
  if (facets.has("relationships"))
226
289
  for (const r of store.recs("relationships")) {
227
- if (r.from !== subject && r.to !== subject)
290
+ if (!isSubject(r.from) && !isSubject(r.to))
228
291
  continue;
229
292
  const scope = admit("relationships", r);
230
293
  if (!scope)
@@ -407,6 +470,54 @@ function assertClosedBy(store, principal, commitment) {
407
470
  }
408
471
  return commitment.closed_by;
409
472
  }
473
+ /** one-entity-per-external-ref. An entity: no other active entity in the partition may carry one
474
+ * of its external keys (the incumbent is named; merge/split are explicit, later). A commitment or
475
+ * derived statement: its subject may not be the external key of a record an entity already
476
+ * carries — the entity's id is the subject, and the refusal names it (the writer re-derives;
477
+ * ids derive from the subject, so nothing is rewritten under it). A subject no entity claims
478
+ * stays a free-form key: explicit refs only, no guessing. */
479
+ function assertExternalIdentity(store, principal, scope, facet, record) {
480
+ const repo = partitionOf(store);
481
+ const inPartition = (e) => scopePath(recordScope(e, repo)) === scopePath(scope);
482
+ if (facet === "entities") {
483
+ const entity = record;
484
+ if (entity.merged_into !== undefined) {
485
+ const target = store.recs("entities").find((e) => e.id === entity.merged_into);
486
+ if (!target || !inPartition(target))
487
+ throw new StateRefusal("conflict", `merged_into ${entity.merged_into} is not an entity on record in ${scopePath(scope)}: a merge names a survivor that exists — write it first`, { incumbent_id: entity.merged_into, reason: "merge survivor absent" });
488
+ if (!granted(principal, recordScope(target, repo)))
489
+ throw new StateRefusal("outside-grants", `merged_into ${entity.merged_into} is outside the principal's grants`);
490
+ if (target.lifecycle !== "active")
491
+ throw new StateRefusal("conflict", `merged_into ${entity.merged_into} is ${target.lifecycle}${target.merged_into ? ` (merged into ${target.merged_into})` : ""}: the survivor of a merge is an active entity — merge into the one that stands now`, { incumbent_id: target.merged_into ?? target.id, reason: "merge survivor not active" });
492
+ }
493
+ if (entity.lifecycle !== "active")
494
+ return;
495
+ const keys = new Set(entity.refs.map(externalKey));
496
+ for (const other of store.recs("entities")) {
497
+ if (other.id === entity.id || other.lifecycle !== "active" || !inPartition(other))
498
+ continue;
499
+ const shared = other.refs.map(externalKey).find((k) => keys.has(k));
500
+ if (shared)
501
+ throw new StateRefusal("conflict", `${shared} is already carried by entity ${other.id} in ${scopePath(scope)}: one external record is one entity — write under ${other.id}, or retire it first (merge and split are explicit)`, { incumbent_id: other.id, reason: "one-entity-per-external-ref" });
502
+ }
503
+ return;
504
+ }
505
+ if (facet !== "commitments" && facet !== "derived")
506
+ return;
507
+ const subject = record.subject;
508
+ const { bySubject, byId, survivor } = entityIndex(store, principal, repo);
509
+ const byKey = bySubject.get(subject);
510
+ if (byKey && byKey.id !== subject && inPartition(byKey)) {
511
+ throw new StateRefusal("identity", `subject ${subject} is the external key of entity ${byKey.id} in ${scopePath(scope)}: the entity's id is the subject — re-derive with subject ${byKey.id}`, { incumbent_id: byKey.id, reason: "subject is an entity's external key" });
512
+ }
513
+ const named = byId.get(subject);
514
+ if (named && named.lifecycle === "retired" && named.merged_into && inPartition(named)) {
515
+ const stands = survivor(named);
516
+ if (stands.id !== named.id && stands.lifecycle === "active") {
517
+ throw new StateRefusal("identity", `subject ${subject} was merged into ${stands.id}: new state goes under the survivor — re-derive with subject ${stands.id}`, { incumbent_id: stands.id, reason: "subject was merged" });
518
+ }
519
+ }
520
+ }
410
521
  /** Top-level fields whose canonical hash differs between two records, sorted. */
411
522
  function differingFields(a, b) {
412
523
  const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
@@ -488,13 +599,14 @@ export function writeState(store, input, opts = {}) {
488
599
  catch (e) {
489
600
  throw new StateRefusal(/grants/.test(e.message) ? "outside-grants" : "malformed", e.message);
490
601
  }
491
- const { home, hunchDir, isPrivate } = homeFor(store, request.scope);
602
+ const { home, hunchDir, isPrivate } = stateHomeFor(store, request.scope);
492
603
  const now = (opts.now ?? (() => new Date()))().toISOString();
493
604
  const facet = request.facet;
494
605
  if (!ENTITY_KINDS.includes(facet))
495
606
  throw new StateRefusal("unsupported", `facet ${facet} is not a store kind`);
496
607
  const record = normalizeRecord(facet, request.scope, request.record, request.principal);
497
608
  const id = record.id;
609
+ assertExternalIdentity(store, request.principal, request.scope, facet, record);
498
610
  /** The normalized PAYLOAD hash: what idempotency recognizes on a re-send. */
499
611
  const hash = stateHash(record);
500
612
  const ledger = readLedger(hunchDir, request.scope);
@@ -531,8 +643,42 @@ export function writeState(store, input, opts = {}) {
531
643
  if (!ok)
532
644
  throw new StateRefusal("conflict", `expected_version does not match the incumbent ${id}`, { incumbent_id: id, reason: "expected_version mismatch" });
533
645
  }
534
- // one-live-decision-per-topic refuse with the incumbent named; supersession is explicit.
646
+ // human-correction-outranks-agent-writes: what a human confirmed, an agent does not rewrite.
647
+ // Allowed for an agent: a replay (the same facts, the tier downgrade aside), a derived statement
648
+ // written back stale with the external cause that moved (the writer's currentness duty), a
649
+ // commitment closed by a receipt on record (a fact that happened) — both keep the human's
650
+ // provenance on the record. Everything else on a human-confirmed incumbent — in place or by
651
+ // supersession — is refused with the incumbent named.
535
652
  let supersedes = request.supersedes ?? null;
653
+ if (request.principal.kind !== "human") {
654
+ const guard = (incumbent, how) => {
655
+ if (!incumbent || !isHumanConfirmed(incumbent))
656
+ return null;
657
+ const incumbentId = String(incumbent.id);
658
+ const changed = how === "overwrite" ? differingFields(incumbent, record).filter((f) => f !== "provenance") : ["a new record"];
659
+ if (how === "overwrite") {
660
+ if (changed.length === 0)
661
+ return "replay";
662
+ const staleWithCause = facet === "derived" && incumbent.state === "current" && record.state === "stale" && request.cause?.kind === "external"
663
+ && changed.every((f) => f === "state" || f === "valid_to");
664
+ const closedByReceipt = facet === "commitments" && !!record.closed_by && record.status === "done"
665
+ && changed.every((f) => f === "status" || f === "closed_by" || f === "valid_to");
666
+ if (staleWithCause || closedByReceipt)
667
+ return "keep-provenance";
668
+ }
669
+ throw new StateRefusal("conflict", `${incumbentId} was confirmed by a human; ${request.principal.kind === "agent" ? "an agent" : "a service"} principal may not ${how} it (differs in: ${changed.join(", ")}). A human writes the change, or the agent leaves the record as the human left it.`, { incumbent_id: incumbentId, reason: "human-confirmed incumbent" });
670
+ };
671
+ const verdict = guard(existing, "overwrite");
672
+ if (verdict === "replay") {
673
+ appendChanges(hunchDir, request.scope, [], { key: request.idempotency_key, entry: { record_id: id, record_hash: stateHash(existing), payload_hash: hash, facet } }, now);
674
+ return result("replayed");
675
+ }
676
+ if (verdict === "keep-provenance")
677
+ record.provenance = existing.provenance;
678
+ if (supersedes && supersedes !== id)
679
+ guard(store.getRec(facet, supersedes), "supersede");
680
+ }
681
+ // one-live-decision-per-topic — refuse with the incumbent named; supersession is explicit.
536
682
  if (facet === "decisions") {
537
683
  const d = record;
538
684
  if (d.topic && d.status === "accepted") {
@@ -580,6 +726,8 @@ export function writeState(store, input, opts = {}) {
580
726
  // ledger says so, and names the external pointer that moved when the writer gives one.
581
727
  const invalidated = facet === "derived" && !!existing && existing.state === "current" && record.state === "stale";
582
728
  const invalidates = facet === "receipts" ? record.invalidates : [];
729
+ // An entity leaving service is a `retired` change (a merge names the survivor in the record).
730
+ const retired = facet === "entities" && record.lifecycle === "retired" && (!existing || existing.lifecycle !== "retired");
583
731
  const subject = subjectOf(facet, record);
584
732
  if (supersedes) {
585
733
  const closed = closeWindow(store, facet, supersedes, id, now, isPrivate);
@@ -588,7 +736,7 @@ export function writeState(store, input, opts = {}) {
588
736
  changes.push({ facet, record_id: supersedes, record_hash: stateHash(old), change: "superseded", subject: subjectOf(facet, old), invalidates: [], cause });
589
737
  }
590
738
  }
591
- changes.push({ facet, record_id: id, record_hash: onFileHash, change: invalidated ? "invalidated" : existing ? "updated" : "created", subject, invalidates: invalidated && subject ? [subject] : invalidates, cause });
739
+ changes.push({ facet, record_id: id, record_hash: onFileHash, change: invalidated ? "invalidated" : retired ? "retired" : existing ? "updated" : "created", subject, invalidates: invalidated && subject ? [subject] : invalidates, cause });
592
740
  appendChanges(hunchDir, request.scope, changes, { key: request.idempotency_key, entry: { record_id: id, record_hash: onFileHash, payload_hash: hash, facet } }, now);
593
741
  store.reindex();
594
742
  return result(supersedes ? "superseded" : existing ? "updated" : "created");
@@ -600,7 +748,7 @@ export function subscribeState(store, input) {
600
748
  const request = SubscribeRequestSchema.parse(input);
601
749
  if (!granted(request.principal, request.scope))
602
750
  throw new StateRefusal("outside-grants", `scope ${scopePath(request.scope)} is outside the principal's grants`);
603
- const { hunchDir } = homeFor(store, request.scope);
751
+ const { hunchDir } = stateHomeFor(store, request.scope);
604
752
  const ledger = readLedger(hunchDir, request.scope);
605
753
  const facets = request.facets ? new Set(request.facets) : null;
606
754
  const subjects = request.subjects ? new Set(request.subjects) : null;
@@ -31,6 +31,7 @@ import { tmpdir } from "node:os";
31
31
  import { dirname, join } from "node:path";
32
32
  import { writeFileAtomic } from "../core/io.js";
33
33
  import { summarizeDiff } from "../extractors/diff.js";
34
+ import { languageFor } from "../extractors/languages.js";
34
35
  const IS_WIN = process.platform === "win32";
35
36
  /**
36
37
  * Run a command, optionally feeding `input` to its stdin, and resolve its
@@ -614,6 +615,9 @@ export class DeterministicProvider {
614
615
  const dirs = topDirs(input.files);
615
616
  const a = input.analysis;
616
617
  const summary = a ? summarizeDiff(a) : "";
618
+ // input.files can be markdown-only (issue #12) — don't claim "code" for a
619
+ // commit that touched none.
620
+ const noun = input.files.some((f) => languageFor(f) !== null) ? "code" : "content";
617
621
  const verb = /^(add|introduce|create|feat)/i.test(input.subject) ? "introduced"
618
622
  : /^(remove|delete|drop)/i.test(input.subject) ? "removed"
619
623
  : /^(refactor|rework|restructure)/i.test(input.subject) ? "refactored"
@@ -630,12 +634,12 @@ export class DeterministicProvider {
630
634
  // We extracted real structure → a bit more trustworthy than a blind heuristic.
631
635
  const informative = !!(a && (a.addedSymbols.length || a.removedSymbols.length || a.changedSymbols.length || a.addedDeps.length || a.removedDeps.length));
632
636
  return {
633
- title: input.subject || "Code change",
637
+ title: input.subject || `${cap(noun)} change`,
634
638
  context: [input.body, summary && `What changed: ${summary}.`].filter(Boolean).join(" ").slice(0, 500)
635
639
  || `Touched ${input.files.length} file(s) across ${dirs.join(", ") || "the repo"}.`,
636
640
  decision: summary
637
641
  ? `${cap(verb)} ${dirs.join(", ") || "the repo"}: ${summary}.`
638
- : `${cap(verb)} code in ${dirs.join(", ") || "the repo"} (${input.files.length} file(s)).`,
642
+ : `${cap(verb)} ${noun} in ${dirs.join(", ") || "the repo"} (${input.files.length} file(s)).`,
639
643
  consequences,
640
644
  alternatives_rejected: [],
641
645
  // advisory either way, but real extraction earns a touch more confidence
@@ -5,7 +5,7 @@ import { decisionId, bugId, constraintId } from "../core/ids.js";
5
5
  import { commitCoveredBy } from "../core/dupdetect.js";
6
6
  import { pathMatchesGlob } from "../core/glob.js";
7
7
  import { draftTripwires, knownRepoDeps } from "./tripwires.js";
8
- import { languageFor } from "../extractors/languages.js";
8
+ import { isSubstantive } from "../extractors/languages.js";
9
9
  // "chore(deps):" is anchored separately (not via \b) because \b requires a
10
10
  // word/non-word transition, and the character after the closing ")" is ":" or a
11
11
  // space — both non-word — so no boundary ever fires there.
@@ -26,14 +26,16 @@ export function isTrivialSubject(meta) {
26
26
  * deterministic. Any structural change (symbol/dependency delta), non-trivial
27
27
  * churn, several files, OR an explanatory commit body signals a real decision
28
28
  * worth the model. Everything below (typo/tweak/one-liner with no message) falls
29
- * to the free deterministic draft — shallower but honestly low-confidence. */
30
- export function isSignificant(meta, a, codeFiles) {
29
+ * to the free deterministic draft — shallower but honestly low-confidence.
30
+ * `files` is whatever the caller is synthesizing from (code and/or markdown, per
31
+ * isSubstantive) — the line/file-count checks are format-agnostic. */
32
+ export function isSignificant(meta, a, files) {
31
33
  const structural = a.addedSymbols.length + a.removedSymbols.length + a.changedSymbols.length + a.addedDeps.length + a.removedDeps.length;
32
34
  if (structural > 0)
33
35
  return true;
34
36
  if (a.addedLines + a.removedLines >= SIG_MIN_LINES)
35
37
  return true;
36
- if (codeFiles.length >= 3)
38
+ if (files.length >= 3)
37
39
  return true;
38
40
  if (meta.body.trim().length >= SIG_MIN_BODY)
39
41
  return true;
@@ -50,9 +52,28 @@ export async function syncCommit(store, root, sha, opts = {}) {
50
52
  return { status: "skipped", reason: "commit not found" };
51
53
  if (isTrivialSubject(meta))
52
54
  return { status: "skipped", reason: `trivial subject: ${meta.subject}` };
53
- const codeFiles = meta.files.filter((f) => languageFor(f) !== null);
54
- if (codeFiles.length === 0)
55
- return { status: "skipped", reason: "no code files changed" };
55
+ // .hunch/** is Hunch's OWN store commitDiff already excludes it from the diff
56
+ // CONTENT via DIFF_NOISE ("circular noise": re-synthesizing a commit that wrote
57
+ // it would draft a decision about Hunch's own bookkeeping). That guard never
58
+ // covered this file-LIST gate, and before #12 it didn't need to: languageFor()
59
+ // already returned null for .hunch/**'s JSON and for the .md grounding docs
60
+ // flushCapture regenerates alongside it (AGENTS.md/CLAUDE.md/copilot-instructions.md),
61
+ // so such a commit failed "no code files changed" by coincidence. Once markdown
62
+ // became substantive input those grounding docs alone made the commit eligible.
63
+ // Checked by PATH, not commit-message convention (flushCapture's exact subject
64
+ // wording lives as separate literals in mcp/server.ts/cli/index.ts and could
65
+ // drift independently of any subject regex here) — a human editing AGENTS.md on
66
+ // its own, with no .hunch/** change alongside it, stays fully synthesis-eligible.
67
+ if (meta.files.some((f) => pathMatchesGlob(f, "**/.hunch/**"))) {
68
+ return { status: "skipped", reason: "touches Hunch's own store (.hunch/**) — circular, never synthesis input" };
69
+ }
70
+ // Substantive, not just parseable: markdown carries the *why* in a docs/ADR repo
71
+ // just as legitimately as a .ts diff does, even though it has no symbol graph
72
+ // (issue #12). languageFor() stays the parseability question for the symbol/dep
73
+ // extraction inside analyzeDiff below.
74
+ const substantiveFiles = meta.files.filter((f) => isSubstantive(f));
75
+ if (substantiveFiles.length === 0)
76
+ return { status: "skipped", reason: "no code or markdown files changed" };
56
77
  // Seed the id from the COMMIT (stable across runs), not the LLM-generated title
57
78
  // (which varies) — so re-syncing a commit updates rather than dupes.
58
79
  const id = decisionId(meta.sha);
@@ -83,7 +104,7 @@ export async function syncCommit(store, root, sha, opts = {}) {
83
104
  // review triage measured 7 of 14 queued drafts as exactly this. A recent
84
105
  // human-confirmed decision claiming this commit's files → skip the draft (and
85
106
  // the subscription call). Recency-windowed; --force overrides.
86
- const covered = commitCoveredBy(codeFiles, meta.subject, store.recs("decisions"), Date.now());
107
+ const covered = commitCoveredBy(substantiveFiles, meta.subject, store.recs("decisions"), Date.now());
87
108
  if (covered && !opts.force) {
88
109
  return {
89
110
  status: "skipped",
@@ -111,10 +132,10 @@ export async function syncCommit(store, root, sha, opts = {}) {
111
132
  ? new DeterministicProvider()
112
133
  : opts.deep
113
134
  ? (await selectEnsemble({ samples: opts.samples })) ?? await selectProvider({ root })
114
- : opts.force || opts.verify || isSignificant(meta, analysis, codeFiles)
135
+ : opts.force || opts.verify || isSignificant(meta, analysis, substantiveFiles)
115
136
  ? await selectProvider({ root })
116
137
  : new DeterministicProvider();
117
- const input = { subject: meta.subject, body: meta.body, files: codeFiles, diff, analysis };
138
+ const input = { subject: meta.subject, body: meta.body, files: substantiveFiles, diff, analysis };
118
139
  let draft = await draftDecisionSafe(provider, input);
119
140
  // The Critic pass: audit the draft against the commit, PRUNE unsupported alternatives
120
141
  // (BEFORE they scaffold tripwires below) and consequences, and lower confidence on weak
@@ -150,13 +171,13 @@ export async function syncCommit(store, root, sha, opts = {}) {
150
171
  const branchTag = branch ? [`branch:${branch}`] : [];
151
172
  const components = store.json.loadAll("components");
152
173
  const relatedComponents = components
153
- .filter((c) => codeFiles.some((f) => c.paths.some((g) => pathMatchesGlob(f, g))))
174
+ .filter((c) => substantiveFiles.some((f) => c.paths.some((g) => pathMatchesGlob(f, g))))
154
175
  .map((c) => c.id);
155
176
  // Surface any do-not-break constraints this commit's files touch (DESIGN §4
156
177
  // "constraint touched" flag) right in the decision context, with evidence.
157
178
  const touchedConstraints = store.json
158
179
  .loadAll("constraints")
159
- .filter((c) => codeFiles.some((f) => c.scope.some((g) => pathMatchesGlob(f, g))));
180
+ .filter((c) => substantiveFiles.some((f) => c.scope.some((g) => pathMatchesGlob(f, g))));
160
181
  const constraintNote = touchedConstraints.length
161
182
  ? ` Touches invariant(s): ${touchedConstraints.map((c) => `${c.id} (${c.statement})`).join("; ")}.`
162
183
  : "";
@@ -183,9 +204,9 @@ export async function syncCommit(store, root, sha, opts = {}) {
183
204
  // → never block until confirmed via `hunch review --accept` (dec_a466655539).
184
205
  rejected_tripwires: existing?.rejected_tripwires?.length
185
206
  ? existing.rejected_tripwires
186
- : draftTripwires(draft.alternatives_rejected, codeFiles, knownRepoDeps(root)),
207
+ : draftTripwires(draft.alternatives_rejected, substantiveFiles, knownRepoDeps(root)),
187
208
  related_components: relatedComponents,
188
- related_files: codeFiles,
209
+ related_files: substantiveFiles,
189
210
  supersedes: existing?.supersedes ?? null,
190
211
  superseded_by: existing?.superseded_by ?? null,
191
212
  caused_by_bug: existing?.caused_by_bug ?? null,
@@ -201,7 +222,7 @@ export async function syncCommit(store, root, sha, opts = {}) {
201
222
  provenance: {
202
223
  source: draft.source,
203
224
  confidence: draft.confidence,
204
- evidence: [`commit:${meta.shortSha}`, synthEvidence, ...branchTag, ...codeFiles.slice(0, 8)],
225
+ evidence: [`commit:${meta.shortSha}`, synthEvidence, ...branchTag, ...substantiveFiles.slice(0, 8)],
205
226
  last_verified: new Date().toISOString(), // when the Hunch last re-derived this
206
227
  },
207
228
  date: meta.date, // the commit date
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davesheffer/hunch",
3
- "version": "1.29.0",
3
+ "version": "1.30.0",
4
4
  "mcpName": "io.github.davesheffer/hunch",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Dave Sheffer <dave.sheffer1@gmail.com>",
package/server.json CHANGED
@@ -7,13 +7,13 @@
7
7
  "source": "github"
8
8
  },
9
9
  "websiteUrl": "https://www.hunchmemory.com",
10
- "version": "1.29.0",
10
+ "version": "1.30.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.29.0",
16
+ "version": "1.30.0",
17
17
  "runtimeHint": "npx",
18
18
  "packageArguments": [
19
19
  {