@davesheffer/hunch 1.41.6 → 1.43.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.
Files changed (48) hide show
  1. package/README.md +5 -1
  2. package/dist/cli/index.js +489 -167
  3. package/dist/constitution/experiment.d.ts +3 -3
  4. package/dist/constitution/g3.d.ts +1 -1
  5. package/dist/core/delivery.d.ts +12 -0
  6. package/dist/core/delivery.js +4 -4
  7. package/dist/core/footprint.d.ts +15 -0
  8. package/dist/core/footprint.js +167 -0
  9. package/dist/core/groundingLag.d.ts +15 -0
  10. package/dist/core/groundingLag.js +27 -0
  11. package/dist/core/hookText.d.ts +4 -0
  12. package/dist/core/hookText.js +8 -0
  13. package/dist/core/hookcache.d.ts +22 -0
  14. package/dist/core/hookcache.js +53 -2
  15. package/dist/core/pipeline.d.ts +28 -0
  16. package/dist/core/pipeline.js +50 -0
  17. package/dist/core/publication.js +17 -3
  18. package/dist/core/shellwrites.d.ts +7 -0
  19. package/dist/core/shellwrites.js +131 -0
  20. package/dist/core/siblingfix.d.ts +124 -0
  21. package/dist/core/siblingfix.js +814 -0
  22. package/dist/core/taskReportHook.d.ts +1 -1
  23. package/dist/core/taskReportHook.js +20 -4
  24. package/dist/core/taskSelection.d.ts +67 -0
  25. package/dist/core/taskSelection.js +196 -0
  26. package/dist/extractors/git.d.ts +31 -0
  27. package/dist/extractors/git.js +180 -1
  28. package/dist/extractors/nativeTreeSitter.d.ts +2 -1
  29. package/dist/extractors/nativeTreeSitter.js +128 -30
  30. package/dist/integrations/claudemd.d.ts +20 -2
  31. package/dist/integrations/claudemd.js +79 -53
  32. package/dist/integrations/providers.d.ts +9 -5
  33. package/dist/integrations/providers.js +36 -23
  34. package/dist/integrations/scaffold.js +2 -1
  35. package/dist/integrations/team.d.ts +24 -4
  36. package/dist/integrations/team.js +154 -16
  37. package/dist/integrations/worktree.d.ts +3 -2
  38. package/dist/integrations/worktree.js +7 -4
  39. package/dist/mcp/server.d.ts +489 -0
  40. package/dist/mcp/server.js +208 -62
  41. package/dist/mcp/taskReportTools.js +12 -9
  42. package/dist/mcp/toolset.d.ts +11 -1
  43. package/dist/mcp/toolset.js +28 -8
  44. package/dist/store/hunchStore.d.ts +25 -1
  45. package/dist/store/hunchStore.js +146 -21
  46. package/dist/store/jsonStore.js +25 -4
  47. package/package.json +1 -1
  48. package/server.json +2 -2
@@ -1,9 +1,15 @@
1
1
  import { createRequire } from "node:module";
2
- import { copyFileSync, mkdirSync, mkdtempSync, readdirSync, rmSync } from "node:fs";
3
- import { tmpdir } from "node:os";
4
- import { basename, dirname, join } from "node:path";
2
+ import { createHash } from "node:crypto";
3
+ import { copyFileSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, renameSync, rmSync, statSync, utimesSync } from "node:fs";
4
+ import { tmpdir, userInfo } from "node:os";
5
+ import { basename, dirname, join, relative, sep } from "node:path";
5
6
  const runtimeRequire = createRequire(import.meta.url);
7
+ /** Legacy per-process copy dirs (`hunch-tree-sitter-<pid>-*`), pruned when dead. */
6
8
  const COPY_PREFIX = "hunch-tree-sitter-";
