hypomnema 1.4.2 → 1.5.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.
Files changed (70) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.ko.md +35 -18
  4. package/README.md +25 -8
  5. package/commands/audit.md +2 -2
  6. package/commands/crystallize.md +22 -14
  7. package/commands/doctor.md +1 -1
  8. package/commands/feedback.md +2 -2
  9. package/commands/graph.md +1 -1
  10. package/commands/ingest.md +1 -1
  11. package/commands/init.md +2 -2
  12. package/commands/lint.md +1 -1
  13. package/commands/query.md +1 -1
  14. package/commands/rename.md +1 -1
  15. package/commands/resume.md +1 -1
  16. package/commands/stats.md +1 -1
  17. package/commands/uninstall.md +4 -2
  18. package/commands/upgrade.md +1 -1
  19. package/commands/verify.md +1 -1
  20. package/docs/ARCHITECTURE.md +4 -4
  21. package/docs/CONTRIBUTING.md +8 -7
  22. package/hooks/hypo-auto-commit.mjs +2 -2
  23. package/hooks/hypo-auto-minimal-crystallize.mjs +7 -7
  24. package/hooks/hypo-compact-guard.mjs +2 -2
  25. package/hooks/hypo-cwd-change.mjs +27 -37
  26. package/hooks/hypo-lookup.mjs +37 -2
  27. package/hooks/hypo-personal-check.mjs +37 -22
  28. package/hooks/hypo-session-end.mjs +1 -1
  29. package/hooks/hypo-session-record.mjs +2 -2
  30. package/hooks/hypo-session-start.mjs +62 -42
  31. package/hooks/hypo-shared.mjs +430 -85
  32. package/hooks/version-check.mjs +1 -1
  33. package/package.json +5 -1
  34. package/scripts/check-tracker-ids.mjs +69 -31
  35. package/scripts/crystallize.mjs +151 -53
  36. package/scripts/doctor.mjs +7 -7
  37. package/scripts/feedback-sync.mjs +5 -5
  38. package/scripts/feedback.mjs +9 -10
  39. package/scripts/init.mjs +7 -7
  40. package/scripts/lib/check-tracker-ids.mjs +90 -32
  41. package/scripts/lib/design-history-stale.mjs +1 -1
  42. package/scripts/lib/extensions.mjs +7 -7
  43. package/scripts/lib/failure-type.mjs +1 -1
  44. package/scripts/lib/feedback-scope.mjs +1 -1
  45. package/scripts/lib/page-usage.mjs +141 -0
  46. package/scripts/lib/project-create.mjs +2 -2
  47. package/scripts/lib/template-schema-version.mjs +1 -1
  48. package/scripts/lib/wd-match.mjs +181 -0
  49. package/scripts/lib/wikilink.mjs +1 -1
  50. package/scripts/lint.mjs +5 -5
  51. package/scripts/rename.mjs +1 -1
  52. package/scripts/resume.mjs +20 -22
  53. package/scripts/session-audit.mjs +1 -1
  54. package/scripts/stats.mjs +3 -3
  55. package/scripts/uninstall.mjs +3 -3
  56. package/scripts/upgrade.mjs +85 -18
  57. package/skills/crystallize/SKILL.md +17 -11
  58. package/skills/graph/SKILL.md +3 -3
  59. package/skills/ingest/SKILL.md +5 -5
  60. package/skills/lint/SKILL.md +3 -3
  61. package/skills/query/SKILL.md +3 -3
  62. package/skills/verify/SKILL.md +3 -3
  63. package/templates/SCHEMA.md +1 -1
  64. package/templates/hypo-config.md +1 -1
  65. package/templates/hypo-guide.md +21 -15
  66. package/templates/projects/_template/hot.md +1 -1
  67. package/scripts/fix-status-verify.mjs +0 -256
  68. package/scripts/lib/adr-corpus.mjs +0 -79
  69. package/scripts/lib/fix-manifest.mjs +0 -109
  70. package/scripts/lib/fix-status-verify.mjs +0 -439
