@davesheffer/hunch 1.28.0 → 1.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -44,9 +44,29 @@ 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
- z.object({ kind: z.literal("record"), id: z.string().regex(TOKEN), record_hash: z.string().regex(SHA256) }).strict(),
67
+ /** `scope` (additive) points into ANOTHER partition — the repository decision an
68
+ * organization-drawer receipt rests on. Absent, the ref is in the record's own partition. */
69
+ z.object({ kind: z.literal("record"), id: z.string().regex(TOKEN), record_hash: z.string().regex(SHA256), scope: ScopeSchema.optional() }).strict(),
50
70
  z.object({ kind: z.literal("external"), ref: ExternalRefSchema }).strict(),
51
71
  z.object({ kind: z.literal("schema"), name: z.string().max(256), fingerprint: z.string().regex(SHA256) }).strict(),
52
72
  ]);
@@ -66,6 +86,12 @@ export const ActionReceiptSchema = z.object({
66
86
  verified_at: z.string().regex(ISO).optional(),
67
87
  result_fingerprint: z.string().regex(SHA256).optional(),
68
88
  invalidates: z.array(z.string().max(512)).max(64).default([]),
89
+ /** What the action rested on (additive): the decision it implements, the change proof for
90
+ * the shipped revision, the commitment or incident it answers. Same shape as a derived
91
+ * statement's dependencies, so "what does this closure rest on" is one read. A record ref
92
+ * in the receipt's own partition is verified by hash on write; a ref into another partition
93
+ * is a pointer the reader resolves with `records`, grants first. */
94
+ rests_on: z.array(DependencyRefSchema).max(64).optional(),
69
95
  provenance: ProvenanceSchema,
70
96
  }).strict();
71
97
  /** committed — an obligation with a due date and an in-force window. */
@@ -80,6 +106,9 @@ export const CommitmentSchema = z.object({
80
106
  status: z.enum(["open", "waiting", "done", "cancelled"]),
81
107
  source: ExternalRefSchema.optional(),
82
108
  evidence_excerpt: z.string().max(900).optional(),
109
+ /** The receipt that fulfilled this commitment (additive). A closure names what happened:
110
+ * the binding refuses a `closed_by` that is not a succeeded/verified receipt on record. */
111
+ closed_by: z.string().regex(/^nrc_[a-f0-9]{24}$/).optional(),
83
112
  valid_from: z.string().regex(ISO),
84
113
  valid_to: z.string().regex(ISO).nullable().default(null),
85
114
  provenance: ProvenanceSchema,
@@ -113,10 +142,22 @@ export const ExternalEntitySchema = z.object({
113
142
  refs: z.array(ExternalRefSchema).min(1).max(64),
114
143
  attributes: z.record(z.string().max(128), AttributeValue).default({}),
115
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(),
116
151
  provenance: ProvenanceSchema,
117
152
  created_at: z.string().regex(ISO),
118
153
  updated_at: z.string().regex(ISO),
119
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
+ }
120
161
  const prefix = `${entity.kind}:`;
121
162
  const key = entity.id.startsWith(prefix) ? entity.id.slice(prefix.length) : "";
122
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
@@ -97,4 +97,45 @@ export function installPreCommitHook(root, invocation, strict = false) {
97
97
  chmodSync(hookPath, 0o755);
98
98
  return { path: hookPath, action: "appended" };
99
99
  }
100
+ const MERGE_MARK = "# >>> hunch post-merge >>>";
101
+ const MERGE_END = "# <<< hunch post-merge <<<";
102
+ /** Install a post-merge hook that re-syncs the committed grounding docs when a merge
103
+ * brought memory in behind them (fnd_c402046ac7). Two branches that each captured a
104
+ * record regenerate the same "N+1" counts line; git merges identical lines silently
105
+ * and the doc ends up one behind the store. The hook regenerates the existing docs
106
+ * from the PUBLIC store right after a local merge/pull that touched .hunch/, so the
107
+ * next commit carries them. Foreground (it rewrites five files), loop-guarded via
108
+ * HUNCH_SYNC, and it can never fail the merge. Preserves any existing hook. */
109
+ export function installPostMergeHook(root, invocation) {
110
+ const dir = hooksDir(root);
111
+ const abs = isAbsolute(dir) ? dir : join(root, dir);
112
+ mkdirSync(abs, { recursive: true });
113
+ const hookPath = join(abs, "post-merge");
114
+ const blk = [
115
+ MERGE_MARK,
116
+ 'if [ -z "$HUNCH_SYNC" ]; then',
117
+ " if ! git diff --quiet ORIG_HEAD HEAD -- .hunch 2>/dev/null; then",
118
+ ` ( HUNCH_SYNC=1 ${invocation} grounding --refresh 2>/dev/null || true )`,
119
+ " fi",
120
+ "fi",
121
+ MERGE_END,
122
+ ].join("\n");
123
+ if (!existsSync(hookPath)) {
124
+ writeFileSync(hookPath, `#!/bin/sh\n${blk}\n`);
125
+ chmodSync(hookPath, 0o755);
126
+ return { path: hookPath, action: "created" };
127
+ }
128
+ const cur = readFileSync(hookPath, "utf8");
129
+ if (cur.includes(MERGE_MARK)) {
130
+ const updated = cur.replace(new RegExp(`${escapeRe(MERGE_MARK)}[\\s\\S]*?${escapeRe(MERGE_END)}`), blk);
131
+ if (updated === cur)
132
+ return { path: hookPath, action: "unchanged" };
133
+ writeFileSync(hookPath, updated);
134
+ chmodSync(hookPath, 0o755);
135
+ return { path: hookPath, action: "updated" };
136
+ }
137
+ writeFileSync(hookPath, cur.endsWith("\n") ? `${cur}${blk}\n` : `${cur}\n${blk}\n`);
138
+ chmodSync(hookPath, 0o755);
139
+ return { path: hookPath, action: "appended" };
140
+ }
100
141
  //# sourceMappingURL=hooks.js.map
@@ -360,6 +360,14 @@ export function regenerateGrounding(root, store) {
360
360
  writeWindsurfRule(root, store),
361
361
  ];
362
362
  }
363
+ /** The five grounding docs, repo-relative (POSIX separators, as git prints them). */
364
+ export const GROUNDING_DOC_PATHS = Object.freeze([
365
+ "CLAUDE.md",
366
+ "AGENTS.md",
367
+ ".github/copilot-instructions.md",
368
+ ".cursor/rules/hunch.mdc",
369
+ ".windsurf/rules/hunch.md",
370
+ ]);
363
371
  function groundingTargets(root, store) {
364
372
  return [
365
373
  ["CLAUDE.md", () => updateClaudeMd(root, store)],