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
@@ -5,7 +5,11 @@ export function parseFrontmatter(content) {
5
5
  for (const line of m[1].split(/\r?\n/)) {
6
6
  const idx = line.indexOf(':');
7
7
  if (idx < 0) continue;
8
- fm[line.slice(0, idx).trim()] = line.slice(idx + 1).trim().replace(/\s*#.*$/, '').replace(/^["']|["']$/g, '');
8
+ fm[line.slice(0, idx).trim()] = line
9
+ .slice(idx + 1)
10
+ .trim()
11
+ .replace(/\s*#.*$/, '')
12
+ .replace(/^["']|["']$/g, '');
9
13
  }
10
14
  return fm;
11
15
  }
@@ -6,19 +6,21 @@ export function loadHypoIgnore(hypoDir) {
6
6
  if (!existsSync(ignorePath)) return [];
7
7
  return readFileSync(ignorePath, 'utf-8')
8
8
  .split('\n')
9
- .map(l => l.trim())
10
- .filter(l => l && !l.startsWith('#'));
9
+ .map((l) => l.trim())
10
+ .filter((l) => l && !l.startsWith('#'));
11
11
  }
12
12
 
13
13
  function globToRegex(glob) {
14
- return new RegExp('^' +
15
- glob
16
- .replace(/[.+^${}()|[\]\\]/g, '\\$&')
17
- .replace(/\*\*/g, '\x00') // placeholder before single-* replacement
18
- .replace(/\*/g, '[^/]*')
19
- .replace(/\?/g, '[^/]')
20
- .replace(/\x00/g, '.*') // restore ** → .*
21
- + '$');
14
+ return new RegExp(
15
+ '^' +
16
+ glob
17
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
18
+ .replace(/\*\*/g, '\x00') // placeholder before single-* replacement
19
+ .replace(/\*/g, '[^/]*')
20
+ .replace(/\?/g, '[^/]')
21
+ .replace(/\x00/g, '.*') + // restore ** → .*
22
+ '$',
23
+ );
22
24
  }
23
25
 
24
26
  export function isIgnored(filePath, hypoDir, patterns) {
@@ -10,7 +10,15 @@
10
10
  * that as a refusal to operate on the destination.
11
11
  */
12
12
 
13
- import { existsSync, readFileSync, writeFileSync, renameSync, lstatSync, mkdirSync, unlinkSync } from 'fs';
13
+ import {
14
+ existsSync,
15
+ readFileSync,
16
+ writeFileSync,
17
+ renameSync,
18
+ lstatSync,
19
+ mkdirSync,
20
+ unlinkSync,
21
+ } from 'fs';
14
22
  import { join, dirname } from 'path';
15
23
  import { createHash } from 'crypto';
16
24
 
@@ -20,12 +28,20 @@ export function sha256(buf) {
20
28
 
21
29
  export function isRegularFile(path) {
22
30
  if (!existsSync(path)) return false;
23
- try { return lstatSync(path).isFile(); } catch { return false; }
31
+ try {
32
+ return lstatSync(path).isFile();
33
+ } catch {
34
+ return false;
35
+ }
24
36
  }
25
37
 
26
38
  export function readFileIfRegular(path) {
27
39
  if (!isRegularFile(path)) return null;
28
- try { return readFileSync(path); } catch { return null; }
40
+ try {
41
+ return readFileSync(path);
42
+ } catch {
43
+ return null;
44
+ }
29
45
  }
30
46
 
31
47
  export function readPkgJson(pkgJsonPath) {
@@ -36,7 +52,7 @@ export function readPkgJson(pkgJsonPath) {
36
52
  } catch (err) {
37
53
  // Preserve corrupt file as <name>.corrupt-<ts>.json so the user can recover.
38
54
  try {
39
- const ts = new Date().toISOString().replace(/[:.]/g, '-');
55
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
40
56
  const bak = `${pkgJsonPath}.corrupt-${ts}.json`;
41
57
  renameSync(pkgJsonPath, bak);
42
58
  console.error(`[hypomnema] WARN: ${pkgJsonPath} was not valid JSON. Preserved as ${bak}.`);
@@ -53,7 +69,9 @@ export function writePkgJsonAtomic(pkgJsonPath, data) {
53
69
  try {
54
70
  renameSync(tmp, pkgJsonPath);
55
71
  } catch (err) {
56
- try { unlinkSync(tmp); } catch {}
72
+ try {
73
+ unlinkSync(tmp);
74
+ } catch {}
57
75
  throw err;
58
76
  }
59
77
  }
@@ -0,0 +1,225 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * project-create.mjs — atomic auto-project scaffold (fix #23, ADR 0023)
4
+ *
5
+ * Invoked by the LLM (NOT a user-facing subcommand — ADR 0023 deprecated
6
+ * `hypomnema project new`) after the user answers "Y" to a SessionStart /
7
+ * CwdChanged auto-project offer. One call materializes a whole project:
8
+ *
9
+ * 1. mkdir projects/<name>/{decisions,session-log}
10
+ * 2. copy templates/projects/_template/*.md with token substitution
11
+ * <project-name> → name, <started> → date, <working_dir> → cwd,
12
+ * YYYY-MM-DD → today (frontmatter `updated:` only)
13
+ * 3. append a row to root hot.md "Active Projects" table
14
+ * 4. append a `## [today] project-create | <name>` entry to log.md
15
+ *
16
+ * Idempotent: existing files/rows/entries are preserved, never overwritten or
17
+ * duplicated, so a re-run after a partial failure converges.
18
+ *
19
+ * CLI:
20
+ * node scripts/lib/project-create.mjs --name <slug> --working-dir <path> \
21
+ * [--hypo-dir <path>] [--started <YYYY-MM-DD>] [--json]
22
+ */
23
+
24
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from 'fs';
25
+ import { join, dirname, resolve, sep } from 'path';
26
+ import { fileURLToPath } from 'url';
27
+ import { resolveHypoRoot, expandHome } from './hypo-root.mjs';
28
+
29
+ const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
30
+ const PKG_ROOT = join(SCRIPT_DIR, '..', '..');
31
+ const TEMPLATE_DIR = join(PKG_ROOT, 'templates', 'projects', '_template');
32
+
33
+ const TEMPLATE_FILES = ['index.md', 'prd.md', 'hot.md', 'session-state.md'];
34
+
35
+ function todayISO() {
36
+ return new Date().toISOString().slice(0, 10);
37
+ }
38
+
39
+ /**
40
+ * Substitute the project tokens in a template file's content.
41
+ * @param {string} content
42
+ * @param {{name: string, started: string, workingDir: string, today: string}} vars
43
+ */
44
+ export function substituteTokens(content, { name, started, workingDir, today }) {
45
+ return content
46
+ .split('<project-name>')
47
+ .join(name)
48
+ .split('<started>')
49
+ .join(started)
50
+ .split('<working_dir>')
51
+ .join(workingDir)
52
+ .split('YYYY-MM-DD')
53
+ .join(today);
54
+ }
55
+
56
+ /**
57
+ * Insert a project row into the root hot.md "Active Projects" table.
58
+ * Idempotent: returns the original content unchanged if a row already links
59
+ * to `[[projects/<name>/hot]]`. Returns null if the table cannot be located.
60
+ * @returns {string|null} new content, or null when no table marker is found
61
+ */
62
+ export function insertHotRow(content, name, today) {
63
+ const link = `[[projects/${name}/hot]]`;
64
+ if (content.includes(link)) return content; // already present
65
+ const lines = content.split('\n');
66
+ // Scope the search to the "## Active Projects" section so a table appearing
67
+ // earlier in hot.md can't capture the row (codex review 2026-05-22). Start
68
+ // looking from the heading; stop at the next H2 so we never cross sections.
69
+ const headingIdx = lines.findIndex((l) => /^##\s+Active Projects\s*$/.test(l));
70
+ if (headingIdx === -1) return null;
71
+ let sepIdx = -1;
72
+ for (let i = headingIdx + 1; i < lines.length; i++) {
73
+ if (/^##\s/.test(lines[i])) break; // next section — table not found in scope
74
+ if (/^\|\s*-{2,}\s*\|/.test(lines[i])) {
75
+ sepIdx = i;
76
+ break;
77
+ }
78
+ }
79
+ if (sepIdx === -1) return null;
80
+ const row = `| ${name} | ${today} | ${link} |`;
81
+ lines.splice(sepIdx + 1, 0, row);
82
+ return lines.join('\n');
83
+ }
84
+
85
+ /**
86
+ * Create a project. Idempotent and best-effort per side effect: a missing root
87
+ * hot.md / log.md is reported in `warnings` rather than thrown, so the core
88
+ * project files still land.
89
+ *
90
+ * @param {{hypoDir: string, name: string, workingDir: string, started?: string, today?: string}} opts
91
+ * @returns {{created: string[], skipped: string[], warnings: string[], projectDir: string}}
92
+ */
93
+ export function createProject(opts) {
94
+ const { name, workingDir } = opts;
95
+ // Name must be a single path segment with at least one alnum. The charset
96
+ // alone is not enough: `.`, `..`, `...` all pass `[A-Za-z0-9._-]+` yet would
97
+ // resolve `projects/<name>` to the wiki root or `projects/` itself (codex
98
+ // review 2026-05-22, both workers). Reject dot-only names and require alnum.
99
+ if (!name || !/^[A-Za-z0-9._-]+$/.test(name) || /^\.+$/.test(name) || !/[A-Za-z0-9]/.test(name)) {
100
+ throw new Error(
101
+ `invalid project name: ${JSON.stringify(name)} (need a single segment with ≥1 alnum, charset A-Za-z0-9._-, not "."/"..")`,
102
+ );
103
+ }
104
+ if (!workingDir) throw new Error('workingDir is required');
105
+
106
+ const hypoDir = opts.hypoDir || resolveHypoRoot();
107
+ const today = opts.today || todayISO();
108
+ const started = opts.started || today;
109
+ const vars = { name, started, workingDir, today };
110
+
111
+ const created = [];
112
+ const skipped = [];
113
+ const warnings = [];
114
+
115
+ const projectsRoot = resolve(hypoDir, 'projects');
116
+ const projectDir = join(projectsRoot, name);
117
+ // Defense in depth: the resolved target must stay strictly inside projects/.
118
+ if (
119
+ resolve(projectDir) !== join(projectsRoot, name) ||
120
+ !resolve(projectDir).startsWith(projectsRoot + sep)
121
+ ) {
122
+ throw new Error(`project name escapes projects/: ${JSON.stringify(name)}`);
123
+ }
124
+ for (const sub of ['decisions', 'session-log']) {
125
+ mkdirSync(join(projectDir, sub), { recursive: true });
126
+ }
127
+
128
+ for (const file of TEMPLATE_FILES) {
129
+ const src = join(TEMPLATE_DIR, file);
130
+ const dest = join(projectDir, file);
131
+ if (!existsSync(src)) {
132
+ warnings.push(`template missing: ${file}`);
133
+ continue;
134
+ }
135
+ if (existsSync(dest)) {
136
+ skipped.push(`projects/${name}/${file}`);
137
+ continue;
138
+ }
139
+ writeFileSync(dest, substituteTokens(readFileSync(src, 'utf-8'), vars));
140
+ created.push(`projects/${name}/${file}`);
141
+ }
142
+
143
+ // root hot.md pointer row
144
+ const hotPath = join(hypoDir, 'hot.md');
145
+ if (existsSync(hotPath)) {
146
+ const orig = readFileSync(hotPath, 'utf-8');
147
+ const next = insertHotRow(orig, name, today);
148
+ if (next === null) {
149
+ warnings.push('root hot.md: no Active Projects table found — add row manually');
150
+ } else if (next !== orig) {
151
+ writeFileSync(hotPath, next);
152
+ created.push('hot.md row');
153
+ } else {
154
+ skipped.push('hot.md row');
155
+ }
156
+ } else {
157
+ warnings.push('root hot.md missing — skipped pointer row');
158
+ }
159
+
160
+ // log.md entry
161
+ const logPath = join(hypoDir, 'log.md');
162
+ const entry = `## [${today}] project-create | ${name}`;
163
+ if (existsSync(logPath)) {
164
+ const log = readFileSync(logPath, 'utf-8');
165
+ if (log.includes(entry)) {
166
+ skipped.push('log.md entry');
167
+ } else {
168
+ writeFileSync(logPath, log.replace(/\s*$/, '\n') + `\n${entry}\n`);
169
+ created.push('log.md entry');
170
+ }
171
+ } else {
172
+ warnings.push('log.md missing — skipped activity entry');
173
+ }
174
+
175
+ return { created, skipped, warnings, projectDir };
176
+ }
177
+
178
+ // ── CLI ──────────────────────────────────────────────────────────────────────
179
+ function parseArgs(argv) {
180
+ const args = { json: false };
181
+ for (let i = 0; i < argv.length; i++) {
182
+ const a = argv[i];
183
+ if (a === '--json') args.json = true;
184
+ else if (a === '--name') args.name = argv[++i];
185
+ else if (a === '--working-dir') args.workingDir = expandHome(argv[++i]);
186
+ else if (a === '--hypo-dir') args.hypoDir = expandHome(argv[++i]);
187
+ else if (a === '--started') args.started = argv[++i];
188
+ else if (a === '--help' || a === '-h') args.help = true;
189
+ }
190
+ return args;
191
+ }
192
+
193
+ const USAGE = `Usage: project-create.mjs --name <slug> --working-dir <path> [--hypo-dir <path>] [--started YYYY-MM-DD] [--json]`;
194
+
195
+ function main() {
196
+ const args = parseArgs(process.argv.slice(2));
197
+ if (args.help) {
198
+ console.log(USAGE);
199
+ return;
200
+ }
201
+ if (!args.name || !args.workingDir) {
202
+ console.error(`Error: --name and --working-dir are required\n${USAGE}`);
203
+ process.exit(1);
204
+ }
205
+ let result;
206
+ try {
207
+ result = createProject(args);
208
+ } catch (err) {
209
+ console.error(`Error: ${err?.message ?? String(err)}`);
210
+ process.exit(1);
211
+ }
212
+ if (args.json) {
213
+ console.log(JSON.stringify(result, null, 2));
214
+ } else {
215
+ result.created.forEach((c) => console.log(`✓ created ${c}`));
216
+ result.skipped.forEach((s) => console.log(`· skipped ${s} (exists)`));
217
+ result.warnings.forEach((w) => console.warn(`⚠ ${w}`));
218
+ console.log(`\nProject '${args.name}' ready at ${result.projectDir}`);
219
+ }
220
+ }
221
+
222
+ // Run as CLI only when invoked directly, not when imported by tests.
223
+ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
224
+ main();
225
+ }
@@ -0,0 +1,96 @@
1
+ import { existsSync, readFileSync } from 'fs';
2
+ import { join } from 'path';
3
+
4
+ const VOCAB_HEADER_RE = /^##\s+(?:\d+\.\s+)?Tag\s+(?:Vocabulary|Taxonomy)\s*$/m;
5
+ const TYPE_TAXONOMY_HEADER_RE = /^##\s+(?:\d+\.\s+)?Page\s+Type\s+Taxonomy\s*$/m;
6
+ const PAGE_DIR_TOKEN_RE = /`pages\/([a-z0-9-]+)\//g;
7
+ const NEXT_H2_RE = /^##\s+/m;
8
+ const CATEGORY_PREFIX_RE = /^\s*\*\*[^*]+\*\*:\s*/;
9
+ const BACKTICK_TOKEN_RE = /`([^`]+)`/g;
10
+
11
+ const FORBIDDEN_PATTERNS = [
12
+ { name: 'PascalCase', test: (t) => /[A-Z]/.test(t) },
13
+ { name: 'whitespace', test: (t) => /\s/.test(t) },
14
+ {
15
+ name: 'generic',
16
+ test: (t) => ['general', 'misc', 'other', 'todo'].includes(t),
17
+ },
18
+ {
19
+ name: 'plural',
20
+ test: (t) => ['learnings', 'tips', 'feedbacks', 'concepts', 'playbooks'].includes(t),
21
+ },
22
+ ];
23
+
24
+ export function checkForbidden(tag) {
25
+ for (const rule of FORBIDDEN_PATTERNS) {
26
+ if (rule.test(tag)) return rule.name;
27
+ }
28
+ return null;
29
+ }
30
+
31
+ // Extract only backtick tokens from canonical vocabulary lines.
32
+ // A vocabulary line is one whose non-backtick residue (after stripping an
33
+ // optional `**Category**:` prefix) contains only separators (whitespace, commas).
34
+ // This excludes prose like "...`lint` blocks unknown tags..." and table rows
35
+ // in the Forbidden patterns section that mention example tokens.
36
+ function vocabLineTokens(rawLine) {
37
+ const line = rawLine.replace(CATEGORY_PREFIX_RE, '').trim();
38
+ if (!line) return [];
39
+ const residue = line.replace(/`[^`]+`/g, '').trim();
40
+ if (!/^[\s,]*$/.test(residue)) return [];
41
+ const tokens = [];
42
+ for (const m of line.matchAll(BACKTICK_TOKEN_RE)) {
43
+ const t = m[1].trim();
44
+ if (t && !t.includes(' ')) tokens.push(t);
45
+ }
46
+ return tokens;
47
+ }
48
+
49
+ export function parseSchemaVocab(hypoDir) {
50
+ const path = join(hypoDir, 'SCHEMA.md');
51
+ if (!existsSync(path)) return new Set();
52
+ const content = readFileSync(path, 'utf-8');
53
+
54
+ const headerMatch = VOCAB_HEADER_RE.exec(content);
55
+ if (!headerMatch) return new Set();
56
+
57
+ const sectionStart = headerMatch.index + headerMatch[0].length;
58
+ const rest = content.slice(sectionStart);
59
+ const nextH2 = NEXT_H2_RE.exec(rest);
60
+ const section = nextH2 ? rest.slice(0, nextH2.index) : rest;
61
+
62
+ const vocab = new Set();
63
+ for (const rawLine of section.split('\n')) {
64
+ for (const token of vocabLineTokens(rawLine)) vocab.add(token);
65
+ }
66
+ return vocab;
67
+ }
68
+
69
+ // Derive the set of valid immediate subdirectories under pages/ from the
70
+ // SCHEMA "Page Type Taxonomy" table (e.g. learnings, feedback, people). Single
71
+ // source of truth — adding a type+dir row to SCHEMA automatically widens the
72
+ // whitelist. Returns an empty Set when SCHEMA.md or the table is absent, which
73
+ // callers treat as "skip the check" for back-compat with minimal wikis.
74
+ export function parseSchemaPageDirs(hypoDir) {
75
+ const path = join(hypoDir, 'SCHEMA.md');
76
+ if (!existsSync(path)) return new Set();
77
+ const content = readFileSync(path, 'utf-8');
78
+
79
+ const headerMatch = TYPE_TAXONOMY_HEADER_RE.exec(content);
80
+ if (!headerMatch) return new Set();
81
+
82
+ const sectionStart = headerMatch.index + headerMatch[0].length;
83
+ const rest = content.slice(sectionStart);
84
+ const nextH2 = NEXT_H2_RE.exec(rest);
85
+ const section = nextH2 ? rest.slice(0, nextH2.index) : rest;
86
+
87
+ // Only scan table rows (lines starting with `|`) so a backticked path that
88
+ // appears in surrounding prose (e.g. an example of a *wrong* dir) can never
89
+ // leak into the whitelist.
90
+ const dirs = new Set();
91
+ for (const rawLine of section.split('\n')) {
92
+ if (!rawLine.trimStart().startsWith('|')) continue;
93
+ for (const m of rawLine.matchAll(PAGE_DIR_TOKEN_RE)) dirs.add(m[1]);
94
+ }
95
+ return dirs;
96
+ }