@davesheffer/hunch 0.20.0 → 0.21.1

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
@@ -179,6 +179,13 @@ the CI PR comment render *public-only*, so a private record can't reach a public
179
179
  sensitive items with `private: true` (`hunch_record_decision` / `hunch_record_correction`);
180
180
  post-commit synthesis can route there too, and `hunch private --auto-commit` (opt-in)
181
181
  auto-commits + pushes each capture to the private repo — recursion-safe, staging only `.hunch/`.
182
+
183
+ Already published a repo *with* its `.hunch/` memory and want it private after the fact?
184
+ `hunch private --repo <url> --migrate` does it in one shot: it **moves** your existing public
185
+ records into the overlay (union by id — nothing is lost), empties the public store, untracks +
186
+ gitignores the `.hunch/` memory tree, and regenerates the assistant grounding (CLAUDE.md, AGENTS.md,
187
+ …) so the repo becomes **code-only**. It commits the private overlay for you and prints the one
188
+ `git` command to commit the now-clean public repo.
182
189
  → [docs](https://hunch-pi.vercel.app/docs#private)
183
190
 
184
191
  ## Continuous learning (CI)
package/dist/cli/index.js CHANGED
@@ -28,16 +28,16 @@ import { indexRepo } from "../extractors/indexer.js";
28
28
  import { syncCommit, recordFailure, captureTestRun } from "../synthesis/synthesize.js";
29
29
  import { parseTestReport } from "../extractors/testreport.js";
30
30
  import { selectProvider } from "../synthesis/provider.js";
31
- import { isGitRepo, headSha, logSince, lastChangeDate, stagedFiles, commitFiles, asOfDate, stagedDiff, commitDiff, rangeFiles, rangeDiff, revExists, commitAndPushHunch } from "../extractors/git.js";
31
+ import { isGitRepo, headSha, logSince, lastChangeDate, stagedFiles, commitFiles, asOfDate, stagedDiff, commitDiff, rangeFiles, rangeDiff, revExists, commitAndPushHunch, gitUntrackCached } from "../extractors/git.js";
32
32
  import { renderText, renderMarkdown, reportFailsStrict } from "../core/checkreport.js";
33
33
  import { partitionReview, READY_MIN_GROUNDED } from "../core/reviewqueue.js";
34
34
  import { installPostCommitHook, installPreCommitHook } from "../integrations/hooks.js";
35
35
  import { installMergeDriver } from "../integrations/mergeDriver.js";
36
- import { ensureGitignore } from "../integrations/gitignore.js";
36
+ import { ensureGitignore, ignoreHunchMemory, HUNCH_MEMORY_DIRS } from "../integrations/gitignore.js";
37
37
  import { writeCiWorkflow } from "../integrations/ciAction.js";
38
38
  import { updateClaudeMd } from "../integrations/claudemd.js";
39
39
  import { writeMcpJson, writeSlashCommands, installClaudeHooks } from "../integrations/scaffold.js";
40
- import { scaffoldProviders } from "../integrations/providers.js";
40
+ import { scaffoldProviders, regenerateGrounding } from "../integrations/providers.js";
41
41
  import { healClaudeConfigCaseSplit } from "../integrations/claudeConfig.js";
42
42
  import { formatContext } from "../core/format.js";
43
43
  import { readConfig, writeConfig, FIRMNESS_LEVELS, isFirmness } from "../core/config.js";
@@ -46,6 +46,8 @@ import { draftTripwires, knownRepoDeps } from "../synthesis/tripwires.js";
46
46
  import { constraintId } from "../core/ids.js";
47
47
  import { readManifest, writeManifest, SCHEMA_VERSION } from "../core/migrate.js";
48
48
  import { mergeHunchJson } from "../store/merge.js";
49
+ import { movePublicMemoryToPrivate } from "../store/privateMigrate.js";
50
+ import { ENTITY_KINDS } from "../core/types.js";
49
51
  import { planCompaction } from "../store/compact.js";
50
52
  import { resolveInvocation } from "./invocation.js";
51
53
  const program = new Command();
@@ -288,6 +290,7 @@ program
288
290
  .option("--no-hook", "don't switch the post-commit hook to private sync")
289
291
  .option("--auto-commit", "opt-in: also git add+commit+push the private repo after each capture (post-commit hook AND MCP private writes)")
290
292
  .option("--sync", "flush the configured private store now (git add+commit+push) — catches records made via MCP between commits")
293
+ .option("--migrate", "ONE-TIME: move this repo's EXISTING public .hunch memory into the overlay, then make the public repo code-only — untrack + gitignore the memory tree and regenerate grounding so no memory is published here")
291
294
  .action((dir, opts) => {
292
295
  const root = findRoot();
293
296
  if (opts.sync) {
@@ -334,9 +337,36 @@ program
334
337
  const h = installPostCommitHook(root, inv.shell, { private: true, commit: opts.autoCommit });
335
338
  hookNote = ` ✓ post-commit hook ${h.action} — captured decisions route here${opts.autoCommit ? " (auto-commit+push on)" : ""}\n`;
336
339
  }
340
+ // 5) one-time migration: MOVE existing public memory INTO the overlay, then make
341
+ // THIS repo code-only. Records are absorbed (union by id) BEFORE the public
342
+ // store is emptied, so an interrupted run never loses memory.
343
+ let migrateNote = "";
344
+ if (opts.migrate) {
345
+ const pub = new JsonStore(paths);
346
+ const priv = new JsonStore(hunchPathsForDir(hunchDir));
347
+ const res = movePublicMemoryToPrivate(pub, priv);
348
+ for (const kind of ENTITY_KINDS)
349
+ pub.dropAll(kind); // public store now empty on disk
350
+ if (isGitRepo(root))
351
+ gitUntrackCached(root, HUNCH_MEMORY_DIRS); // stop publishing it
352
+ ignoreHunchMemory(root);
353
+ const gstore = new HunchStore(paths); // public store is empty → grounding shows no memory
354
+ const grounding = regenerateGrounding(root, gstore);
355
+ gstore.close();
356
+ commitAndPushHunch(hunchDir, "hunch: absorb public memory into private overlay"); // durable
357
+ const breakdown = Object.entries(res.moved).map(([k, n]) => `${n} ${k}`).join(", ") || "0 records";
358
+ migrateNote =
359
+ ` ✓ migrated public memory → overlay (${breakdown}); public store emptied\n` +
360
+ ` ✓ untracked + gitignored the .hunch memory tree — this repo is now CODE-ONLY\n` +
361
+ ` ✓ regenerated ${grounding.length} grounding file(s) (CLAUDE.md, AGENTS.md, …) — no public memory shown\n` +
362
+ ` ✓ committed + pushed the private overlay (best-effort)\n` +
363
+ ` next: review, then commit the PUBLIC repo:\n` +
364
+ ` git add -A && git commit -m "chore: move engineering memory to a private overlay" && git push\n`;
365
+ }
337
366
  console.log(`✓ private overlay enabled → ${hunchDir}\n` +
338
367
  ` ✓ recorded in .hunch/local.json (gitignored) — auto-detected, no env var or shell-profile edit\n` +
339
368
  hookNote +
369
+ migrateNote +
340
370
  ` record sensitive items with private:true (hunch_record_decision / hunch_record_correction)\n` +
341
371
  ` override per-shell with HUNCH_PRIVATE_DIR; CI / public PR comments stay public-only.`);
342
372
  });
@@ -79,7 +79,7 @@ export const RetiredSignalSchema = z.object({
79
79
  * existed in code, so its prose is turned into a testable set/regex. Carries its
80
80
  * OWN provenance, separate from the decision's: an LLM may DRAFT a tripwire
81
81
  * (advisory only); only a `human_confirmed` tripwire may BLOCK a commit — for every
82
- * tier. One predictable rule (dec_a466655539). See docs/veto.md. */
82
+ * tier. One predictable rule (dec_a466655539). */
83
83
  export const RejectedTripwireSchema = z.object({
84
84
  alternative: z.string().describe("the rejected approach's human text — printed verbatim in the receipt"),
85
85
  scope: z.array(z.string()).default([]).describe("glob(s) it applies to, e.g. vscode-extension/**"),
@@ -39,6 +39,18 @@ export function commitAndPushHunch(hunchDir, message) {
39
39
  export function headSha(cwd) {
40
40
  return gitSafe(["rev-parse", "HEAD"], cwd);
41
41
  }
42
+ /** Stop tracking `paths` in git (remove from the INDEX only — keep the working-tree
43
+ * files). Used by `hunch private --migrate` to un-publish the .hunch memory tree
44
+ * without deleting it locally. `--ignore-unmatch` makes an already-untracked path a
45
+ * no-op rather than an error; best-effort (a non-repo dir just no-ops). */
46
+ export function gitUntrackCached(cwd, paths) {
47
+ if (paths.length === 0)
48
+ return;
49
+ try {
50
+ execFileSync("git", ["-C", cwd, "rm", "-r", "--cached", "--quiet", "--ignore-unmatch", "--", ...paths], { stdio: "ignore" });
51
+ }
52
+ catch { /* best-effort: not a repo / nothing tracked */ }
53
+ }
42
54
  /** Resolve any commit-ish (short sha / HEAD / branch) to a canonical full sha.
43
55
  * Returns the input unchanged if it can't be resolved (e.g. not a git repo). */
44
56
  export function revParse(ref, cwd) {
@@ -27,24 +27,54 @@ const ENTRIES = [
27
27
  // fully separate private repo.
28
28
  ".hunch-private/",
29
29
  ];
30
- export function ensureGitignore(root) {
30
+ // `hunch private --migrate` makes the repo CODE-ONLY: the engineering-memory tree
31
+ // (one curated subdir per kind) moves to a private overlay and must stop being
32
+ // published here. The derived index + pointer are already covered by the block
33
+ // above; this is a SEPARATE marked block so re-running `private` doesn't have to
34
+ // re-touch the block above, and so the two concerns read clearly in the file.
35
+ const MEM_MARK = "# >>> hunch private-only (engineering memory kept in a private overlay; not published here) >>>";
36
+ const MEM_END = "# <<< hunch private-only <<<";
37
+ const MEM_ENTRIES = [
38
+ ".hunch/decisions/",
39
+ ".hunch/bugs/",
40
+ ".hunch/constraints/",
41
+ ".hunch/components/",
42
+ ".hunch/symbols/",
43
+ ".hunch/edges/",
44
+ ];
45
+ /** Idempotent + merge-safe append of one marked block (con_8460b6770f): never
46
+ * rewrites the user's existing entries, and re-running is a no-op once the block
47
+ * (or an equivalent hand-written set of the same patterns) is present. */
48
+ function appendBlock(root, mark, entries, end) {
31
49
  const path = join(root, ".gitignore");
32
- const block = [MARK, ...ENTRIES, END].join("\n");
50
+ const block = [mark, ...entries, end].join("\n");
33
51
  if (!existsSync(path)) {
34
52
  writeFileSync(path, block + "\n");
35
53
  return { path, action: "created" };
36
54
  }
37
55
  const cur = readFileSync(path, "utf8");
38
- if (cur.includes(MARK))
56
+ if (cur.includes(mark))
39
57
  return { path, action: "unchanged" }; // already managed
40
58
  // Already covered by the user's OWN entries (e.g. a hand-written, commented
41
59
  // section listing the same patterns)? Don't append a redundant managed block —
42
60
  // that would leave two copies of every ignore. Keep the .gitignore clean.
43
61
  const lines = new Set(cur.split("\n").map((l) => l.trim()));
44
- if (ENTRIES.every((e) => lines.has(e)))
62
+ if (entries.every((e) => lines.has(e)))
45
63
  return { path, action: "unchanged" };
46
64
  const sep = cur.endsWith("\n") || cur.length === 0 ? "" : "\n";
47
65
  writeFileSync(path, `${cur}${sep}${block}\n`);
48
66
  return { path, action: "appended" };
49
67
  }
68
+ export function ensureGitignore(root) {
69
+ return appendBlock(root, MARK, ENTRIES, END);
70
+ }
71
+ /** Ignore the engineering-memory tree so a private-migrated repo stays code-only.
72
+ * The kind subdirs the user's records live in (decisions/, bugs/, …) move to the
73
+ * private overlay; this stops git from re-publishing them. The `.hunch/` dir, its
74
+ * manifest, and the local.json pointer are left tracked/managed elsewhere. */
75
+ export function ignoreHunchMemory(root) {
76
+ return appendBlock(root, MEM_MARK, MEM_ENTRIES, MEM_END);
77
+ }
78
+ /** The .hunch memory subdirs un-published by a private migration (git pathspecs). */
79
+ export const HUNCH_MEMORY_DIRS = MEM_ENTRIES.map((e) => e.replace(/\/$/, ""));
50
80
  //# sourceMappingURL=gitignore.js.map
@@ -19,7 +19,7 @@
19
19
  */
20
20
  import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
21
21
  import { join, dirname } from "node:path";
22
- import { renderHunchSection, upsertSection } from "./claudemd.js";
22
+ import { renderHunchSection, upsertSection, updateClaudeMd } from "./claudemd.js";
23
23
  /** Strip // line and block comments + trailing commas (JSONC → JSON). String-aware
24
24
  * (double-quoted, with escapes) so a // inside a value isn't mangled. VS Code's
25
25
  * .vscode/mcp.json is JSONC, so we must tolerate comments. */
@@ -226,6 +226,21 @@ export function writeWindsurfRule(root, store) {
226
226
  writeFileSync(file, body);
227
227
  return file;
228
228
  }
229
+ /** Rewrite the auto-maintained Hunch section in EVERY assistant grounding doc
230
+ * (CLAUDE.md, AGENTS.md, Copilot instructions, Cursor + Windsurf rules) from the
231
+ * current store — without touching the MCP/provider config files. `hunch private
232
+ * --migrate` calls this AFTER emptying the public store so the committed public
233
+ * docs reflect that no engineering memory is published here (renderHunchSection
234
+ * reads the public store only, so private records never leak into them). */
235
+ export function regenerateGrounding(root, store) {
236
+ return [
237
+ updateClaudeMd(root, store),
238
+ writeAgentsMd(root, store),
239
+ writeCopilotInstructions(root, store),
240
+ writeCursorRule(root, store),
241
+ writeWindsurfRule(root, store),
242
+ ];
243
+ }
229
244
  /** Scaffold MCP config + grounding for all supported assistants. Returns a
230
245
  * per-assistant summary for `hunch init` to print. Each assistant is isolated:
231
246
  * a writer that refuses to clobber a malformed file degrades to a warning rather
@@ -288,9 +288,13 @@ export function buildServer(root) {
288
288
  else
289
289
  store.json.put("decisions", rec);
290
290
  // Invalidate, don't delete: closing the superseded decision's valid-time window
291
- // (+ a supersedes edge) preserves the why-it-changed trail. Supersede operates on
292
- // the public store, so skip it for a private record (a v1 limitation, not a leak).
293
- const superseded = decision.supersedes && !decision.private ? store.supersede(decision.supersedes, rec) : null;
291
+ // (+ a supersedes edge) preserves the why-it-changed trail. Route the close to the
292
+ // same store the new record landed in a private decision supersedes within the
293
+ // private overlay; a public one in the committed store. A private write never
294
+ // mutates the public store.
295
+ const superseded = decision.supersedes
296
+ ? (decision.private ? store.supersedePrivate(decision.supersedes, rec) : store.supersede(decision.supersedes, rec))
297
+ : null;
294
298
  store.reindex();
295
299
  // Auto-flush the private repo when configured (hunch private --auto-commit), so a
296
300
  // record made via MCP between public commits is committed+pushed immediately.
@@ -615,7 +615,22 @@ export class HunchStore {
615
615
  * and write a `supersedes` edge. Returns the updated old decision, or null if it
616
616
  * doesn't exist. All writes are atomic via json.put (con_902759b3dc). */
617
617
  supersede(oldId, by) {
618
- const old = this.json.get("decisions", oldId);
618
+ return this.supersedeIn(this.json, oldId, by);
619
+ }
620
+ /** Private-overlay counterpart of `supersede`: close + link the old decision inside
621
+ * the HUNCH_PRIVATE_DIR store, so a PRIVATE decision can supersede another private
622
+ * one (the MCP record path is private→private). A private write never mutates the
623
+ * committed public store. Returns null if no private store is configured or the old
624
+ * record isn't in it. */
625
+ supersedePrivate(oldId, by) {
626
+ if (!this.privateJson)
627
+ return null;
628
+ this.privateJson.ensureDirs();
629
+ return this.supersedeIn(this.privateJson, oldId, by);
630
+ }
631
+ /** Shared body for supersede / supersedePrivate against a specific store. */
632
+ supersedeIn(json, oldId, by) {
633
+ const old = json.get("decisions", oldId);
619
634
  if (!old || old.id === by.id)
620
635
  return null;
621
636
  const closed = {
@@ -624,7 +639,7 @@ export class HunchStore {
624
639
  superseded_by: by.id,
625
640
  valid_to: old.valid_to ?? by.valid_from ?? null,
626
641
  };
627
- this.json.put("decisions", closed);
642
+ json.put("decisions", closed);
628
643
  const edge = {
629
644
  id: edgeId(by.id, oldId, "supersedes"),
630
645
  from: by.id,
@@ -634,7 +649,7 @@ export class HunchStore {
634
649
  strength: 1,
635
650
  provenance: { source: "derived", confidence: 1, evidence: [by.id, oldId] },
636
651
  };
637
- this.json.put("edges", edge);
652
+ json.put("edges", edge);
638
653
  return closed;
639
654
  }
640
655
  /** Regression Guard: detect a change RE-INTRODUCING something an in-force
@@ -690,7 +705,7 @@ export class HunchStore {
690
705
  * never did. Precision-first ladder (dep > symbol > pattern); the semantic tier is
691
706
  * advisory and lives elsewhere. A hit `blocks` only when isVetoBlocker passes (a
692
707
  * human-confirmed tripwire on an in-force, non-stale decision — dec_a466655539).
693
- * Read-only; shared by buildCheckReport. See docs/veto.md. */
708
+ * Read-only; shared by buildCheckReport. */
694
709
  vetoHits(an, files, staleDecisions = new Set()) {
695
710
  const addedDeps = new Set(an.addedDeps);
696
711
  const out = [];
@@ -229,6 +229,24 @@ export class JsonStore {
229
229
  this.invalidate(kind);
230
230
  return true;
231
231
  }
232
+ /** Remove EVERY record of a kind from disk (the kind dir's JSON files), keeping
233
+ * the dir itself so the layout/manifest survive. Used by `hunch private --migrate`
234
+ * to empty the PUBLIC store after its records have been moved into the private
235
+ * overlay. Returns the number of files removed. Invalidates the memoized load. */
236
+ dropAll(kind) {
237
+ const dir = this.paths.dir(kind);
238
+ if (!existsSync(dir))
239
+ return 0;
240
+ let n = 0;
241
+ for (const name of readdirSync(dir)) {
242
+ if (name.endsWith(".json")) {
243
+ rmSync(join(dir, name));
244
+ n++;
245
+ }
246
+ }
247
+ this.invalidate(kind);
248
+ return n;
249
+ }
232
250
  /** Persist a schema migration: rewrite every LOADABLE record in its current shape.
233
251
  * A record that still fails validation after migration is kept untouched (never
234
252
  * deleted) and counted as `skipped`, so migration can't lose data. The caller
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Move a repo's PUBLIC engineering memory into its PRIVATE overlay, for
3
+ * `hunch private --migrate` (turn a public-by-default repo into a code-only public
4
+ * repo whose memory lives in a separate private store).
5
+ *
6
+ * The merge is a UNION BY ID: every public record is absorbed into the private
7
+ * store, and a record that already exists on both sides (e.g. a decision present
8
+ * in both) is resolved by the same precedence the merge driver uses (human-confirmed
9
+ * › higher confidence › more recent › deterministic tiebreak). Private-only records
10
+ * are preserved untouched. This NEVER deletes from the public store — the CLI empties
11
+ * it separately (JsonStore.dropAll) only after this returns, so an interrupted move
12
+ * can't lose records.
13
+ */
14
+ import { ENTITY_KINDS } from "../core/types.js";
15
+ import { mergeRecordsById } from "./merge.js";
16
+ /** Union every public record into `priv` (by id). Returns what was absorbed.
17
+ * Idempotent: re-running with the same input rewrites identical content. */
18
+ export function movePublicMemoryToPrivate(pub, priv) {
19
+ priv.ensureDirs();
20
+ const moved = {};
21
+ let total = 0;
22
+ for (const kind of ENTITY_KINDS) {
23
+ const pubRecs = pub.loadAll(kind);
24
+ if (pubRecs.length === 0)
25
+ continue;
26
+ const privRecs = priv.loadAll(kind);
27
+ // base=[] so both sides' records are treated as additions; collisions resolved
28
+ // by pickWinner. Public is "theirs", private "ours" — order is irrelevant since
29
+ // pickWinner is symmetric for equal-provenance records.
30
+ const merged = mergeRecordsById([], privRecs, pubRecs);
31
+ // replaceAll re-validates each record against its schema (records loaded here
32
+ // are already current-schema), and writes atomically per file (con_902759b3dc).
33
+ priv.replaceAll(kind, merged);
34
+ moved[kind] = pubRecs.length;
35
+ total += pubRecs.length;
36
+ }
37
+ return { moved, total };
38
+ }
39
+ //# sourceMappingURL=privateMigrate.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davesheffer/hunch",
3
- "version": "0.20.0",
3
+ "version": "0.21.1",
4
4
  "license": "Apache-2.0",
5
5
  "author": "Dave Sheffer <dave.sheffer1@gmail.com>",
6
6
  "description": "Hunch — an Engineering Memory OS: a persistent, git-native reasoning graph over a codebase, exposed to Claude Code via MCP.",