hypomnema 1.0.1 → 1.2.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 (76) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.ko.md +12 -5
  4. package/README.md +12 -5
  5. package/commands/audit.md +46 -0
  6. package/commands/crystallize.md +113 -23
  7. package/commands/feedback.md +40 -26
  8. package/commands/ingest.md +31 -9
  9. package/commands/upgrade.md +2 -2
  10. package/docs/ARCHITECTURE.md +83 -9
  11. package/docs/CONTRIBUTING.md +2 -2
  12. package/hooks/hooks.json +39 -1
  13. package/hooks/hypo-auto-commit.mjs +23 -4
  14. package/hooks/hypo-auto-minimal-crystallize.mjs +145 -0
  15. package/hooks/hypo-auto-stage.mjs +9 -5
  16. package/hooks/hypo-compact-guard.mjs +33 -24
  17. package/hooks/hypo-cwd-change.mjs +107 -24
  18. package/hooks/hypo-file-watch.mjs +23 -10
  19. package/hooks/hypo-first-prompt.mjs +37 -23
  20. package/hooks/hypo-hot-rebuild.mjs +31 -8
  21. package/hooks/hypo-lookup.mjs +171 -65
  22. package/hooks/hypo-personal-check.mjs +207 -112
  23. package/hooks/hypo-pre-commit.mjs +46 -0
  24. package/hooks/hypo-session-end.mjs +58 -0
  25. package/hooks/hypo-session-record.mjs +60 -0
  26. package/hooks/hypo-session-start.mjs +312 -44
  27. package/hooks/hypo-shared.mjs +880 -28
  28. package/hooks/hypo-web-fetch-ingest.mjs +121 -0
  29. package/hooks/version-check-fetch.mjs +74 -0
  30. package/hooks/version-check.mjs +184 -0
  31. package/package.json +17 -3
  32. package/scripts/crystallize.mjs +623 -18
  33. package/scripts/doctor.mjs +739 -46
  34. package/scripts/feedback-sync.mjs +974 -0
  35. package/scripts/feedback.mjs +253 -44
  36. package/scripts/graph.mjs +35 -22
  37. package/scripts/ingest.mjs +89 -16
  38. package/scripts/init.mjs +442 -114
  39. package/scripts/lib/design-history-stale.mjs +83 -0
  40. package/scripts/lib/extensions.mjs +749 -0
  41. package/scripts/lib/frontmatter.mjs +5 -1
  42. package/scripts/lib/hypo-ignore.mjs +12 -10
  43. package/scripts/lib/pkg-json.mjs +23 -5
  44. package/scripts/lib/project-create.mjs +225 -0
  45. package/scripts/lib/schema-vocab.mjs +96 -0
  46. package/scripts/lint.mjs +238 -31
  47. package/scripts/query.mjs +26 -10
  48. package/scripts/resume.mjs +11 -5
  49. package/scripts/session-audit.mjs +277 -0
  50. package/scripts/smoke-pack.mjs +224 -0
  51. package/scripts/stats.mjs +24 -10
  52. package/scripts/uninstall.mjs +369 -48
  53. package/scripts/upgrade.mjs +766 -195
  54. package/scripts/verify.mjs +24 -14
  55. package/scripts/weekly-report.mjs +211 -0
  56. package/skills/crystallize/SKILL.md +24 -7
  57. package/skills/graph/SKILL.md +4 -0
  58. package/skills/ingest/SKILL.md +29 -5
  59. package/skills/lint/SKILL.md +4 -0
  60. package/skills/query/SKILL.md +4 -0
  61. package/skills/verify/SKILL.md +4 -0
  62. package/templates/.hypoignore +19 -2
  63. package/templates/Home.md +2 -0
  64. package/templates/SCHEMA.md +61 -6
  65. package/templates/extensions/agents/.gitkeep +0 -0
  66. package/templates/extensions/commands/.gitkeep +0 -0
  67. package/templates/extensions/hooks/.gitkeep +0 -0
  68. package/templates/extensions/skills/.gitkeep +0 -0
  69. package/templates/gitignore +5 -0
  70. package/templates/hot.md +2 -0
  71. package/templates/hypo-config.md +1 -1
  72. package/templates/hypo-guide.md +63 -1
  73. package/templates/hypo-help.md +1 -1
  74. package/templates/pages/observability/_index.md +77 -0
  75. package/templates/projects/_template/index.md +2 -2
  76. package/templates/projects/_template/prd.md +1 -1