@@ -0,0 +1,181 @@
1
+ /**
2
+ * scripts/lib/wd-match.mjs — shared cwd ↔ working_dir project matcher.
3
+ *
4
+ * The four readers (resume.mjs, hooks/hypo-shared.mjs, hooks/hypo-session-start.mjs,
5
+ * hooks/hypo-cwd-change.mjs) each used to inline the same prefix-match. This
6
+ * module is the single source of truth they delegate the path logic to, so the
7
+ * cross-machine basename fallback below is implemented (and tested) once.
8
+ *
9
+ * The cross-machine problem: a git-synced vault carries one machine's absolute
10
+ * working_dir to every machine, so on a second machine cwd never prefix-matches
11
+ * and cwd-first resume silently degrades to recency. The basename tier recovers
12
+ * the match WITHOUT a synced map or a writer: when the absolute prefix fails, a
13
+ * cwd ancestor whose directory name is a GLOBALLY unique project working_dir
14
+ * basename identifies the project. Unique-only, so a shared repo dirname fails
15
+ * closed back to recency (the original prefix-only behavior).
16
+ *
17
+ * The matcher (pickProjectByCwd) is PURE — no fs. collectProjectWorkingDirs is
18
+ * the disk companion that builds the project universe the matcher reasons over.
19
+ */
20
+
21
+ import { homedir } from 'os';
22
+ import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
23
+ import { join } from 'path';
24
+ import { parseFrontmatter } from './frontmatter.mjs';
25
+
26
+ // Expand a leading `~`/`~/`, strip trailing slashes. Mirrors the inline
27
+ // expansion the readers did, kept here so every caller normalizes identically.
28
+ // Returns null for empty/falsy input.
29
+ export function normalizeWorkingDir(p) {
30
+ if (!p) return null;
31
+ let s = String(p).trim();
32
+ if (s === '~') s = homedir();
33
+ else if (s.startsWith('~/')) s = `${homedir()}/${s.slice(2)}`;
34
+ s = s.replace(/\/+$/, '');
35
+ return s || null;
36
+ }
37
+
38
+ // macOS (APFS/HFS+ default) and Windows match paths case-insensitively, so a
39
+ // case-only difference between cwd and a recorded working_dir is the SAME dir
40
+ // there and a genuinely different dir on Linux. Fold only on those platforms:
41
+ // on Linux folding stays off, preserving the exact original comparison.
42
+ export function isCaseInsensitiveFs(platform = process.platform) {
43
+ return platform === 'darwin' || platform === 'win32';
44
+ }
45
+
46
+ function casefold(s, ci) {
47
+ return ci ? s.toLowerCase() : s;
48
+ }
49
+
50
+ // Trailing path segment (basename) without importing path — paths here are
51
+ // already normalized (no trailing slash). Returns '' for a bare root.
52
+ function lastSegment(p) {
53
+ const i = p.lastIndexOf('/');
54
+ return i < 0 ? p : p.slice(i + 1);
55
+ }
56
+
57
+ /**
58
+ * Resolve which project a cwd belongs to.
59
+ *
60
+ * @param {{slug:string, workingDir:string|null|undefined}[]} projects
61
+ * EVERY project with an index.md (the universe). The basename-uniqueness gate
62
+ * is computed over all of them, so an unrelated project sharing a dirname
63
+ * correctly disqualifies the basename tier (fail closed).
64
+ * @param {string} cwd process.cwd()
65
+ * @param {object} [opts]
66
+ * @param {string[]|null} [opts.eligible] restrict the ANSWER to these slugs
67
+ * (e.g. resume's hot.md rows); uniqueness is still judged over all `projects`.
68
+ * null = any project is an acceptable answer.
69
+ * @param {string|null} [opts.realpathCwd] realpathSync(cwd), if the caller
70
+ * resolved symlinks. Tried in addition to the raw cwd.
71
+ * @param {boolean} [opts.caseInsensitive] override FS case sensitivity (tests).
72
+ * @returns {string|null} matched slug, or null when nothing matches.
73
+ */
74
+ export function pickProjectByCwd(projects, cwd, opts = {}) {
75
+ const { eligible = null, realpathCwd = null, caseInsensitive = isCaseInsensitiveFs() } = opts;
76
+ if (!cwd && !realpathCwd) return null;
77
+
78
+ const eligibleSet = eligible ? new Set(eligible) : null;
79
+ const isEligible = (slug) => !eligibleSet || eligibleSet.has(slug);
80
+
81
+ // Universe of normalized (slug, path) entries with a real working_dir.
82
+ const entries = [];
83
+ for (const p of projects) {
84
+ const path = normalizeWorkingDir(p.workingDir);
85
+ if (path) entries.push({ slug: p.slug, path });
86
+ }
87
+ if (entries.length === 0) return null;
88
+
89
+ // cwd variants in PRIORITY order: raw first, realpath (symlink-resolved) only
90
+ // as a fallback. A raw-cwd match must not be overridden by a realpath match
91
+ // (preserves the original single-cwd behavior; realpath only rescues misses).
92
+ const cwds = [];
93
+ for (const c of [cwd, realpathCwd]) {
94
+ const n = normalizeWorkingDir(c);
95
+ if (n && !cwds.includes(n)) cwds.push(n);
96
+ }
97
+
98
+ // ── Tier 1: longest absolute prefix (the original behavior) ────────────────
99
+ // cwd === path or cwd under path/. Longest path wins so /repo/sub beats /repo.
100
+ // The FIRST cwd variant that yields any prefix match wins (raw before realpath).
101
+ for (const c of cwds) {
102
+ const cf = casefold(c, caseInsensitive);
103
+ let bestSlug = null;
104
+ let bestLen = -1;
105
+ for (const e of entries) {
106
+ if (!isEligible(e.slug)) continue;
107
+ const pf = casefold(e.path, caseInsensitive);
108
+ if ((cf === pf || cf.startsWith(`${pf}/`)) && e.path.length > bestLen) {
109
+ bestLen = e.path.length;
110
+ bestSlug = e.slug;
111
+ }
112
+ }
113
+ if (bestSlug) return bestSlug;
114
+ }
115
+
116
+ // ── Tier 2: globally-unique basename of a cwd ancestor (cross-machine) ─────
117
+ // Count basenames across the WHOLE universe. A basename is usable only when
118
+ // exactly one project carries it, so a shared dirname falls through to null.
119
+ const byBasename = new Map(); // folded basename -> { slug, count }
120
+ for (const e of entries) {
121
+ const b = casefold(lastSegment(e.path), caseInsensitive);
122
+ if (!b) continue;
123
+ const hit = byBasename.get(b);
124
+ if (hit) hit.count += 1;
125
+ else byBasename.set(b, { slug: e.slug, count: 1 });
126
+ }
127
+
128
+ // For each cwd variant (raw first), collect every ancestor whose basename is a
129
+ // globally-unique, eligible project basename. Use it ONLY when the whole chain
130
+ // points at exactly ONE project: if two different projects match along the
131
+ // path (e.g. cwd /x/monorepo/api with both `monorepo` and `api` registered),
132
+ // cwd alone can't disambiguate, so decline and let the caller fall back to
133
+ // recency (fail closed, no wrong-project guess).
134
+ for (const c of cwds) {
135
+ const matched = new Set();
136
+ let cur = c;
137
+ while (cur && cur.includes('/')) {
138
+ const b = casefold(lastSegment(cur), caseInsensitive);
139
+ const hit = b && byBasename.get(b);
140
+ if (hit && hit.count === 1 && isEligible(hit.slug)) matched.add(hit.slug);
141
+ cur = cur.slice(0, cur.lastIndexOf('/'));
142
+ }
143
+ if (matched.size === 1) return [...matched][0];
144
+ }
145
+ return null;
146
+ }
147
+
148
+ /**
149
+ * Scan projects/<slug>/index.md and return [{slug, workingDir}] for every real
150
+ * project. Skips `_template` and dirs without an index.md. The full set is the
151
+ * uniqueness universe for tier 2, so callers should pass ALL projects here even
152
+ * when they later restrict the answer via `opts.eligible`.
153
+ *
154
+ * @param {string} hypoDir
155
+ * @returns {{slug:string, workingDir:string|null}[]}
156
+ */
157
+ export function collectProjectWorkingDirs(hypoDir) {
158
+ const projectsDir = join(hypoDir, 'projects');
159
+ if (!existsSync(projectsDir)) return [];
160
+ const out = [];
161
+ for (const slug of readdirSync(projectsDir)) {
162
+ if (slug === '_template') continue;
163
+ const dir = join(projectsDir, slug);
164
+ try {
165
+ if (!statSync(dir).isDirectory()) continue;
166
+ } catch {
167
+ continue;
168
+ }
169
+ const indexPath = join(dir, 'index.md');
170
+ if (!existsSync(indexPath)) continue;
171
+ let workingDir = null;
172
+ try {
173
+ const fm = parseFrontmatter(readFileSync(indexPath, 'utf-8'));
174
+ workingDir = fm?.working_dir ?? null;
175
+ } catch {
176
+ workingDir = null;
177
+ }
178
+ out.push({ slug, workingDir });
179
+ }
180
+ return out;
181
+ }
@@ -7,7 +7,7 @@
7
7
  // - scripts/graph.mjs (slug index, page collection, link extraction)