9
+ /** Per-user cache of content-addressed copies (`hunch-tree-sitter-cache-<user>`). */
10
+ const CACHE_PREFIX = "hunch-tree-sitter-cache-";
11
+ /** A hash dir no process has used for this long is pruned. */
12
+ const CACHE_TTL_MS = 30 * 24 * 60 * 60 * 1000;
7
13
  const NATIVE_PACKAGES = [
8
14
  "tree-sitter",
9
15
  "tree-sitter-typescript",
@@ -84,21 +90,116 @@ function removeStaleCopies() {
84
90
  function environmentKey(packageName) {
85
91
  return `${packageName.toUpperCase().replaceAll("-", "_")}_PREBUILD`;
86
92
  }
87
- function copyNativeBinding(packageName, copyRoot, nodeGypBuild) {
93
+ /** The per-user copy cache. A binary copied to a fresh path costs a first-load
94
+ * assessment (macOS checks every never-seen dylib: ~2s per addon, six addons)
95
+ * on every process; the same bytes at a path already loaded once cost ~0ms. So
96
+ * copies are content-addressed and reused across processes instead of made per
97
+ * process. The directory is private to the user: an addon dlopen'd from a
98
+ * location another account can write would be code execution as this user. */
99
+ function cacheRoot() {
100
+ const uid = typeof process.getuid === "function" ? process.getuid() : null;
101
+ let user = String(uid ?? "");
102
+ if (!user) {
103
+ try {
104
+ user = userInfo().username;
105
+ }
106
+ catch {
107
+ user = "user";
108
+ }
109
+ }
110
+ const root = join(tmpdir(), `${CACHE_PREFIX}${user.replace(/[^A-Za-z0-9_.-]/g, "_")}`);
111
+ mkdirSync(root, { recursive: true, mode: 0o700 });
112
+ const st = lstatSync(root);
113
+ if (!st.isDirectory() || st.isSymbolicLink())
114
+ throw new Error(`tree-sitter copy cache is not a directory: ${root}`);
115
+ // POSIX: refuse a cache another account owns or can write into. (Windows has
116
+ // no uid and a per-user TEMP; its ACLs are not mode bits.)
117
+ if (uid !== null && (st.uid !== uid || (st.mode & 0o022) !== 0)) {
118
+ throw new Error(`tree-sitter copy cache is not private to this user: ${root}`);
119
+ }
120
+ return root;
121
+ }
122
+ function sha256(bytes) {
123
+ return createHash("sha256").update(bytes).digest("hex");
124
+ }
125
+ function copyNativeBinding(packageName, root, nodeGypBuild) {
88
126
  const packageRoot = dirname(runtimeRequire.resolve(`${packageName}/package.json`));
89
127
  const source = nodeGypBuild.path(packageRoot);
90
- const packageCopy = join(copyRoot, packageName);
128
+ const bytes = readFileSync(source);
129
+ const digest = sha256(bytes);
130
+ const hashDir = join(root, digest.slice(0, 32));
131
+ const packageCopy = join(hashDir, packageName);
91
132
  const normalized = source.replaceAll("\\", "/");
92
133
  const prebuild = /\/prebuilds\/([^/]+)\/[^/]+$/.exec(normalized);
93
134
  const destination = prebuild
94
135
  ? join(packageCopy, "prebuilds", prebuild[1], basename(source))
95
136
  : join(packageCopy, "build", "Release", basename(source));
96
- mkdirSync(dirname(destination), { recursive: true });
97
- copyFileSync(source, destination);
137
+ // Reuse only bytes that still hash to the installed binary: a truncated or
138
+ // replaced copy is rewritten, never loaded.
139
+ const intact = () => {
140
+ try {
141
+ return sha256(readFileSync(destination)) === digest;
142
+ }
143
+ catch {
144
+ return false;
145
+ }
146
+ };
147
+ // Mark the hash dir in use BEFORE verifying it, so a concurrent prune
148
+ // cannot remove it between the check and the dlopen.
149
+ try {
150
+ const now = new Date();
151
+ utimesSync(hashDir, now, now);
152
+ }
153
+ catch { /* not created yet */ }
154
+ if (!intact()) {
155
+ mkdirSync(dirname(destination), { recursive: true, mode: 0o700 });
156
+ // Atomic: a concurrent process sees the old file or the complete new one.
157
+ const partial = `${destination}.${process.pid}.${Date.now()}.tmp`;
158
+ try {
159
+ copyFileSync(source, partial);
160
+ renameSync(partial, destination);
161
+ }
162
+ catch (error) {
163
+ try {
164
+ rmSync(partial, { force: true });
165
+ }
166
+ catch { /* best effort */ }
167
+ // Windows refuses to replace a copy another process has loaded; that copy
168
+ // is then the same bytes, which is all this needs.
169
+ if (!intact())
170
+ throw error;
171
+ }
172
+ if (!intact())
173
+ throw new Error(`tree-sitter copy does not match its source: ${destination}`);
174
+ }
98
175
  return packageCopy;
99
176
  }
177
+ /** Hash dirs unused for CACHE_TTL_MS belong to replaced installs. Deleting one
178
+ * that is still loaded fails on Windows (retried by a later process) and is
179
+ * harmless on POSIX (the mapping outlives the directory entry). */
180
+ function pruneCache(root, keep) {
181
+ let entries;
182
+ try {
183
+ entries = readdirSync(root, { withFileTypes: true });
184
+ }
185
+ catch {
186
+ return;
187
+ }
188
+ const cutoff = Date.now() - CACHE_TTL_MS;
189
+ for (const entry of entries) {
190
+ if (!entry.isDirectory() || keep.has(entry.name))
191
+ continue;
192
+ const dir = join(root, entry.name);
193
+ try {
194
+ if (statSync(dir).mtimeMs < cutoff)
195
+ rmSync(dir, { recursive: true, force: true, maxRetries: 2 });
196
+ }
197
+ catch { /* in use or already gone */ }
198
+ }
199
+ }
100
200
  /** Load all native tree-sitter addons (the parser runtime + every grammar) from
101
- * process-owned temp copies. Windows keeps loaded `.node` files locked for the
201
+ * content-addressed copies in a per-user temp cache — never from the installed
202
+ * package. Windows keeps loaded `.node` files locked for the
102
203
  * process lifetime; redirecting the upstream loaders means npm can replace the
103
204
  * installed package during an active MCP session without killing that session
104
205
  * or falling back to a stale binary. */
@@ -126,25 +227,33 @@ function loadRuntime() {
126
227
  // already-loaded source-built addon slip past this guard and defeat the
127
228
  // file-lock isolation entirely (issue #52).
128
229
  const preloaded = Object.keys(runtimeRequire.cache).filter((path) => /(?:tree-sitter(?:-typescript|-python|-go|-php|-yaml)?|tree_sitter(?:_[a-z]+)*_binding)\.node$/.test(path)
129
- && !new RegExp(`(?:^|[\\\\/])${COPY_PREFIX}\\d+-`).test(path));
230
+ && !new RegExp(`(?:^|[\\\\/])(?:${COPY_PREFIX}\\d+-|${CACHE_PREFIX})`).test(path));
130
231
  if (preloaded.length) {
131
232
  throw new Error(`tree-sitter native addon was loaded before Hunch could isolate it: ${preloaded.join(", ")}`);
132
233
  }
133
234
  removeStaleCopies();
134
- const copyRoot = mkdtempSync(join(tmpdir(), `${COPY_PREFIX}${process.pid}-`));
235
+ // A refused cache (another account's or a group-writable dir squatting the
236
+ // predictable name) must not kill parsing: fall back to a private
237
+ // per-process copy dir, which removeStaleCopies prunes once the pid is gone.
238
+ let root;
239
+ let shared = true;
240
+ try {
241
+ root = cacheRoot();
242
+ }
243
+ catch {
244
+ root = mkdtempSync(join(tmpdir(), `${COPY_PREFIX}${process.pid}-`));
245
+ shared = false;
246
+ }
135
247
  const previous = new Map();
248
+ const used = new Set();
136
249
  try {
137
- // Resolved INSIDE the try: if node-gyp-build cannot be resolved (a partial
138
- // install, npm mid-swap) the catch below still removes the copy dir we just
139
- // created. Outside it, every retry — and failure is deliberately not
140
- // memoized, so a long-lived MCP server retries forever — leaked one empty
141
- // hunch-tree-sitter-<pid>-* dir that removeStaleCopies can never prune,
142
- // because it only prunes dirs whose pid is dead.
143
250
  const nodeGypBuild = runtimeRequire("node-gyp-build");
144
251
  for (const packageName of NATIVE_PACKAGES) {
145
252
  const key = environmentKey(packageName);
146
253
  previous.set(key, process.env[key]);
147
- process.env[key] = copyNativeBinding(packageName, copyRoot, nodeGypBuild);
254
+ const packageCopy = copyNativeBinding(packageName, root, nodeGypBuild);
255
+ used.add(relative(root, packageCopy).split(sep)[0]);
256
+ process.env[key] = packageCopy;
148
257
  }
149
258
  const Parser = runtimeRequire("tree-sitter");
150
259
  const languages = runtimeRequire("tree-sitter-typescript");
@@ -154,13 +263,6 @@ function loadRuntime() {
154
263
  const yaml = runtimeRequire("@tree-sitter-grammars/tree-sitter-yaml");
155
264
  runtime = { Parser, typescript: languages.typescript, tsx: languages.tsx, python, go, php: php.php, yaml };
156
265
  }
157
- catch (error) {
158
- try {
159
- rmSync(copyRoot, { recursive: true, force: true });
160
- }
161
- catch { /* best effort */ }
162
- throw error;
163
- }
164
266
  finally {
165
267
  for (const [key, value] of previous) {
166
268
  if (value === undefined)
@@ -169,12 +271,8 @@ function loadRuntime() {
169
271
  process.env[key] = value;
170
272
  }
171
273
  }
172
- process.once("exit", () => {
173
- try {
174
- rmSync(copyRoot, { recursive: true, force: true });
175
- }
176
- catch { /* next process prunes it */ }
177
- });
274
+ if (shared)
275
+ pruneCache(root, used);
178
276
  return runtime;
179
277
  }
180
278
  //# sourceMappingURL=nativeTreeSitter.js.map
@@ -1,4 +1,18 @@
1
1
  import type { HunchStore } from "../store/hunchStore.js";
2
+ import { groundingTemplate } from "../core/groundingLag.js";
3
+ /** Version of the block's PROSE. Bump it whenever renderHunchSection's wording
4
+ * changes. A block without the stamp is template 1. */
5
+ export declare const GROUNDING_TEMPLATE = 2;
6
+ export { groundingTemplate };
7
+ /** Never downgrade a block's prose (fnd_f6875f475b). The post-commit capture runs
8
+ * the PINNED hunch, which can be older than the renderer that wrote the committed
9
+ * block (a branch that develops the renderer, or a repo whose pin lags its docs).
10
+ * When the existing block carries a newer template than `section`, keep its prose
11
+ * and move only the record counts, so the counts stay true without reverting text
12
+ * this version did not author. The preserved block keeps its wiki line and those
13
+ * Top-invariants lines whose constraint this version still renders.
14
+ * Returns the section to write. */
15
+ export declare function preserveNewerTemplate(existing: string, section: string): string;
2
16
  /** Remove the managed HUNCH section (markers inclusive), leaving only the
3
17
  * user-authored surroundings. Lets a caller decide whether two versions of a
4
18
  * doc differ ONLY in generated content (the stranded-grounding heal,
@@ -8,6 +22,10 @@ export declare function renderHunchSection(store: HunchStore, root?: string): st
8
22
  /** Insert/replace the marker-delimited HUNCH section in a markdown doc, preserving
9
23
  * all user-authored content outside the markers. Shared by CLAUDE.md, AGENTS.md,
10
24
  * and .github/copilot-instructions.md so every assistant gets the same grounding. */
11
- export declare function upsertSection(file: string, section: string, fallbackTitle: string): string;
25
+ export declare function upsertSection(file: string, section: string, fallbackTitle: string, opts?: {
26
+ force?: boolean;
27
+ }): string;
12
28
  /** Insert/replace the HUNCH section in CLAUDE.md, preserving everything else. */
13
- export declare function updateClaudeMd(root: string, store: HunchStore): string;
29
+ export declare function updateClaudeMd(root: string, store: HunchStore, opts?: {
30
+ force?: boolean;
31
+ }): string;
@@ -8,9 +8,56 @@ import { writeFileAtomic } from "../core/io.js";
8
8
  import { basename, join, dirname } from "node:path";
9
9
  import { wikiSummary } from "../wiki/wiki.js";
10
10
  import { PolicyRepository } from "../constitution/repository.js";
11
- import { renderCountsMatch } from "../core/groundingLag.js";
11
+ import { renderCountsMatch, parseGroundingCounts, groundingTemplate } from "../core/groundingLag.js";
12
12
  const START = "<!-- HUNCH:START — auto-generated, do not edit by hand -->";
13
13
  const END = "<!-- HUNCH:END -->";
14
+ /** Version of the block's PROSE. Bump it whenever renderHunchSection's wording
15
+ * changes. A block without the stamp is template 1. */
16
+ export const GROUNDING_TEMPLATE = 2;
17
+ /** The pre-edit hook injects each in-scope invariant in full; the always-loaded
18
+ * list only has to name it (fnd_a65f71f38f, #369). Blocking statements are never
19
+ * clipped; others are cut at a word boundary within this many chars. */
20
+ const INVARIANT_CHARS = 200;
21
+ export { groundingTemplate };
22
+ /** Never downgrade a block's prose (fnd_f6875f475b). The post-commit capture runs
23
+ * the PINNED hunch, which can be older than the renderer that wrote the committed
24
+ * block (a branch that develops the renderer, or a repo whose pin lags its docs).
25
+ * When the existing block carries a newer template than `section`, keep its prose
26
+ * and move only the record counts, so the counts stay true without reverting text
27
+ * this version did not author. The preserved block keeps its wiki line and those
28
+ * Top-invariants lines whose constraint this version still renders.
29
+ * Returns the section to write. */
30
+ export function preserveNewerTemplate(existing, section) {
31
+ const iStart = existing.indexOf(START);
32
+ const iEnd = existing.indexOf(END);
33
+ if (iStart < 0 || iEnd <= iStart)
34
+ return section;
35
+ const current = existing.slice(iStart, iEnd + END.length);
36
+ if (groundingTemplate(current) <= groundingTemplate(section))
37
+ return section;
38
+ // Never republish an invariant this version no longer renders: a constraint that
39
+ // was retired, deleted, or moved to the private overlay drops out of the kept list.
40
+ const rendered = new Set(section.split(/\r?\n/).map((line) => INVARIANT_LINE_RE.exec(line)?.[1]).filter(Boolean));
41
+ const eol = current.includes("\r\n") ? "\r\n" : "\n";
42
+ const lines = current.split(/\r?\n/).filter((line) => {
43
+ const id = INVARIANT_LINE_RE.exec(line)?.[1];
44
+ return !id || rendered.has(id);
45
+ });
46
+ // A heading left with no invariants under it goes too.
47
+ const h = lines.findIndex((line) => line.startsWith(INVARIANTS_HEADING));
48
+ if (h >= 0 && !lines.slice(h + 1).some((line) => INVARIANT_LINE_RE.test(line))) {
49
+ lines.splice(lines[h - 1] === "" ? h - 1 : h, lines[h - 1] === "" ? 2 : 1);
50
+ }
51
+ const kept = lines.join(eol);
52
+ const have = parseGroundingCounts(kept);
53
+ const next = parseGroundingCounts(section);
54
+ // Unreadable counts leave the counts sentence as written rather than downgrading
55
+ // the block (two versions would flip-flop); `hunch grounding` reports countsReadable: false.
56
+ return have && next ? kept.replace(have.match, next.match) : kept;
57
+ }
58
+ const INVARIANTS_HEADING = "### ⛔ Top invariants";
59
+ /** A Top-invariants line; group 1 is its constraint id. */
60
+ const INVARIANT_LINE_RE = /^- \*\*\[[a-z]+\]\*\* .*; (con_[A-Za-z0-9_]+)\)_\r?$/;
14
61
  /** Remove the managed HUNCH section (markers inclusive), leaving only the
15
62
  * user-authored surroundings. Lets a caller decide whether two versions of a
16
63
  * doc differ ONLY in generated content (the stranded-grounding heal,
@@ -36,58 +83,27 @@ export function renderHunchSection(store, root) {
36
83
  policies: root ? new PolicyRepository(root, store).listPolicies({ publicOnly: true }).length : 0,
37
84
  findings: store.json.loadAll("findings").filter((f) => f.triage === "open" || f.triage === "accepted-risk" || f.triage === "scheduled").length,
38
85
  };
86
+ // Name the policy tools only where the MCP server registers them by default
87
+ // (src/mcp/toolset.ts: on once the repo holds a policy). Only committed evidence counts:
88
+ // env and the gitignored .hunch/config.json would make the committed block differ by
89
+ // machine. No root (a bare render) keeps the full list.
90
+ const policyTools = !root || counts.policies > 0;
39
91
  const lines = [];
40
92
  lines.push(START);
93
+ lines.push(`<!-- hunch:template ${GROUNDING_TEMPLATE} -->`);
41
94
  lines.push("## 🧠 Hunch (Engineering Memory)");
42
95
  lines.push("");
43
- lines.push("This repo has **Hunch** — a curated graph of *why* the code is the way it is " +
44
- "(decisions, bug history, invariants). It currently holds " +
45
- `${renderCountsMatch(counts)}.`);
46
- lines.push("");
47
- lines.push("**Consult Hunch via the `hunch_*` MCP tools — pick by MOMENT, not from memory:**");
48
- lines.push("");
49
- lines.push("**Orient (session/task start):**");
50
- lines.push("- If the host's prompt hook already opened the task and printed a task ID plus a `task verify` command, reuse that exact ID and command — do NOT call `hunch_task(action: \"start\")` for it. Otherwise start the task yourself: call `hunch_task(action: \"start\", title: <short task title>)` once and take `verification_argv` from its result. Each new prompt has its own ID; reuse the ID for follow-up work on the same task and never borrow another task's ID. This is task bookkeeping; `hunch_context` remains the first memory lookup. If reporting fails, continue the work and disclose the gap.");
51
- lines.push("- When the user asks to **update Hunch**, run `hunch update` from this repository root. It updates to the latest release and repairs all configured harness pins. Use `hunch update --global` to also update a global CLI alongside a repository dependency; reconnect active MCP sessions afterward.");
52
- lines.push("- `hunch_context(target, task_id)` — the minimal relevant slice for what you're about to do; a task phrase falls back to the closest graph matches. **Call FIRST** for memory. Include the current task ID on each context call so its contribution is inspectable.");
53
- lines.push("- `hunch_structure(target?)` — the indexed shape of the repo/dir/file/symbol — orient from the graph, not grep rounds.");
54
- lines.push("- `hunch_workspaces(view?)` — which worktrees and branches are open on which machine, what is merged and deletable (read-only; this machine live, others from memory). Call it instead of `git branch` / `git worktree list`; never delete on its say-so.");
55
- lines.push("- `hunch_runbook(task)` — the proven steps for a recurring task, before re-deriving them.");
56
- lines.push("- `hunch_escalations()` — the decisions only the HUMAN can make (including one exact imported ADR at a time, topic conflicts, and policy calls). Normally empty; when it isn't, ASK the user inline — an entry is a question, silence is never approval. Apply an ADR answer only through `hunch_review_imported_adr` with its printed source and review hashes.");
57
- lines.push("- `hunch now` (CLI) — recent decisions + the live roadmap; `hunch log` — the memory-move timeline (every capture/adopt/supersede/prune/repair, each revertable).");
58
- lines.push("");
59
- lines.push("**Before designing / choosing an approach:**");
60
- lines.push("- `hunch_why(target)` — why a file/symbol is shaped this way (decisions, bugs, constraints) — including what was already REJECTED.");
61
- lines.push("- `hunch_current_decision(topic)` — the one live answer for a topic (history + rejected included).");
62
- lines.push("- `hunch_bug_lineage(symptom_or_symbol)` — has this failed before? what was the root cause?");
63
- lines.push("- `hunch_compare(candidates)` — rank candidate branches/commits by fewest invariant hits.");
64
- lines.push("- `hunch_query(query)` — free-text search when nothing above fits.");
96
+ lines.push("This repo has **Hunch**, a graph of *why* the code is the way it is. It holds " +
97
+ `${renderCountsMatch(counts)}. Use the \`hunch_*\` MCP tools by moment:`);
65
98
  lines.push("");
66
- lines.push("**Before editing:**");
67
- lines.push("- `hunch_check_constraints(scope)` and `hunch_get_dependents(symbol)` / `hunch_blast_radius(target)` — invariants in scope + who you'd break. (The pre-edit hook injects this per file automatically; call these for PLANNING breadth.)");
68
- lines.push("- `hunch_findings(scope?)` — known-but-unfixed gaps in the area (past audits, measurements, incidents) so you inherit them instead of re-discovering them.");
69
- lines.push("");
70
- lines.push("**Before committing / merging:**");
71
- lines.push("- `hunch_conformance()` — does the code still SATISFY recorded intent? Run before and after a refactor.");
72
- lines.push("- `hunch_policy_evaluate(policy_id?, active_only?)` / `hunch_policy_plan(policy_id)` / `hunch_policy_card(policy_id)` / `hunch_policy_proof(policy_id)` — evaluate canonical policy, inspect the planned corpus, review the evidence/uncertainty card, and inspect raw replay receipts; only an explicit human activation grants authority.");
73
- lines.push("- `hunch_pr_impact(base?)` / `hunch_merge_verdict(...)` — a change's memory surface; would it re-open a closed bug?");
74
- lines.push("");
75
- lines.push("**Before the final response — make Hunch's contribution visible:**");
76
- lines.push("- When running a relevant check, use the exact launcher the prompt hook printed — or, on a host without one, the `verification_argv` returned by hunch_task start — followed by the check command and its arguments, from this worktree. It runs `hunch task verify <task_id> -- <command> [arguments]` using the same installation as MCP, avoiding stale global binaries. This retains the actual exit result and source snapshot; raw output is not stored. Do not rerun an expensive check solely for reporting; missing evidence stays unverified.");
77
- lines.push("- Include the current task_id when calling hunch_record_decision, hunch_record_correction, or hunch_record_finding. The save path records its actual memory home and verifies exact Git revisions when committing or pushing; never infer publication from a successful capture alone.");
78
- lines.push("- Before claiming an application, call `hunch_report(task_id)` and copy the exact occurrence_id, record_id and content_hash from application_references, adding an action you actually took. Never derive an occurrence ID by replacing a receipt prefix or use the task's scope hash as a record hash. If you did not apply a lesson, omit applications.");
79
- lines.push("- When this task actually used Hunch (a `hunch_*` call carrying the task_id, a verified check, Hunch hook context you acted on, or an application to claim), call `hunch_task(action: \"finish\", task_id, applications?)` and include the returned contribution_card in your final response without the user asking. Skip the finish call only when it used none of those AND the host's own stop hook closes the task and shows the evidence for you (its prompt-hook instruction says so); where no host hook closes the task, and for a task you started yourself with `hunch_task(action: \"start\")`, always finish it yourself. Copy the card verbatim, including its Evidence line (the command that renders the local report on demand) and the agent-reported label; the structured result contains the card even when the host hides text blocks. Do not replace it with a generic claim that Hunch helped. If presentation_enabled is false, omit the card. A delivered lesson or passing command alone does not prove causal impact.");
80
- lines.push("- If interrupted, finish with `outcome: \"interrupted\"` when possible. `hunch_report(task_id, html: true)` opens the evidence trail by generating a local file; it may contain private memory and is not a public export. If report tools are unavailable after an update, say so and reconnect the host rather than inventing a report.");
81
- lines.push("");
82
- lines.push("**Build the Constitution review queue:**");
83
- lines.push("- `hunch constitution bootstrap --since 90d --max-candidates 3` (CLI) — normalize recent structured human evidence into at most three non-active policy candidates; add `--history` for exact, human-identifier-grounded fix/revert deltas or explicit dependency retirements. Coincidence/ambiguity stays uncompilable; neither path grants authority.");
84
- lines.push("- `hunch constitution ingest --since 90d [--instructions] [--from export.json]` (CLI) — normalize corrections/failures plus bounded committed instructions/ADRs and strict local review/conversation/PR exports into Git-native evidence; raw prose is hash-only, unsupported intent remains uncompilable, and no policy is minted.");
85
- lines.push("");
86
- lines.push("**After deciding / when corrected:**");
87
- lines.push("- `hunch_capture_decision(topic?)` → `hunch_record_decision(...)` — interview first, then write; status `proposed` = roadmap intent (shows in `hunch now`).");
88
- lines.push("- `hunch_record_correction(...)` — a human correction becomes an ENFORCED rule (Never Twice), not a one-session memory.");
89
- lines.push("- `hunch_record_finding(...)` — an OBSERVATION with no code change (an audit that found a gap, a measured number, an incident) becomes durable memory anchored to a date + evidence; `/audit` runs the ritual.");
90
- lines.push("- `hunch_timeline(target)` — decision history when investigating how something evolved.");
99
+ lines.push("- **Start:** reuse the task ID and `task verify` command the prompt hook printed; with none, call `hunch_task(action: \"start\", title)` once. Then `hunch_context(target, task_id)` first. Orient with `hunch_structure`, `hunch_workspaces`, `hunch_runbook(task)`. Ask the user about each `hunch_escalations()` entry; silence is never approval.");
100
+ lines.push("- **Design:** `hunch_why(target)` (includes what was rejected), `hunch_current_decision(topic)`, `hunch_bug_lineage(symptom_or_symbol)`, `hunch_compare(candidates)`, `hunch_query(query)`.");
101
+ lines.push("- **Edit:** `hunch_check_constraints(scope)`, `hunch_get_dependents(symbol)` / `hunch_blast_radius(target)`, `hunch_findings(scope?)`.");
102
+ lines.push("- **Merge:** `hunch_conformance()`, `hunch_pr_impact(base?)`, `hunch_merge_verdict`." +
103
+ (policyTools ? " Policy review: `hunch_policy_evaluate`, `hunch_policy_plan(policy_id)`, `hunch_policy_card(policy_id)`, `hunch_policy_proof`; only a human activates a policy." : ""));
104
+ lines.push("- **Record:** `hunch_capture_decision` → `hunch_record_decision`; `hunch_record_correction` turns a human correction into an enforced rule; `hunch_record_finding` keeps an observation with evidence. Pass the task_id.");
105
+ lines.push("- **Finish:** run checks through the `task verify` launcher. If the task used Hunch, you started it, or no host stop hook closes it, call `hunch_task(action: \"finish\", task_id)` and show its card verbatim. Its `applications` schema carries the claim rules.");
106
+ lines.push("- To update Hunch, run `hunch update` from the repo root.");
91
107
  const wiki = root ? wikiSummary(root) : null;
92
108
  if (wiki) {
93
109
  lines.push("");
@@ -97,19 +113,21 @@ export function renderHunchSection(store, root) {
97
113
  lines.push("");
98
114
  lines.push("### ⛔ Top invariants (do not break)");
99
115
  for (const c of constraints) {
100
- lines.push(`- **[${c.severity}]** ${c.statement} _(scope: ${c.scope.join(", ") || "repo"}; ${c.id})_`);
116
+ lines.push(`- **[${c.severity}]** ${c.severity === "blocking" ? c.statement : clip(c.statement, INVARIANT_CHARS)} _(scope: ${c.scope.join(", ") || "repo"}; ${c.id})_`);
101
117
  }
102
118
  }
103
119
  lines.push("");
104
- lines.push("_Hunch updates itself from commits and test failures. Records carry provenance + confidence; treat low-confidence items as advisory._");
120
+ lines.push("_Records carry provenance and confidence; treat low-confidence items as advisory._");
105
121
  lines.push(END);
106
122
  return lines.join("\n");
107
123
  }
108
124
  /** Insert/replace the marker-delimited HUNCH section in a markdown doc, preserving
109
125
  * all user-authored content outside the markers. Shared by CLAUDE.md, AGENTS.md,
110
126
  * and .github/copilot-instructions.md so every assistant gets the same grounding. */
111
- export function upsertSection(file, section, fallbackTitle) {
127
+ export function upsertSection(file, section, fallbackTitle, opts) {
112
128
  let content = existsSync(file) ? readFileSync(file, "utf8") : "";
129
+ if (!opts?.force)
130
+ section = preserveNewerTemplate(content, section);
113
131
  const iStart = content.indexOf(START);
114
132
  const iEnd = content.indexOf(END);
115
133
  if (iStart >= 0 && iEnd > iStart) {
@@ -133,8 +151,16 @@ export function upsertSection(file, section, fallbackTitle) {
133
151
  return file;
134
152
  }
135
153
  /** Insert/replace the HUNCH section in CLAUDE.md, preserving everything else. */
136
- export function updateClaudeMd(root, store) {
137
- return upsertSection(join(root, "CLAUDE.md"), renderHunchSection(store, root), `# ${basename(root)}`);
154
+ export function updateClaudeMd(root, store, opts) {
155
+ return upsertSection(join(root, "CLAUDE.md"), renderHunchSection(store, root), `# ${basename(root)}`, opts);
156
+ }
157
+ /** Cut at the last whitespace at or before `max - 1` chars, so a word is never split. */
158
+ function clip(text, max) {
159
+ if (text.length <= max)
160
+ return text;
161
+ const head = text.slice(0, max);
162
+ const cut = head.search(/\s\S*$/);
163
+ return `${(cut > 0 ? text.slice(0, cut) : text.slice(0, max - 1)).trimEnd()}…`;
138
164
  }
139
165
  function sev(s) {
140
166
  return { blocking: 3, warning: 2, advisory: 1 }[s] ?? 0;
@@ -26,12 +26,16 @@ export declare function writeAntigravityWorkspaceMcp(root: string, inv: Invocati
26
26
  export declare function writeCodexConfig(root: string, inv: Invocation): string;
27
27
  /** AGENTS.md — the cross-tool ambient-instruction standard (Codex and a growing
28
28
  * set of assistants read it). Marker-delimited so user prose is preserved. */
29
- export declare function writeAgentsMd(root: string, store: HunchStore): string;
29
+ export declare function writeAgentsMd(root: string, store: HunchStore, opts?: GroundingWriteOptions): string;
30
30
  /** GitHub Copilot custom instructions (VS Code / github.com). Same grounding. */
31
- export declare function writeCopilotInstructions(root: string, store: HunchStore): string;
31
+ export declare function writeCopilotInstructions(root: string, store: HunchStore, opts?: GroundingWriteOptions): string;
32
+ /** `force` re-renders even a block written by a newer Hunch template. */
33
+ export interface GroundingWriteOptions {
34
+ force?: boolean;
35
+ }
32
36
  /** Cursor project rule (.mdc = frontmatter + body). `alwaysApply` keeps the Hunch
33
37
  * grounding in context for every request. Fully managed by Hunch (overwritten). */
34
- export declare function writeCursorRule(root: string, store: HunchStore): string;
38
+ export declare function writeCursorRule(root: string, store: HunchStore, opts?: GroundingWriteOptions): string;
35
39
  /** Windsurf (Cascade): .windsurf/mcp_config.json — same `mcpServers` shape as
36
40
  * Cursor. Repo-local (committed, shared via git) to match Hunch's other configs,
37
41
  * rather than the global ~/.codeium/windsurf path. Merges; refuses to clobber. */
@@ -43,7 +47,7 @@ export declare function windsurfMcpFile(home?: string): string | null;
43
47
  export declare function writeWindsurfGlobalMcp(inv: Invocation, home?: string): string | null;
44
48
  /** Windsurf project rule (.windsurf/rules/hunch.md). `trigger: always_on` keeps the
45
49
  * Hunch grounding in Cascade's context for every request. Fully managed (overwritten). */
46
- export declare function writeWindsurfRule(root: string, store: HunchStore): string;
50
+ export declare function writeWindsurfRule(root: string, store: HunchStore, opts?: GroundingWriteOptions): string;
47
51
  /** Cursor's hook API is beta, but its project-level config accepts this standard
48
52
  * event map. Context delivery is opportunistic; the always-on rule and MCP
49
53
  * registration remain the durable grounding path if a Cursor build suppresses
@@ -82,7 +86,7 @@ export declare const GROUNDING_DOC_PATHS: readonly string[];
82
86
  * assistant). Run by `hunch index` and non-hook `hunch sync` so a project silently
83
87
  * picks up generator fixes (e.g. corrected MCP tool param names) and fresh record
84
88
  * counts on the next refresh — no manual `hunch init`. */
85
- export declare function refreshExistingGrounding(root: string, store: HunchStore): string[];
89
+ export declare function refreshExistingGrounding(root: string, store: HunchStore, opts?: GroundingWriteOptions): string[];
86
90
  /** Capture-commit refresh: rewrite grounding docs that are git-clean OR whose only
87
91
  * divergence from HEAD is generated content, and return the absolute paths to fold
88
92
  * into the memory commit (commitAndPushHunch alsoStage). This keeps committed record
@@ -22,7 +22,7 @@ import { writeFileAtomic } from "../core/io.js";
22
22
  import { homedir } from "node:os";
23
23
  import { join, dirname } from "node:path";
24
24
  import { isHunchHookCommand } from "./hookmatch.js";
25
- import { renderHunchSection, stripManagedSection, upsertSection, updateClaudeMd } from "./claudemd.js";
25
+ import { renderHunchSection, stripManagedSection, upsertSection, updateClaudeMd, preserveNewerTemplate } from "./claudemd.js";
26
26
  import { headFileContent, isGitCleanPath } from "../extractors/git.js";
27
27
  import { parseJsonc } from "../core/jsonc.js";
28
28
  import { parse as parseToml } from "smol-toml";
@@ -271,18 +271,26 @@ export function writeCodexConfig(root, inv) {
271
271
  }
272
272
  /** AGENTS.md — the cross-tool ambient-instruction standard (Codex and a growing
273
273
  * set of assistants read it). Marker-delimited so user prose is preserved. */
274
- export function writeAgentsMd(root, store) {
275
- return upsertSection(join(root, "AGENTS.md"), renderHunchSection(store, root), "# AGENTS.md");
274
+ export function writeAgentsMd(root, store, opts) {
275
+ return upsertSection(join(root, "AGENTS.md"), renderHunchSection(store, root), "# AGENTS.md", opts);
276
276
  }
277
277
  /** GitHub Copilot custom instructions (VS Code / github.com). Same grounding. */
278
- export function writeCopilotInstructions(root, store) {
279
- return upsertSection(join(root, ".github", "copilot-instructions.md"), renderHunchSection(store, root), "# Copilot instructions");
278
+ export function writeCopilotInstructions(root, store, opts) {
279
+ return upsertSection(join(root, ".github", "copilot-instructions.md"), renderHunchSection(store, root), "# Copilot instructions", opts);
280
+ }
281
+ /** The block for a wholly-owned rule file, never downgrading newer prose (unless forced). */
282
+ function ownedSection(file, store, root, opts) {
283
+ const section = renderHunchSection(store, root);
284
+ if (opts?.force)
285
+ return section;
286
+ const existing = existsSync(file) ? readFileSync(file, "utf8") : "";
287
+ return preserveNewerTemplate(existing, section);
280
288
  }
281
289
  /** Cursor project rule (.mdc = frontmatter + body). `alwaysApply` keeps the Hunch
282
290
  * grounding in context for every request. Fully managed by Hunch (overwritten). */
283
- export function writeCursorRule(root, store) {
291
+ export function writeCursorRule(root, store, opts) {
284
292
  const file = join(root, ".cursor", "rules", "hunch.mdc");
285
- const body = `---\ndescription: Hunch engineering memory — consult the hunch_* MCP tools before editing\nalwaysApply: true\n---\n\n${renderHunchSection(store, root)}\n`;
293
+ const body = `---\ndescription: Hunch engineering memory — consult the hunch_* MCP tools before editing\nalwaysApply: true\n---\n\n${ownedSection(file, store, root, opts)}\n`;
286
294
  mkdirSync(dirname(file), { recursive: true });
287
295
  writeFileAtomic(file, body);
288
296
  return file;
@@ -315,9 +323,9 @@ export function writeWindsurfGlobalMcp(inv, home = homedir()) {
315
323
  }
316
324
  /** Windsurf project rule (.windsurf/rules/hunch.md). `trigger: always_on` keeps the
317
325
  * Hunch grounding in Cascade's context for every request. Fully managed (overwritten). */
318
- export function writeWindsurfRule(root, store) {
326
+ export function writeWindsurfRule(root, store, opts) {
319
327
  const file = join(root, ".windsurf", "rules", "hunch.md");
320
- const body = `---\ntrigger: always_on\ndescription: Hunch engineering memory — consult the hunch_* MCP tools before editing\n---\n\n${renderHunchSection(store, root)}\n`;
328
+ const body = `---\ntrigger: always_on\ndescription: Hunch engineering memory — consult the hunch_* MCP tools before editing\n---\n\n${ownedSection(file, store, root, opts)}\n`;
321
329
  mkdirSync(dirname(file), { recursive: true });
322
330
  writeFileAtomic(file, body);
323
331
  return file;
@@ -356,7 +364,8 @@ export function writeCodexHooks(root, inv) {
356
364
  return writeHookConfig(file, {
357
365
  SessionStart: [entry()],
358
366
  UserPromptSubmit: [entry()],
359
- PreToolUse: [entry("apply_patch")],
367
+ // The shell entries take the baseline a shell write is measured from.
368
+ PreToolUse: [entry("apply_patch|Bash|PowerShell|shell|local_shell")],
360
369
  // Codex's native command tool arrives as `Bash` (or `PowerShell` on
361
370
  // Windows), while older hosts may expose shell/local_shell names.
362
371
  PostToolUse: [entry("apply_patch|Bash|PowerShell|shell|local_shell")],
@@ -435,12 +444,16 @@ export function writeAntigravityHooks(root, inv) {
435
444
  * docs reflect that no engineering memory is published here (renderHunchSection
436
445
  * reads the public store only, so private records never leak into them). */
437
446
  export function regenerateGrounding(root, store) {
447
+ // FORCED: a block from a newer template would otherwise keep its prose — including
448
+ // its Top-invariants list — so a constraint that just moved to the overlay would stay
449
+ // printed in the committed public doc.
450
+ const force = { force: true };
438
451
  return [
439
- updateClaudeMd(root, store),
440
- writeAgentsMd(root, store),
441
- writeCopilotInstructions(root, store),
442
- writeCursorRule(root, store),
443
- writeWindsurfRule(root, store),
452
+ updateClaudeMd(root, store, force),
453
+ writeAgentsMd(root, store, force),
454
+ writeCopilotInstructions(root, store, force),
455
+ writeCursorRule(root, store, force),
456
+ writeWindsurfRule(root, store, force),
444
457
  ];
445
458
  }
446
459
  /** The five grounding docs, repo-relative (POSIX separators, as git prints them). */
@@ -451,13 +464,13 @@ export const GROUNDING_DOC_PATHS = Object.freeze([
451
464
  ".cursor/rules/hunch.mdc",
452
465
  ".windsurf/rules/hunch.md",
453
466
  ]);
454
- function groundingTargets(root, store) {
467
+ function groundingTargets(root, store, opts) {
455
468
  return [
456
- ["CLAUDE.md", () => updateClaudeMd(root, store)],
457
- ["AGENTS.md", () => writeAgentsMd(root, store)],
458
- [join(".github", "copilot-instructions.md"), () => writeCopilotInstructions(root, store)],
459
- [join(".cursor", "rules", "hunch.mdc"), () => writeCursorRule(root, store)],
460
- [join(".windsurf", "rules", "hunch.md"), () => writeWindsurfRule(root, store)],
469
+ ["CLAUDE.md", () => updateClaudeMd(root, store, opts)],
470
+ ["AGENTS.md", () => writeAgentsMd(root, store, opts)],
471
+ [join(".github", "copilot-instructions.md"), () => writeCopilotInstructions(root, store, opts)],
472
+ [join(".cursor", "rules", "hunch.mdc"), () => writeCursorRule(root, store, opts)],
473
+ [join(".windsurf", "rules", "hunch.md"), () => writeWindsurfRule(root, store, opts)],
461
474
  ];
462
475
  }
463
476
  /** Self-heal: refresh the Hunch section in each grounding doc that ALREADY exists,
@@ -466,9 +479,9 @@ function groundingTargets(root, store) {
466
479
  * assistant). Run by `hunch index` and non-hook `hunch sync` so a project silently
467
480
  * picks up generator fixes (e.g. corrected MCP tool param names) and fresh record
468
481
  * counts on the next refresh — no manual `hunch init`. */
469
- export function refreshExistingGrounding(root, store) {
482
+ export function refreshExistingGrounding(root, store, opts) {
470
483
  const changed = [];
471
- for (const [rel, write] of groundingTargets(root, store)) {
484
+ for (const [rel, write] of groundingTargets(root, store, opts)) {
472
485
  const file = join(root, rel);
473
486
  if (!existsSync(file))
474
487
  continue; // refresh-only: never scaffold a doc the project doesn't have
@@ -180,9 +180,10 @@ export function installClaudeHooks(root, hookCmd) {
180
180
  }
181
181
  }
182
182
  const keep = (arr) => (Array.isArray(arr) ? arr.map((entry) => withoutHunchCommands(entry, hookCmd)).filter((e) => e !== null) : []);
183
+ // Shell tools too: their PreToolUse takes the baseline a shell write is measured from.
183
184
  json.hooks.PreToolUse = [
184
185
  ...keep(json.hooks.PreToolUse),
185
- { matcher: "Edit|Write|MultiEdit", hooks: [{ type: "command", command: hookCmd }] },
186
+ { matcher: "Edit|Write|MultiEdit|Bash|PowerShell", hooks: [{ type: "command", command: hookCmd }] },
186
187
  ];
187
188
  json.hooks.UserPromptSubmit = [
188
189
  ...keep(json.hooks.UserPromptSubmit),