@@ -2,35 +2,100 @@
2
2
  /**
3
3
  * Hypomnema feedback script
4
4
  *
5
- * Creates or appends to pages/feedback/<topic>.md with a feedback entry.
6
- * Also appends a log entry to log.md.
5
+ * Creates or appends to pages/feedback/<topic>.md with a behaviour-correction
6
+ * entry. The wiki feedback page is the single source of truth (ADR 0031 / fix
7
+ * #37); MEMORY.md and CLAUDE.md <learned_behaviors> are one-way *projections*
8
+ * derived from it. Never hand-edit those targets — this script writes the page
9
+ * and then runs `feedback-sync --write` to refresh the projection automatically.
10
+ *
7
11
  * Used by /hypo:feedback after Claude drafts the feedback content.
8
12
  *
9
13
  * Usage:
10
- * node scripts/feedback.mjs --topic=<slug> --entry=<text> [options]
14
+ * node scripts/feedback.mjs --topic=<slug> --entry=<text> [classification flags]
15
+ *
16
+ * Page write:
17
+ * --topic=<slug> Feedback topic slug (e.g. "response-length")
18
+ * --entry=<text> Rule body (one-line rule or short paragraph)
19
+ * --title=<text> Frontmatter title (default: topic)
20
+ *
21
+ * Classification (lint #8 schema — required on create):
22
+ * --scope=<v> global | project:<slug> (required)
23
+ * --tier=<v> L1 | L2 (required)
24
+ * --targets=<list> comma list of project-memory,claude-learned (default: project-memory)
25
+ * --sensitivity=<v> public | sanitized (default: public)
26
+ * --priority=<1-5> projection sort weight (default: 3)
27
+ * --memory-summary=<t> one-line MEMORY.md index summary (required)
28
+ * --reason=<t> why this rule exists (required)
29
+ * --source=<t> provenance (default: session:<today>)
30
+ * --behavior=<t> optional: the behaviour being corrected
31
+ *
32
+ * Required only when --targets includes claude-learned:
33
+ * --global-summary=<t> one-line CLAUDE.md learned-behaviour summary
34
+ * --promote-to-global mark the page for global CLAUDE.md projection
11
35
  *
12
- * Options:
13
- * --hypo-dir=<path> Hypomnema root (default: resolved via HYPO_DIR / hypo-config.md / ~/hypomnema)
14
- * --topic=<slug> Feedback topic slug (e.g. "response-length", "code-style")
15
- * --entry=<text> Feedback entry text (one-line rule or short paragraph)
16
- * --dry-run Preview without writing
17
- * --list List existing feedback topics
36
+ * Modes:
37
+ * --hypo-dir=<path> Hypomnema root (default: HYPO_DIR / hypo-config.md / ~/hypomnema)
38
+ * --claude-home=<path> Projection target home for the post-step (default: ~/.claude)
39
+ * --project-id=<id> Projection MEMORY project-id for the post-step (default: derived from cwd)
40
+ * --no-sync Skip the automatic `feedback-sync --write` post-step
41
+ * --dry-run Preview without writing (also skips projection)
42
+ * --list List existing feedback topics
18
43
  */
19
44
 
20
45
  import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from 'fs';
21
- import { join, resolve, sep } from 'path';
46
+ import { join } from 'path';
47
+ import { spawnSync } from 'child_process';
48
+ import { fileURLToPath } from 'url';
22
49
  import { resolveHypoRoot, expandHome } from './lib/hypo-root.mjs';
23
50
 
51
+ const SCRIPT_DIR = fileURLToPath(new URL('.', import.meta.url));
52
+
24
53
  // ── arg parsing ──────────────────────────────────────────────────────────────
25
54
 