8
8
  // - scripts/crystallize.mjs (page collection, link extraction)
9
9
  //
10
- // IMPR-13 consolidation. Before this lib, each script carried its own copy of
10
+ // Shared-resolver consolidation. Before this lib, each script carried its own copy of
11
11
  // collectPages + a slug-form deriver, drifting in subtle ways. The four
12
12
  // collectPages variants are NOT interchangeable — they differ deliberately in
13
13
  // traversal/security/output-shape policy (codex design review, CONCERN):
package/scripts/lint.mjs CHANGED
@@ -115,7 +115,7 @@ const TYPE_CONDITIONAL_FIELDS = {
115
115
  'tool-eval': ['status'],
116
116
  postmortem: ['outcome'],
117
117
  learning: ['source'],
118
- // feedback: ADR 0031 — projection SoT requires full classification
118
+ // feedback: projection SoT requires full classification
119
119
  feedback: [
120
120
  'status',
121
121
  'scope',
@@ -136,13 +136,13 @@ const TYPE_ENUM_FIELDS = {
136
136
  prd: { status: ['draft', 'active', 'completed', 'cancelled', 'archived'] },
137
137
  adr: { status: ['proposed', 'accepted', 'deprecated', 'superseded'] },
138
138
  'tool-eval': { status: ['adopted', 'evaluating', 'rejected'] },
139
- // feedback: ADR 0031 — sensitivity:private is forbidden (wiki is a
139
+ // feedback: sensitivity:private is forbidden (wiki is a
140
140
  // git-pushed public surface)
141
141
  feedback: {
142
142
  status: ['active', 'superseded', 'archived'],
143
143
  tier: ['L1', 'L2'],
144
144
  sensitivity: ['public', 'sanitized'],
145
- // FEAT-1: optional failure taxonomy. The enum loop guards on `if (fm[field])`,
145
+ // optional failure taxonomy. The enum loop guards on `if (fm[field])`,
146
146
  // so an omitted failure_type is never validated (optional, migration-safe).
147
147
  failure_type: FAILURE_TYPE_ENUM,
148
148
  },
@@ -283,7 +283,7 @@ const issues = [];
283
283
  // double-gate).
284
284
  // NOTE: no gate currently passes --strict (npm run lint / CI / release.yml /
285
285
  // crystallize / the close-gate all run plain lint), so promotion is
286
- // forward-looking these surface as warnings today. IMPR-3's deliverable is
286
+ // forward-looking: these surface as warnings today. The deliverable is
287
287
  // "stop silently green-passing invalid YAML"; the W9 warning satisfies that.
288
288
  const STRICT_PROMOTE_IDS = new Set(['W1', 'W2', 'W4', 'W9']);
289
289
 
@@ -392,7 +392,7 @@ function lintPage({ path, rel }, slugMap, tagVocab, pageDirs, validTypes) {
392
392
  }
393
393
  }
394
394
 
395
- // feedback: scope vocabulary + conditional claude-learned fields (ADR 0031)
395
+ // feedback: scope vocabulary + conditional claude-learned fields
396
396
  if (fm.type === 'feedback') {
397
397
  const scope = fm.scope || '';
398
398
  if (scope && !FEEDBACK_SCOPE_RE.test(scope)) {
@@ -234,7 +234,7 @@ function fail(args, msg) {
234
234
  // • A directory move does NOT change basenames, only the path prefix. So bare
235
235
  // `[[0052]]` links keep resolving to the relocated page automatically and need
236
236
  // NO rewrite — only the FULL-slug and 1-seg DIR-RELATIVE forms encode the moved
237
- // prefix and break. (Forms that drop ≥2 segments, e.g. `[[decisions/0052]]`, do
237
+ // prefix and break. (Forms that drop ≥2 segments, e.g. `[[decisions/NNNN]]`, do
238
238
  // not encode the renamed prefix either: lint cannot resolve them today and they
239
239
  // survive a move unchanged — out of scope, matching lint's buildSlugMap.)
240
240
  // • Every moved page is BOTH a target (it relocates) and a source (its links to
@@ -26,10 +26,10 @@
26
26
  * --json Output as JSON
27
27
  */
28
28
 
29
- import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
29
+ import { existsSync, readFileSync, readdirSync, statSync, realpathSync } from 'fs';
30
30
  import { join } from 'path';
31
- import { homedir } from 'os';
32
31
  import { resolveHypoRoot, expandHome } from './lib/hypo-root.mjs';
32
+ import { pickProjectByCwd, collectProjectWorkingDirs } from './lib/wd-match.mjs';
33
33
 
34
34
  // ── arg parsing ──────────────────────────────────────────────────────────────
35
35
 
@@ -60,27 +60,25 @@ function parseFrontmatterField(content, key) {
60
60
  .replace(/^['"]|['"]$/g, '');
61
61
  }
62
62
 
63
- // Among `slugs`, return the one whose projects/<slug>/index.md `working_dir`
64
- // is the LONGEST prefix of cwd (so /repo/sub wins over /repo). Returns null
65
- // when cwd is falsy or matches none. resume gives this authority OVER recency
66
- // (ADR 0044): a cwd↔working_dir match wins regardless of which row is newest.
63
+ // Return the project (among `slugs`) that owns cwd: a longest-prefix
64
+ // working_dir match, or when no absolute path matches because the vault was
65
+ // synced from another machine a cwd ancestor whose directory name is a
66
+ // globally-unique project basename. Uniqueness is judged over EVERY project on
67
+ // disk (not just `slugs`), so a shared dirname declines and falls back to
68
+ // recency. resume gives this authority over recency: a cwd↔project match wins
69
+ // regardless of which hot.md row is newest.
67
70
  function pickByCwd(hypoDir, slugs, cwd) {
68
71
  if (!cwd) return null;
69
- let best = null;
70
- let bestLen = -1;
71
- for (const slug of slugs) {
72
- const indexPath = join(hypoDir, 'projects', slug, 'index.md');
73
- if (!existsSync(indexPath)) continue;
74
- const wd = parseFrontmatterField(readFileSync(indexPath, 'utf-8'), 'working_dir');
75
- if (!wd) continue;
76
- let resolved = wd.startsWith('~/') ? join(homedir(), wd.slice(2)) : wd;
77
- resolved = resolved.replace(/\/+$/, ''); // trailing-slash normalize
78
- if ((cwd === resolved || cwd.startsWith(resolved + '/')) && resolved.length > bestLen) {
79
- bestLen = resolved.length;
80
- best = slug;
81
- }
72
+ let realpathCwd = null;
73
+ try {
74
+ realpathCwd = realpathSync(cwd);
75
+ } catch {
76
+ realpathCwd = null;
82
77
  }
83
- return best;
78
+ return pickProjectByCwd(collectProjectWorkingDirs(hypoDir), cwd, {
79
+ eligible: slugs,
80
+ realpathCwd,
81
+ });
84
82
  }
85
83
 
86
84
  // When cwd is set but no project's working_dir matches it, resume falls back to
@@ -125,11 +123,11 @@ function resolveActiveProject(hypoDir, cwd = null) {
125
123
  ),
126
124
  ].map((m) => ({ name: m[1].trim(), date: m[2] || '', slug: m[3] }));
127
125
  if (wikiRows.length > 0) {
128
- // cwd-first (ADR 0044): a cwd↔working_dir match wins over recency, across
126
+ // cwd-first: a cwd↔working_dir match wins over recency, across
129
127
  // ALL rows (not just a same-date tie). The user is physically in that
130
128
  // project, so cwd is a stronger intent signal than "some other project was
131
129
  // touched more recently". This reverses the earlier tie-breaker-only
132
- // semantics now that resume=cwd-positive (ADR 0043). Tradeoff: a stale cwd
130
+ // semantics now that resume=cwd-positive. Tradeoff: a stale cwd
133
131
  // match can mask a genuinely newer project; `--project` overrides. close
134
132
  // callers pass null → recency path below (resume=cwd-positive / close=no-pick).
135
133
  if (cwd) {
@@ -6,7 +6,7 @@
6
6
  * (search count, ingest count, URLs mentioned, feedback count) so the
7
7
  * weekly observability report (Lane E) can compute autonomy scores.
8
8
  *
9
- * Transcript dual-source (ADR 0019):
9
+ * Transcript dual-source:
10
10
  * 1) Primary: <hypo-dir>/.cache/sessions/index.jsonl
11
11
  * Written by hooks/hypo-session-record.mjs (Stop hook).
12
12
  * 2) Fallback: ~/.claude/projects/<encoded>/*.jsonl
package/scripts/stats.mjs CHANGED
@@ -48,8 +48,8 @@ function collectMdFiles(dir, acc = [], hypoDir = '', ignorePatterns = []) {
48
48
  // Local top-level-only extractor, behavior-aligned with lib/frontmatter.mjs:
49
49
  // skip indented / list lines, first-wins, strip a trailing `# comment`. Without
50
50
  // the comment strip a `failure_type: overreach # note` would aggregate under the
51
- // literal "overreach # note" key (FEAT-1). Kept local (not the shared import) to
52
- // hold stats' dependency surface flat per the FEAT-1 scope split.
51
+ // literal "overreach # note" key. Kept local (not the shared import) to
52
+ // hold stats' dependency surface flat per the scope split.
53
53
  function parseFrontmatter(content) {
54
54
  const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
55
55
  if (!m) return null;
@@ -101,7 +101,7 @@ const sources = existsSync(join(args.hypoDir, 'sources'))
101
101
  : [];
102
102
 
103
103
  const typeCounts = {};
104
- const failureTypeCounts = {}; // FEAT-1: feedback pages carrying a failure_type
104
+ const failureTypeCounts = {}; // feedback pages carrying a failure_type
105
105
  let missingFrontmatter = 0;
106
106
 
107
107
  for (const f of pageFiles) {
@@ -15,7 +15,7 @@
15
15
  * --force-extensions Remove user-modified extension files (hypo-ext-*) instead of preserving them
16
16
  * --hooks-dir=<path> Override Claude hooks directory (default: ~/.claude/hooks)
17
17
  *
18
- * Extensions (ADR 0024): hypo-ext-* hard-copies under
18
+ * Extensions: hypo-ext-* hard-copies under
19
19
  * ~/.claude/{hooks,commands,skills,agents}/ and ~/.codex/{hooks,commands}/ (with
20
20
  * --codex) are removed when their on-disk SHA matches the recorded one in
21
21
  * ~/.claude/hypo-pkg.json#extensions.<target>. User-modified copies are preserved
@@ -94,7 +94,7 @@ function removeCommands(apply, force) {
94
94
  return { removed, skippedUserModified, skippedNonRegular };
95
95
  }
96
96
 
97
- // ── extensions removal (ADR 0024) ───────────────────────────────────
97
+ // ── extensions removal ───────────────────────────────────
98
98
 
99
99
  // Strip per-target extension SHA records from ~/.claude/hypo-pkg.json. Surgical:
100
100
  // we only touch the entries for keys we actually removed, so a `--force-extensions`
@@ -424,7 +424,7 @@ const hookResult = removeHookFiles(claudeHooksDir, hookFiles, args.apply);
424
424
  const settingsResult = stripSettingsJson(claudeSettings, claudeHooksDir, hookMap, args.apply);
425
425
  const commandResult = removeCommands(args.apply, args.forceCommands);
426
426
 
427
- // Extensions (ADR 0024). Order matters: remove files first, then strip
427
+ // Extensions. Order matters: remove files first, then strip
428
428
  // settings, then surgically clear the per-target SHA map. The SHA strip uses
429
429
  // removedKeys so a user-modified file we left in place keeps its recorded SHA
430
430
  // (doctor still has a baseline next run). Settings are path-based and run even
@@ -20,7 +20,7 @@
20
20
  * the user-extensions companion sync (E4).
21
21
  * --json Output results as JSON
22
22
  * --allow-downgrade Override the guard that refuses to overwrite a NEWER
23
- * active install with an older package (ADR 0038)
23
+ * active install with an older package
24
24
  * --allow-dual-install Override the dual-install guard: register the Claude core
25
25
  * surface even though the Hypomnema plugin is also enabled
26
26
  * (knowingly accept the double-registration risk)
@@ -235,7 +235,7 @@ function checkSchemaVersion(hypoDir) {
235
235
 
236
236
  // Target-aware: the same core-hook integrity check runs against ~/.claude/hooks/
237
237
  // for the default claude target, and against ~/.codex/hooks/ when --codex is set
238
- // (ADR 0024). The function reads only apply happens in applyHookFiles.
238
+ // The function reads only (apply happens in applyHookFiles).
239
239
  function checkHookFiles(hooksDir) {
240
240
  const results = [];
241
241
 
@@ -465,6 +465,49 @@ function applyHypoignoreMigration(result) {
465
465
  return appended;
466
466
  }
467
467
 
468
+ // ── .gitignore migration: mirror .cache/ into .gitignore (page-usage privacy) ─
469
+ //
470
+ // Legacy vaults may have a .gitignore that predates .cache/ (init now scaffolds
471
+ // it, but old vaults were migrated only in .hypoignore). Without .cache/ in
472
+ // .gitignore, the page-usage log could be committed. This mirrors the hypoignore
473
+ // migration: idempotent append of the missing entry, no-op when already present
474
+ // or when there is no .gitignore (the runtime logging guard fails closed for the
475
+ // no-file case, so nothing leaks in the meantime).
476
+
477
+ const GITIGNORE_REQUIRED_ENTRIES = [
478
+ {
479
+ pattern: '.cache/',
480
+ comment: '# Hypomnema runtime cache (page-usage log, session growth metrics)',
481
+ },
482
+ ];
483
+
484
+ function checkGitignore(hypoDir) {
485
+ const path = join(hypoDir, '.gitignore');
486
+ if (!existsSync(path)) return { status: 'no-file', missing: [], path };
487
+ const content = readFileSync(path, 'utf-8');
488
+ const entries = new Set(
489
+ content
490
+ .split('\n')
491
+ .map((l) => l.trim())
492
+ .filter((l) => l && !l.startsWith('#')),
493
+ );
494
+ const missing = GITIGNORE_REQUIRED_ENTRIES.filter((e) => !entries.has(e.pattern));
495
+ return { status: missing.length === 0 ? 'up-to-date' : 'needs-migration', missing, path };
496
+ }
497
+
498
+ function applyGitignoreMigration(result) {
499
+ if (result.status !== 'needs-migration') return [];
500
+ let content = readFileSync(result.path, 'utf-8');
501
+ if (!content.endsWith('\n')) content += '\n';
502
+ const appended = [];
503
+ for (const entry of result.missing) {
504
+ content += `\n${entry.comment}\n${entry.pattern}\n`;
505
+ appended.push(entry.pattern);
506
+ }
507
+ writeFileSync(result.path, content);
508
+ return appended;
509
+ }
510
+
468
511
  function writeMigrationReport(hypoDir, fromVersion, toVersion, { pluginMode = false } = {}) {
469
512
  const today = new Date().toISOString().slice(0, 10);
470
513
  const filename = `MIGRATION-v${toVersion}.md`;
@@ -473,15 +516,15 @@ function writeMigrationReport(hypoDir, fromVersion, toVersion, { pluginMode = fa
473
516
  // Don't overwrite an existing report
474
517
  if (existsSync(dest)) return dest;
475
518
 
476
- // v1 → v2 specific guidance for the ADR 0031 feedback classification bump.
477
- // Other major jumps fall back to the generic body. ADR 0034 reserves the
478
- // right to keep SCHEMA.md as user-owned (Option C); auto-stub of the 9 new
479
- // fields is rejected because scope/tier/targets/sensitivity/reason/source
480
- // are semantic decisions whose wrong defaults would project wrong behavior.
481
- // Fire for any 1.x → 2.x major crossing, not just 1.0 → 2.0 exactly: the ADR
482
- // 0031 feedback-field backfill that this body explains was introduced at 2.0
519
+ // v1 → v2 specific guidance for the feedback classification bump. Other major
520
+ // jumps fall back to the generic body. The schema deliberately keeps SCHEMA.md
521
+ // user-owned (Option C); auto-stub of the 9 new fields is rejected because
522
+ // scope/tier/targets/sensitivity/reason/source are semantic decisions whose
523
+ // wrong defaults would project wrong behavior.
524
+ // Fire for any 1.x → 2.x major crossing, not just 1.0 → 2.0 exactly: the
525
+ // feedback-field backfill that this body explains was introduced at 2.0
483
526
  // and still applies to a user landing on any later 2.x (e.g. 2.1's additive
484
- // failure_type — FEAT-1). Keying on the exact target string would silently
527
+ // failure_type). Keying on the exact target string would silently
485
528
  // drop this guidance the moment the template minor moves.
486
529
  const fromV = parseVersion(fromVersion);
487
530
  const toV = parseVersion(toVersion);
@@ -489,8 +532,7 @@ function writeMigrationReport(hypoDir, fromVersion, toVersion, { pluginMode = fa
489
532
  const specificBody = isV1ToV2
490
533
  ? `## What changed in SCHEMA 2.0
491
534
 
492
- ADR 0031 (\`projects/hypomnema/decisions/0031-feedback-as-sot-external-memory-projection.md\`)
493
- made \`feedback\` pages the single source of truth for behavior corrections and added
535
+ This release made \`feedback\` pages the single source of truth for behavior corrections and added
494
536
  **9 hard-required frontmatter fields** for every \`feedback\` page:
495
537
 
496
538
  - \`status\` (active | superseded | archived)
@@ -510,8 +552,8 @@ that does not satisfy all four is rejected by both \`hypomnema lint\` and
510
552
  \`hypomnema feedback\` (see \`scripts/lint.mjs\` and \`scripts/feedback.mjs\`
511
553
  enforcement).
512
554
 
513
- ADR 0034 records this schema bump and the reasoning (semver-major because
514
- existing feedback pages without these fields now fail \`hypomnema lint\`).
555
+ This schema bump is semver-major because existing feedback pages without these
556
+ fields now fail \`hypomnema lint\`.
515
557
 
516
558
  ## Action items — existing wiki
517
559
 
@@ -799,7 +841,7 @@ function writePluginModeMetadata() {
799
841
  const args = parseArgs(process.argv);
800
842
 
801
843
  // Target paths. Claude is always checked; codex is checked only when --codex is set
802
- // (ADR 0024) so users without codex installed see no false drift.
844
+ // so users without codex installed see no false drift.
803
845
  const claudeHooksDir = join(HOME, '.claude', 'hooks');
804
846
  const claudeSettingsPath = join(HOME, '.claude', 'settings.json');
805
847
  const codexHooksDir = join(HOME, '.codex', 'hooks');
@@ -850,6 +892,7 @@ const pkgJson = checkPkgJson();
850
892
  const commands = checkCommands();
851
893
  const oldHookRefs = checkOldHookNames(claudeSettingsPath);
852
894
  const hypoignore = checkHypoignore(args.hypoDir);
895
+ const gitignore = checkGitignore(args.hypoDir);
853
896
 
854
897
  // when --codex is set, mirror the same core-hook checks against ~/.codex/
855
898
  // so `hypomnema upgrade --codex` reports drift symmetrically and `--apply --codex`
@@ -858,7 +901,7 @@ const hooksCodex = args.codex ? checkHookFiles(codexHooksDir) : null;
858
901
  const settingsCodex = args.codex ? checkSettingsJson(codexSettingsPath, codexHooksDir) : null;
859
902
  const oldHookRefsCodex = args.codex ? checkOldHookNames(codexSettingsPath) : null;
860
903
 
861
- // Extensions companion (ADR 0024). Read-only check; the apply
904
+ // Extensions companion. Read-only check; the apply
862
905
  // happens below, AFTER applyCommands, so the per-target SHA map merges into the
863
906
  // hypo-pkg.json that applyCommands writes (rather than being clobbered by it).
864
907
  const extSettingsPath = claudeSettingsPath;
@@ -931,11 +974,12 @@ let appliedSettingsCodex = [];
931
974
  let appliedHookNameRenamesCodex = [];
932
975
  let appliedCommands = [];
933
976
  let appliedHypoignore = [];
977
+ let appliedGitignore = [];
934
978
  let appliedExtensions = null;
935
979
  let appliedExtensionsCodex = null;
936
980
 
937
981
  if (args.apply) {
938
- // Downgrade guard (ADR 0038, P): an `--apply` from an OLDER package than the
982
+ // Downgrade guard: an `--apply` from an OLDER package than the
939
983
  // active install would overwrite newer hooks (upgrade.mjs:287 copyFileSync) and
940
984
  // rewrite hypo-pkg.json to the older version. Refuse before the first mutation.
941
985
  // A dev workspace re-running its own --apply (incl. the post-commit sync hook)
@@ -1015,6 +1059,7 @@ if (args.apply) {
1015
1059
  }
1016
1060
  }
1017
1061
  appliedHypoignore = applyHypoignoreMigration(hypoignore);
1062
+ appliedGitignore = applyGitignoreMigration(gitignore);
1018
1063
  // codex core hooks + settings + wiki-*→hypo-* rename mirror. Same order
1019
1064
  // as the claude side (rename first so subsequent hook copy can find renamed targets).
1020
1065
  if (args.codex) {
@@ -1090,6 +1135,7 @@ const hasDrift =
1090
1135
  pkgJsonDrift ||
1091
1136
  schemaDrift ||
1092
1137
  hypoignore.status === 'needs-migration' ||
1138
+ gitignore.status === 'needs-migration' ||
1093
1139
  extDrift ||
1094
1140
  codexCoreDrift;
1095
1141
 
@@ -1110,6 +1156,7 @@ if (args.json) {
1110
1156
  commands,
1111
1157
  oldHookRefs,
1112
1158
  hypoignore,
1159
+ gitignore,
1113
1160
  extensions: extCheck,
1114
1161
  extensionsCodex: extCheckCodex,
1115
1162
  // codex core mirror (null when --codex absent).
@@ -1123,6 +1170,7 @@ if (args.json) {
1123
1170
  hookNameRenames: appliedHookNameRenames,
1124
1171
  commands: appliedCommands,
1125
1172
  hypoignore: appliedHypoignore,
1173
+ gitignore: appliedGitignore,
1126
1174
  extensions: appliedExtensions,
1127
1175
  extensionsCodex: appliedExtensionsCodex,
1128
1176
  hooksCodex: appliedHooksCodex,
@@ -1370,7 +1418,19 @@ if (hypoignore.status === 'no-file') {
1370
1418
  for (const e of hypoignore.missing) lines.push(` + ${e.pattern}`);
1371
1419
  }
1372
1420
 
1373
- // Extensions companion (ADR 0024; conflict/drift gating E3, #31). Shared by the
1421
+ // .gitignore migration (page-usage privacy: mirror .cache/ into .gitignore)
1422
+ if (gitignore.status === 'no-file') {
1423
+ lines.push(`⚠ .gitignore not found at ${gitignore.path} (init.mjs scaffolds this)`);
1424
+ } else if (gitignore.status === 'up-to-date') {
1425
+ lines.push(`✓ .gitignore required entries present`);
1426
+ } else {
1427
+ lines.push(
1428
+ `⚠ .gitignore ${gitignore.missing.length} missing entry(s), run --apply to append:`,
1429
+ );
1430
+ for (const e of gitignore.missing) lines.push(` + ${e.pattern}`);
1431
+ }
1432
+
1433
+ // Extensions companion (conflict/drift gating E3, #31). Shared by the
1374
1434
  // claude target and, under --codex, the codex target (E4, #32) — the label keeps
1375
1435
  // the two blocks distinguishable in the report.
1376
1436
  function pushExtSummary(check, label) {
@@ -1425,6 +1485,7 @@ if (
1425
1485
  appliedHookNameRenames.length > 0 ||
1426
1486
  appliedCommands.length > 0 ||
1427
1487
  appliedHypoignore.length > 0 ||
1488
+ appliedGitignore.length > 0 ||
1428
1489
  appliedHooksCodex.length > 0 ||
1429
1490
  appliedSettingsCodex.length > 0 ||
1430
1491
  appliedHookNameRenamesCodex.length > 0
@@ -1453,6 +1514,10 @@ if (
1453
1514
  lines.push(`✓ Appended .hypoignore entries (${appliedHypoignore.length}):`);
1454
1515
  for (const e of appliedHypoignore) lines.push(` → ${e}`);
1455
1516
  }
1517
+ if (appliedGitignore.length > 0) {
1518
+ lines.push(`✓ Appended .gitignore entries (${appliedGitignore.length}):`);
1519
+ for (const e of appliedGitignore) lines.push(` → ${e}`);
1520
+ }
1456
1521
  // codex-target applied actions (mirrors claude blocks above).
1457
1522
  if (appliedHookNameRenamesCodex.length > 0) {
1458
1523
  lines.push(`✓ Renamed legacy hook references (codex) (${appliedHookNameRenamesCodex.length}):`);
@@ -1508,6 +1573,7 @@ const totalDrift =
1508
1573
  (pkgJsonDrift ? 1 : 0) +
1509
1574
  (schemaDrift ? 1 : 0) +
1510
1575
  (hypoignore.status === 'needs-migration' ? hypoignore.missing.length : 0) +
1576
+ (gitignore.status === 'needs-migration' ? gitignore.missing.length : 0) +
1511
1577
  extCheck.actions.filter(
1512
1578
  (a) => a.action === 'create' || a.action === 'update' || a.action === 'force-update',
1513
1579
  ).length +
@@ -1545,6 +1611,7 @@ if (totalDrift === 0) {
1545
1611
  (appliedPkgJson ? 1 : 0) +
1546
1612
  appliedHookNameRenames.length +
1547
1613
  appliedHypoignore.length +
1614
+ appliedGitignore.length +
1548
1615
  appliedExtCount +
1549
1616
  appliedHooksCodex.length +
1550
1617
  appliedSettingsCodex.length +