26
55
  function parseArgs(argv) {
27
- const args = { hypoDir: null, topic: null, entry: null, dryRun: false, list: false };
56
+ const args = {
57
+ hypoDir: null,
58
+ topic: null,
59
+ entry: null,
60
+ title: null,
61
+ scope: null,
62
+ tier: null,
63
+ targets: null,
64
+ sensitivity: 'public',
65
+ priority: '3',
66
+ memorySummary: null,
67
+ globalSummary: null,
68
+ promoteToGlobal: false,
69
+ reason: null,
70
+ source: null,
71
+ behavior: null,
72
+ claudeHome: null,
73
+ projectId: null,
74
+ noSync: false,
75
+ dryRun: false,
76
+ list: false,
77
+ };
28
78
  for (const arg of argv.slice(2)) {
29
79
  if (arg.startsWith('--hypo-dir=')) args.hypoDir = expandHome(arg.slice(11));
30
- else if (arg.startsWith('--topic=')) args.topic = arg.slice(8);
31
- else if (arg.startsWith('--entry=')) args.entry = arg.slice(8);
32
- else if (arg === '--dry-run') args.dryRun = true;
33
- else if (arg === '--list') args.list = true;
80
+ else if (arg.startsWith('--topic=')) args.topic = arg.slice(8);
81
+ else if (arg.startsWith('--entry=')) args.entry = arg.slice(8);
82
+ else if (arg.startsWith('--title=')) args.title = arg.slice(8);
83
+ else if (arg.startsWith('--scope=')) args.scope = arg.slice(8);
84
+ else if (arg.startsWith('--tier=')) args.tier = arg.slice(7);
85
+ else if (arg.startsWith('--targets=')) args.targets = arg.slice(10);
86
+ else if (arg.startsWith('--sensitivity=')) args.sensitivity = arg.slice(14);
87
+ else if (arg.startsWith('--priority=')) args.priority = arg.slice(11);
88
+ else if (arg.startsWith('--memory-summary=')) args.memorySummary = arg.slice(17);
89
+ else if (arg.startsWith('--global-summary=')) args.globalSummary = arg.slice(17);
90
+ else if (arg === '--promote-to-global') args.promoteToGlobal = true;
91
+ else if (arg.startsWith('--reason=')) args.reason = arg.slice(9);
92
+ else if (arg.startsWith('--source=')) args.source = arg.slice(9);
93
+ else if (arg.startsWith('--behavior=')) args.behavior = arg.slice(11);
94
+ else if (arg.startsWith('--claude-home=')) args.claudeHome = expandHome(arg.slice(14));
95
+ else if (arg.startsWith('--project-id=')) args.projectId = arg.slice(13);
96
+ else if (arg === '--no-sync') args.noSync = true;
97
+ else if (arg === '--dry-run') args.dryRun = true;
98
+ else if (arg === '--list') args.list = true;
34
99
  }
35
100
  if (!args.hypoDir) args.hypoDir = resolveHypoRoot();
36
101
  return args;
@@ -44,7 +109,7 @@ function listTopics(hypoDir) {
44
109
  console.log('No feedback pages found.');
45
110
  return;
46
111
  }
47
- const files = readdirSync(feedbackDir).filter(f => f.endsWith('.md'));
112
+ const files = readdirSync(feedbackDir).filter((f) => f.endsWith('.md'));
48
113
  if (files.length === 0) {
49
114
  console.log('No feedback pages found.');
50
115
  return;
@@ -53,54 +118,195 @@ function listTopics(hypoDir) {
53
118
  for (const f of files) console.log(` ${f.replace(/\.md$/, '')}`);
54
119
  }
55
120
 
56
- // ── write feedback entry ──────────────────────────────────────────────────────
121
+ // ── classification validation (mirrors lint #8 / ADR 0031 §6) ──────────────────
57
122
 
58
- function writeFeedback(hypoDir, topic, entry, dryRun) {
59
- const feedbackDir = join(hypoDir, 'pages', 'feedback');
60
- const filePath = join(feedbackDir, `${topic}.md`);
61
- const today = new Date().toISOString().slice(0, 10);
123
+ const SCOPE_RE = /^(global|project:[a-z0-9][a-z0-9-]*)$/;
124
+ const TIER_ENUM = ['L1', 'L2'];
125
+ const SENSITIVITY_ENUM = ['public', 'sanitized']; // private is forbidden (wiki is git-public)
126
+ const TARGET_ENUM = ['project-memory', 'claude-learned'];
62
127
 
63
- let content;
128
+ function parseTargets(raw) {
129
+ return String(raw || '')
130
+ .split(',')
131
+ .map((t) => t.trim())
132
+ .filter(Boolean);
133
+ }
64
134
 
65
- if (existsSync(filePath)) {
66
- content = readFileSync(filePath, 'utf-8');
67
- const newEntry = `\n## ${today}\n\n${entry}\n`;
68
- content = content.trimEnd() + '\n' + newEntry;
69
- } else {
70
- content = `---
71
- title: "Feedback: ${topic}"
72
- type: feedback
73
- updated: ${today}
74
- tags: [feedback]
75
- ---
135
+ // Validate the create-mode classification. Returns an array of error strings.
136
+ function validateClassification(args, targets) {
137
+ const errs = [];
138
+ if (!args.scope) errs.push('--scope is required (global | project:<slug>)');
139
+ else if (!SCOPE_RE.test(args.scope)) errs.push(`--scope invalid: "${args.scope}"`);
140
+ if (!args.tier) errs.push('--tier is required (L1 | L2)');
141
+ else if (!TIER_ENUM.includes(args.tier)) errs.push(`--tier invalid: "${args.tier}"`);
142
+ if (!SENSITIVITY_ENUM.includes(args.sensitivity))
143
+ errs.push(
144
+ `--sensitivity invalid: "${args.sensitivity}" (public | sanitized; private is forbidden)`,
145
+ );
146
+ if (!targets.length) errs.push('--targets is required (project-memory[,claude-learned])');
147
+ for (const t of targets)
148
+ if (!TARGET_ENUM.includes(t)) errs.push(`--targets invalid member: "${t}"`);
149
+ const pri = Number(args.priority);
150
+ if (!Number.isInteger(pri) || pri < 1 || pri > 5)
151
+ errs.push(`--priority must be an integer 1-5 (got "${args.priority}")`);
152
+ if (!args.memorySummary) errs.push('--memory-summary is required');
153
+ if (!args.reason) errs.push('--reason is required');
154
+
155
+ // CLAUDE.md projection candidates must be global + L1 (ADR 0031 §6 filter), and
156
+ // carry the two conditional fields (lint #8). Enforce here so we never write a
157
+ // claude-learned page that lint rejects or that silently never projects.
158
+ if (targets.includes('claude-learned')) {
159
+ if (!args.globalSummary)
160
+ errs.push('--global-summary is required when --targets includes claude-learned');
161
+ if (!args.promoteToGlobal)
162
+ errs.push('--promote-to-global is required when --targets includes claude-learned');
163
+ if (args.scope !== 'global')
164
+ errs.push('claude-learned projection requires --scope=global (ADR 0031 §6)');
165
+ if (args.tier !== 'L1') errs.push('claude-learned projection requires --tier=L1 (ADR 0031 §6)');
166
+ }
167
+ return errs;
168
+ }
76
169
 
77
- # Feedback: ${topic}
170
+ // ── frontmatter rendering ──────────────────────────────────────────────────────
78
171
 
79
- ## ${today}
172
+ // Frontmatter scalars are written one-per-line as `key: value`. A raw newline in
173
+ // a value would inject a forged frontmatter key (e.g. a `reason` containing
174
+ // "\nstatus: archived"), silently overriding classification AFTER validation.
175
+ // These fields are one-liners by contract, so collapse any whitespace run
176
+ // (including newlines / control chars) to a single space and trim.
177
+ function oneLine(v) {
178
+ return String(v ?? '')
179
+ .replace(/\s+/g, ' ')
180
+ .trim();
181
+ }
80
182
 
81
- ${entry}
82
- `;
183
+ function renderPage(args, targets, today) {
184
+ const title = oneLine(args.title || args.topic);
185
+ const lines = [
186
+ '---',
187
+ `title: ${title}`,
188
+ 'tags: [feedback]',
189
+ 'type: feedback',
190
+ 'status: active',
191
+ `scope: ${args.scope}`,
192
+ `tier: ${args.tier}`,
193
+ `targets: [${targets.join(', ')}]`,
194
+ `sensitivity: ${args.sensitivity}`,
195
+ `priority: ${Number(args.priority)}`,
196
+ `memory_summary: ${oneLine(args.memorySummary)}`,
197
+ ];
198
+ if (targets.includes('claude-learned')) {
199
+ lines.push(`global_summary: ${oneLine(args.globalSummary)}`);
200
+ lines.push(`promote_to_global: ${args.promoteToGlobal}`);
83
201
  }
202
+ lines.push(`reason: ${oneLine(args.reason)}`);
203
+ lines.push(`source: ${oneLine(args.source || `session:${today}`)}`);
204
+ lines.push(`corrected_at: ${today}`);
205
+ lines.push(`updated: ${today}`);
206
+ lines.push(`created: ${today}`);
207
+ if (args.behavior) lines.push(`behavior: ${oneLine(args.behavior)}`);
208
+ lines.push('---');
209
+ lines.push('');
210
+ lines.push(`# ${title}`);
211
+ lines.push('');
212
+ lines.push('## 규칙');
213
+ lines.push('');
214
+ lines.push(args.entry);
215
+ lines.push('');
216
+ return lines.join('\n');
217
+ }
218
+
219
+ // ── write feedback page ─────────────────────────────────────────────────────────
84
220
 
85
- if (dryRun) {
221
+ // Append-mode `updated:` bump: appending a dated entry to an existing page makes
222
+ // it the freshest correction, so it should sort first. Rewrite the `updated:`
223
+ // line ONLY inside the leading frontmatter block (between the first pair of
224
+ // `---` fences). A naive multiline replace would also rewrite any body line that
225
+ // happens to start with "updated:" (codex review) — so we scope to the fence.
226
+ function bumpUpdated(content, today) {
227
+ const m = content.match(/^---\n([\s\S]*?)\n---/);
228
+ if (!m) return content; // no frontmatter → nothing to bump
229
+ const fm = m[1];
230
+ const bumped = /^updated:\s*/m.test(fm)
231
+ ? fm.replace(/^(updated:\s*).*$/m, `$1${today}`)
232
+ : `${fm}\nupdated: ${today}`;
233
+ return content.replace(m[0], `---\n${bumped}\n---`);
234
+ }
235
+
236
+ function writeFeedback(args, today) {
237
+ const feedbackDir = join(args.hypoDir, 'pages', 'feedback');
238
+ const filePath = join(feedbackDir, `${args.topic}.md`);
239
+ const targets = parseTargets(args.targets || 'project-memory');
240
+
241
+ let content;
242
+ let mode;
243
+ if (existsSync(filePath)) {
244
+ // Append a dated entry; preserve existing frontmatter classification.
245
+ mode = 'append';
246
+ const existing = readFileSync(filePath, 'utf-8');
247
+ const appended = existing.trimEnd() + `\n\n## ${today}\n\n${args.entry}\n`;
248
+ content = bumpUpdated(appended, today);
249
+ } else {
250
+ mode = 'create';
251
+ const errs = validateClassification(args, targets);
252
+ if (errs.length) {
253
+ console.error('Error: feedback classification incomplete:');
254
+ for (const e of errs) console.error(` - ${e}`);
255
+ process.exit(1);
256
+ }
257
+ content = renderPage(args, targets, today);
258
+ }
259
+
260
+ if (args.dryRun) {
86
261
  console.log('[DRY RUN — no changes made]\n');
87
- console.log(`Would write to: ${filePath}\n`);
262
+ console.log(`Would ${mode}: ${filePath}\n`);
88
263
  console.log(content);
89
- return;
264
+ return { wrote: false };
90
265
  }
91
266
 
92
267
  if (!existsSync(feedbackDir)) mkdirSync(feedbackDir, { recursive: true });
93
268
  writeFileSync(filePath, content);
94
- console.log(`✓ Written: ${filePath}`);
269
+ console.log(`✓ ${mode === 'create' ? 'Created' : 'Updated'}: ${filePath}`);
95
270
 
96
271
  // append to log.md
97
- const logPath = join(hypoDir, 'log.md');
98
- const logEntry = `\n- ${today} feedback: [[pages/feedback/${topic}]] — ${entry.split('\n')[0].slice(0, 80)}\n`;
272
+ const logPath = join(args.hypoDir, 'log.md');
273
+ const logEntry = `\n- ${today} feedback: [[pages/feedback/${args.topic}]] — ${args.entry
274
+ .split('\n')[0]
275
+ .slice(0, 80)}\n`;
99
276
  if (existsSync(logPath)) {
100
277
  const log = readFileSync(logPath, 'utf-8');
101
278
  writeFileSync(logPath, log.trimEnd() + logEntry);
102
279
  console.log(`↪ Appended to log.md`);
103
280
  }
281
+ return { wrote: true };
282
+ }
283
+
284
+ // ── projection post-step (ADR 0031) ────────────────────────────────────────────
285
+
286
+ // Refresh the MEMORY.md / CLAUDE.md projection from the just-written page. This
287
+ // is best-effort and NON-blocking: a projection failure (over-cap, conflict,
288
+ // unresolved project-id) must not fail the page write — the SoT is already
289
+ // saved. We surface a one-line stderr warning and let the user reconcile.
290
+ function runProjection(args) {
291
+ const cli = join(SCRIPT_DIR, 'feedback-sync.mjs');
292
+ if (!existsSync(cli)) return;
293
+ // Forward --claude-home / --project-id when given so the projection targets a
294
+ // caller-controlled location (tests, CI) instead of always defaulting to the
295
+ // real ~/.claude. Omitted → feedback-sync's own defaults (the production path:
296
+ // ~/.claude + project-id derived from cwd).
297
+ const cliArgs = [cli, '--write', '--no-input', `--hypo-dir=${args.hypoDir}`];
298
+ if (args.claudeHome) cliArgs.push(`--claude-home=${args.claudeHome}`);
299
+ if (args.projectId) cliArgs.push(`--project-id=${args.projectId}`);
300
+ const r = spawnSync(process.execPath, cliArgs, { encoding: 'utf-8' });
301
+ if (r.status === 0) {
302
+ console.log('↪ Projection refreshed (MEMORY.md / CLAUDE.md learned-behaviors)');
303
+ return;
304
+ }
305
+ const detail = (r.stderr || '').trim().split('\n').slice(-1)[0] || `exit ${r.status}`;
306
+ console.error(
307
+ `⚠ feedback-sync --write did not complete cleanly (${detail}). ` +
308
+ `The page is saved; run \`hypomnema feedback-sync --check\` to reconcile the projection.`,
309
+ );
104
310
  }
105
311
 
106
312
  // ── main ─────────────────────────────────────────────────────────────────────
@@ -127,4 +333,7 @@ if (!args.entry) {
127
333
  process.exit(1);
128
334
  }
129
335
 
130
- writeFeedback(args.hypoDir, args.topic, args.entry, args.dryRun);
336
+ const today = new Date().toISOString().slice(0, 10);
337
+ const { wrote } = writeFeedback(args, today);
338
+
339
+ if (wrote && !args.noSync) runProjection(args);
package/scripts/graph.mjs CHANGED
@@ -24,8 +24,8 @@ import { loadHypoIgnore, isIgnored } from './lib/hypo-ignore.mjs';
24
24
  function parseArgs(argv) {
25
25
  const args = { hypoDir: null, format: 'json', minEdges: 0 };
26
26
  for (const arg of argv.slice(2)) {
27
- if (arg.startsWith('--hypo-dir=')) args.hypoDir = expandHome(arg.slice(11));
28
- else if (arg.startsWith('--format=')) args.format = arg.slice(9);
27
+ if (arg.startsWith('--hypo-dir=')) args.hypoDir = expandHome(arg.slice(11));
28
+ else if (arg.startsWith('--format=')) args.format = arg.slice(9);
29
29
  else if (arg.startsWith('--min-edges=')) args.minEdges = parseInt(arg.slice(12), 10) || 0;
30
30
  }
31
31
  if (!args.hypoDir) args.hypoDir = resolveHypoRoot();
@@ -75,7 +75,7 @@ function extractWikilinks(content) {
75
75
 
76
76
  function buildGraph(pages, slugIndex) {
77
77
  const edges = [];
78
- const inDegree = new Map();
78
+ const inDegree = new Map();
79
79
  const outDegree = new Map();
80
80
 
81
81
  for (const p of pages) {
@@ -85,7 +85,11 @@ function buildGraph(pages, slugIndex) {
85
85
 
86
86
  for (const p of pages) {
87
87
  let content;
88
- try { content = readFileSync(p.path, 'utf-8'); } catch { continue; }
88
+ try {
89
+ content = readFileSync(p.path, 'utf-8');
90
+ } catch {
91
+ continue;
92
+ }
89
93
  for (const link of extractWikilinks(content)) {
90
94
  const target = slugIndex.get(link);
91
95
  if (target && target !== p.slug) {
@@ -101,24 +105,28 @@ function buildGraph(pages, slugIndex) {
101
105
 
102
106
  // ── label escapers ────────────────────────────────────────────────────────────
103
107
 
104
- function escapeMermaid(s) { return s.replace(/"/g, '#quot;'); }
105
- function escapeDot(s) { return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); }
108
+ function escapeMermaid(s) {
109
+ return s.replace(/"/g, '#quot;');
110
+ }
111
+ function escapeDot(s) {
112
+ return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
113
+ }
106
114
 
107
115
  // ── formatters ────────────────────────────────────────────────────────────────
108
116
 
109
117
  function formatJson(pages, graph, minEdges) {
110
118
  const nodes = pages
111
- .map(p => ({
119
+ .map((p) => ({
112
120
  slug: p.slug,
113
121
  in: graph.inDegree.get(p.slug) || 0,
114
122
  out: graph.outDegree.get(p.slug) || 0,
115
123
  }))
116
- .filter(n => minEdges === 0 || n.in + n.out >= minEdges)
117
- .sort((a, b) => (b.in + b.out) - (a.in + a.out));
124
+ .filter((n) => minEdges === 0 || n.in + n.out >= minEdges)
125
+ .sort((a, b) => b.in + b.out - (a.in + a.out));
118
126
 
119
- const edges = graph.edges.filter(e => {
120
- const fn = nodes.find(n => n.slug === e.from);
121
- const tn = nodes.find(n => n.slug === e.to);
127
+ const edges = graph.edges.filter((e) => {
128
+ const fn = nodes.find((n) => n.slug === e.from);
129
+ const tn = nodes.find((n) => n.slug === e.to);
122
130
  return fn && tn;
123
131
  });
124
132
 
@@ -128,11 +136,11 @@ function formatJson(pages, graph, minEdges) {
128
136
  function formatMermaid(pages, graph, minEdges) {
129
137
  const activeNodes = new Set(
130
138
  pages
131
- .filter(p => {
139
+ .filter((p) => {
132
140
  const total = (graph.inDegree.get(p.slug) || 0) + (graph.outDegree.get(p.slug) || 0);
133
141
  return total >= minEdges;
134
142
  })
135
- .map(p => p.slug)
143
+ .map((p) => p.slug),
136
144
  );
137
145
 
138
146
  const lines = ['graph TD'];
@@ -149,11 +157,11 @@ function formatMermaid(pages, graph, minEdges) {
149
157
  function formatDot(pages, graph, minEdges) {
150
158
  const activeNodes = new Set(
151
159
  pages
152
- .filter(p => {
160
+ .filter((p) => {
153
161
  const total = (graph.inDegree.get(p.slug) || 0) + (graph.outDegree.get(p.slug) || 0);
154
162
  return total >= minEdges;
155
163
  })
156
- .map(p => p.slug)
164
+ .map((p) => p.slug),
157
165
  );
158
166
 
159
167
  const lines = ['digraph wiki {', ' rankdir=LR;'];
@@ -171,13 +179,18 @@ function formatDot(pages, graph, minEdges) {
171
179
  const args = parseArgs(process.argv);
172
180
 
173
181
  const ignorePatterns = loadHypoIgnore(args.hypoDir);
174
- const scanDirs = ['pages', 'projects'].map(d => join(args.hypoDir, d));
175
- const pages = scanDirs.flatMap(d => collectPages(d, args.hypoDir, [], ignorePatterns));
182
+ const scanDirs = ['pages', 'projects'].map((d) => join(args.hypoDir, d));
183
+ const pages = scanDirs.flatMap((d) => collectPages(d, args.hypoDir, [], ignorePatterns));
176
184
  const slugIndex = buildSlugIndex(pages);
177
- const graph = buildGraph(pages, slugIndex);
185
+ const graph = buildGraph(pages, slugIndex);
178
186
 
179
187
  switch (args.format) {
180
- case 'mermaid': console.log(formatMermaid(pages, graph, args.minEdges)); break;
181
- case 'dot': console.log(formatDot(pages, graph, args.minEdges)); break;
182
- default: console.log(formatJson(pages, graph, args.minEdges));
188
+ case 'mermaid':
189
+ console.log(formatMermaid(pages, graph, args.minEdges));
190
+ break;
191
+ case 'dot':
192
+ console.log(formatDot(pages, graph, args.minEdges));
193
+ break;
194
+ default:
195
+ console.log(formatJson(pages, graph, args.minEdges));
183
196
  }
@@ -16,25 +16,56 @@
16
16
  * Options:
17
17
  * --hypo-dir=<path> Hypomnema root (default: resolved via HYPO_DIR / hypo-config.md / ~/hypomnema)
18
18
  * --json Output as JSON
19
+ * --check=<path> Privacy guard: exit 1 if <path> matches a `.hypoignore`
20
+ * pattern, exit 0 silently otherwise. Used by `/hypo:ingest`
21
+ * to refuse ingesting secrets before they reach `sources/`.
19
22
  */
20
23
 
21
- import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
22
- import { join, extname, basename } from 'path';
24
+ import { existsSync, readFileSync, readdirSync, statSync, realpathSync } from 'fs';
25
+ import { join, extname, basename, isAbsolute } from 'path';
23
26
  import { resolveHypoRoot, expandHome } from './lib/hypo-root.mjs';
24
27
  import { loadHypoIgnore, isIgnored } from './lib/hypo-ignore.mjs';
25
28
 
26
29
  // ── arg parsing ──────────────────────────────────────────────────────────────
27
30
 
28
31
  function parseArgs(argv) {
29
- const args = { hypoDir: null, json: false };
32
+ const args = { hypoDir: null, json: false, check: null };
30
33
  for (const arg of argv.slice(2)) {
31
34
  if (arg.startsWith('--hypo-dir=')) args.hypoDir = expandHome(arg.slice(11));
32
- else if (arg === '--json') args.json = true;
35
+ else if (arg === '--json') args.json = true;
36
+ else if (arg.startsWith('--check=')) args.check = expandHome(arg.slice(8));
33
37
  }
34
38
  if (!args.hypoDir) args.hypoDir = resolveHypoRoot();
35
39
  return args;
36
40
  }
37
41
 
42
+ // Find the first `.hypoignore` pattern that matches `target`, or null.
43
+ // `target` is resolved relative to `hypoDir` when not already absolute, so
44
+ // both wiki-relative destinations (`sources/<slug>.md`) and user-supplied
45
+ // input paths land inside the wiki tree for anchored-pattern matching.
46
+ //
47
+ // A symlink with an innocuous name (`note.md` → `.env`) must still be
48
+ // refused, so an existing target is also checked by its realpath. realpath
49
+ // throws ENOENT for a not-yet-created destination — fall back to the lexical
50
+ // path in that case. Both the lexical and resolved paths are tested, for
51
+ // defense-in-depth when only one of them matches a pattern.
52
+ function matchingIgnorePattern(target, hypoDir, patterns) {
53
+ const lexical = isAbsolute(target) ? target : join(hypoDir, target);
54
+ let resolved = lexical;
55
+ try {
56
+ resolved = realpathSync(lexical);
57
+ } catch {
58
+ /* not on disk — lexical only */
59
+ }
60
+ const candidates = resolved === lexical ? [lexical] : [lexical, resolved];
61
+ for (const pattern of patterns) {
62
+ for (const candidate of candidates) {
63
+ if (isIgnored(candidate, hypoDir, [pattern])) return pattern;
64
+ }
65
+ }
66
+ return null;
67
+ }
68
+
38
69
  // ── helpers ──────────────────────────────────────────────────────────────────
39
70
 
40
71
  function collectMdFiles(dir, acc = [], hypoDir = '', ignorePatterns = []) {
@@ -57,7 +88,10 @@ function parseFrontmatter(content) {
57
88
  for (const line of m[1].split('\n')) {
58
89
  const idx = line.indexOf(':');
59
90
  if (idx < 0) continue;
60
- fm[line.slice(0, idx).trim()] = line.slice(idx + 1).trim().replace(/^["']|["']$/g, '');
91
+ fm[line.slice(0, idx).trim()] = line
92
+ .slice(idx + 1)
93
+ .trim()
94
+ .replace(/^["']|["']$/g, '');
61
95
  }
62
96
  return fm;
63
97
  }
@@ -67,9 +101,30 @@ function parseFrontmatter(content) {
67
101
  const args = parseArgs(process.argv);
68
102
 
69
103
  const ignorePatterns = loadHypoIgnore(args.hypoDir);
104
+
105
+ // ── --check guard ────────────────────────────────────────────────────────────
106
+ // Privacy boundary (spec §6.8 / §8.10): refuse to ingest a path that matches
107
+ // `.hypoignore` before it ever reaches `sources/`. Separate from the
108
+ // listing-mode filter below — this is the explicit-reject path.
109
+ if (args.check) {
110
+ const matched = matchingIgnorePattern(args.check, args.hypoDir, ignorePatterns);
111
+ if (matched) {
112
+ console.error(
113
+ `Refused: '${args.check}' matches .hypoignore pattern '${matched}' — not ingesting.`,
114
+ );
115
+ process.exit(1);
116
+ }
117
+ process.exit(0);
118
+ }
119
+
70
120
  const sourcesDir = join(args.hypoDir, 'sources');
71
121
  const allSources = existsSync(sourcesDir)
72
- ? readdirSync(sourcesDir).filter(e => !e.startsWith('.') && !statSync(join(sourcesDir, e)).isDirectory() && !isIgnored(join(sourcesDir, e), args.hypoDir, ignorePatterns))
122
+ ? readdirSync(sourcesDir).filter(
123
+ (e) =>
124
+ !e.startsWith('.') &&
125
+ !statSync(join(sourcesDir, e)).isDirectory() &&
126
+ !isIgnored(join(sourcesDir, e), args.hypoDir, ignorePatterns),
127
+ )
73
128
  : [];
74
129
 
75
130
  // collect all source: references in wiki pages
@@ -78,7 +133,11 @@ const referencedSources = new Set();
78
133
 
79
134
  for (const f of pageFiles) {
80
135
  let content;
81
- try { content = readFileSync(f, 'utf-8'); } catch { continue; }
136
+ try {
137
+ content = readFileSync(f, 'utf-8');
138
+ } catch {
139
+ continue;
140
+ }
82
141
  const fm = parseFrontmatter(content);
83
142
  if (fm.source && !fm.source.startsWith('session:')) {
84
143
  referencedSources.add(fm.source);
@@ -86,7 +145,7 @@ for (const f of pageFiles) {
86
145
  }
87
146
 
88
147
  // sources with no summary page
89
- const orphaned = allSources.filter(s => {
148
+ const orphaned = allSources.filter((s) => {
90
149
  const slug = basename(s, extname(s));
91
150
  return !referencedSources.has(s) && !referencedSources.has(slug);
92
151
  });
@@ -95,24 +154,38 @@ const orphaned = allSources.filter(s => {
95
154
  const missingSource = [];
96
155
  for (const f of pageFiles) {
97
156
  let content;
98
- try { content = readFileSync(f, 'utf-8'); } catch { continue; }
157
+ try {
158
+ content = readFileSync(f, 'utf-8');
159
+ } catch {
160
+ continue;
161
+ }
99
162
  const fm = parseFrontmatter(content);
100
163
  if (!fm.source || fm.source.startsWith('session:')) continue;
101
164
  const sourceFile = join(sourcesDir, fm.source);
102
- const sourceFileWithExt = allSources.find(s => s === fm.source || basename(s, extname(s)) === fm.source);
165
+ const sourceFileWithExt = allSources.find(
166
+ (s) => s === fm.source || basename(s, extname(s)) === fm.source,
167
+ );
103
168
  if (!sourceFileWithExt && !existsSync(sourceFile)) {
104
169
  missingSource.push({ page: f, source: fm.source });
105
170
  }
106
171
  }
107
172
 
108
173
  if (args.json) {
109
- console.log(JSON.stringify({
110
- totalSources: allSources.length,
111
- orphaned,
112
- missingSource,
113
- }, null, 2));
174
+ console.log(
175
+ JSON.stringify(
176
+ {
177
+ totalSources: allSources.length,
178
+ orphaned,
179
+ missingSource,
180
+ },
181
+ null,
182
+ 2,
183
+ ),
184
+ );
114
185
  } else {
115
- console.log('[hypomnema] Listing pending ingest targets — synthesis is performed by /hypo:ingest inside Claude Code.');
186
+ console.log(
187
+ '[hypomnema] Listing pending ingest targets — synthesis is performed by /hypo:ingest inside Claude Code.',
188
+ );
116
189
  console.log(`Sources: ${allSources.length} total`);
117
190
 
118
191
  if (orphaned.length === 0) {