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
@@ -0,0 +1,277 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * session-audit.mjs — Hypomnema autonomy/observability audit
4
+ *
5
+ * Reads completed session transcripts and emits per-session metrics
6
+ * (search count, ingest count, URLs mentioned, feedback count) so the
7
+ * weekly observability report (Lane E) can compute autonomy scores.
8
+ *
9
+ * Transcript dual-source (ADR 0019):
10
+ * 1) Primary: <hypo-dir>/.cache/sessions/index.jsonl
11
+ * Written by hooks/hypo-session-record.mjs (Stop hook).
12
+ * 2) Fallback: ~/.claude/projects/<encoded>/*.jsonl
13
+ * Scanned directly when the index is missing/empty.
14
+ *
15
+ * The audit is heuristic. Classification rules:
16
+ * - staleness-skip: transcript older than --max-age-days (default 30)
17
+ * - search-0: 0 search/query tool uses
18
+ * - search-many: >= 5 search/query tool uses
19
+ * - ingest-missed: >= 2 URLs in transcript and 0 ingest calls
20
+ * - normal: otherwise
21
+ *
22
+ * Usage:
23
+ * node scripts/session-audit.mjs [--hypo-dir=<path>] [--limit N]
24
+ * [--max-age-days N] [--json]
25
+ */
26
+
27
+ import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
28
+ import { join } from 'path';
29
+ import { homedir } from 'os';
30
+ import { resolveHypoRoot, expandHome } from './lib/hypo-root.mjs';
31
+
32
+ const HOME = homedir();
33
+
34
+ function parseArgs(argv) {
35
+ const args = { hypoDir: null, limit: 50, maxAgeDays: 30, json: false, fallbackAll: false };
36
+ for (const arg of argv.slice(2)) {
37
+ if (arg.startsWith('--hypo-dir=')) args.hypoDir = expandHome(arg.slice(11));
38
+ else if (arg.startsWith('--limit=')) args.limit = parseInt(arg.slice(8), 10) || 50;
39
+ else if (arg.startsWith('--max-age-days=')) args.maxAgeDays = parseInt(arg.slice(15), 10) || 30;
40
+ else if (arg === '--json') args.json = true;
41
+ else if (arg === '--fallback-all-projects') args.fallbackAll = true;
42
+ }
43
+ if (!args.hypoDir) args.hypoDir = resolveHypoRoot();
44
+ return args;
45
+ }
46
+
47
+ // ── transcript discovery ─────────────────────────────────────────────────────
48
+
49
+ function readIndexEntries(hypoDir) {
50
+ const path = join(hypoDir, '.cache', 'sessions', 'index.jsonl');
51
+ if (!existsSync(path)) return [];
52
+ const entries = [];
53
+ for (const line of readFileSync(path, 'utf-8').split('\n')) {
54
+ const trimmed = line.trim();
55
+ if (!trimmed) continue;
56
+ try {
57
+ const e = JSON.parse(trimmed);
58
+ if (e && e.transcript_path && e.session_id) entries.push(e);
59
+ } catch {
60
+ /* skip malformed lines */
61
+ }
62
+ }
63
+ // Deduplicate by session_id, keeping the latest entry.
64
+ const bySession = new Map();
65
+ for (const e of entries) bySession.set(e.session_id, e);
66
+ return [...bySession.values()];
67
+ }
68
+
69
+ // Claude Code encodes a working directory by replacing path separators with
70
+ // hyphens, so `/Users/foo/wiki` becomes `-Users-foo-wiki`. We use this to
71
+ // limit the fallback scan to the directory matching the wiki under audit
72
+ // rather than every project on the machine.
73
+ function encodeCwdForClaude(cwd) {
74
+ return String(cwd).replace(/\//g, '-');
75
+ }
76
+
77
+ function scanFallback(claudeProjectsDir, { scopeDir = null, all = false } = {}) {
78
+ if (!existsSync(claudeProjectsDir)) return [];
79
+ const subdirs = readdirSync(claudeProjectsDir).filter((d) =>
80
+ statSync(join(claudeProjectsDir, d)).isDirectory(),
81
+ );
82
+ let targets;
83
+ if (all) {
84
+ targets = subdirs;
85
+ } else if (scopeDir) {
86
+ const encoded = encodeCwdForClaude(scopeDir);
87
+ targets = subdirs.filter((d) => d === encoded);
88
+ } else {
89
+ targets = [];
90
+ }
91
+ const entries = [];
92
+ for (const dir of targets) {
93
+ const full = join(claudeProjectsDir, dir);
94
+ for (const file of readdirSync(full)) {
95
+ if (!file.endsWith('.jsonl')) continue;
96
+ const transcriptPath = join(full, file);
97
+ entries.push({
98
+ session_id: file.replace(/\.jsonl$/, ''),
99
+ transcript_path: transcriptPath,
100
+ recorded_at: statSync(transcriptPath).mtime.toISOString(),
101
+ source: 'fallback',
102
+ });
103
+ }
104
+ }
105
+ return entries;
106
+ }
107
+
108
+ export function loadSessionEntries(hypoDir, opts = {}) {
109
+ const primary = readIndexEntries(hypoDir);
110
+ if (primary.length > 0) return primary.map((e) => ({ ...e, source: e.source || 'index' }));
111
+ const fallbackDir = opts.fallbackDir ?? join(HOME, '.claude', 'projects');
112
+ // By default we restrict fallback to transcripts whose Claude-encoded path
113
+ // matches `scopeDir` (the wiki root). This keeps an empty wiki's report
114
+ // from harvesting unrelated `~/.claude/projects/*` sessions.
115
+ return scanFallback(fallbackDir, {
116
+ scopeDir: opts.scopeDir ?? hypoDir,
117
+ all: !!opts.fallbackAll,
118
+ });
119
+ }
120
+
121
+ // ── transcript parsing & metrics ─────────────────────────────────────────────
122
+
123
+ const SEARCH_TOOLS = new Set(['Grep', 'WebSearch', 'WebFetch']);
124
+ const SEARCH_CMDS = ['/hypo:query', '/query'];
125
+ const INGEST_CMDS = ['/hypo:ingest', '/ingest'];
126
+ const FEEDBACK_CMDS = ['/hypo:feedback', '/feedback'];
127
+
128
+ function readTranscriptLines(transcriptPath) {
129
+ if (!existsSync(transcriptPath)) return [];
130
+ const out = [];
131
+ for (const line of readFileSync(transcriptPath, 'utf-8').split('\n')) {
132
+ const trimmed = line.trim();
133
+ if (!trimmed) continue;
134
+ try {
135
+ out.push(JSON.parse(trimmed));
136
+ } catch {
137
+ /* skip */
138
+ }
139
+ }
140
+ return out;
141
+ }
142
+
143
+ function extractText(entry) {
144
+ if (!entry) return '';
145
+ if (typeof entry.content === 'string') return entry.content;
146
+ if (Array.isArray(entry.content)) {
147
+ return entry.content.map((c) => (typeof c === 'string' ? c : c?.text || '')).join('\n');
148
+ }
149
+ if (entry.message?.content) return extractText({ content: entry.message.content });
150
+ return '';
151
+ }
152
+
153
+ function countUrls(text) {
154
+ const matches = text.match(/https?:\/\/\S+/g);
155
+ return matches ? matches.length : 0;
156
+ }
157
+
158
+ // Walk a transcript line for `tool_use` blocks at the top level AND nested
159
+ // inside `message.content[]`. Real Claude Code transcripts put tool calls in
160
+ // the nested form (`{ type:"assistant", message:{ content:[{type:"tool_use",
161
+ // name:"Bash"}] } }`); legacy fixtures use the top-level form. Both must work.
162
+ function extractToolNames(entry) {
163
+ const names = [];
164
+ if (!entry || typeof entry !== 'object') return names;
165
+ if (entry.type === 'tool_use') {
166
+ const n = entry.name || entry.tool_name;
167
+ if (n) names.push(n);
168
+ } else if (entry.tool_name || entry.name) {
169
+ names.push(entry.tool_name || entry.name);
170
+ }
171
+ const content = entry.message?.content ?? (Array.isArray(entry.content) ? entry.content : null);
172
+ if (Array.isArray(content)) {
173
+ for (const block of content) {
174
+ if (block && typeof block === 'object' && block.type === 'tool_use') {
175
+ const n = block.name || block.tool_name;
176
+ if (n) names.push(n);
177
+ }
178
+ }
179
+ }
180
+ return names;
181
+ }
182
+
183
+ export function computeMetrics(transcriptLines) {
184
+ const metrics = { search_count: 0, ingest_count: 0, feedback_count: 0, urls: 0, messages: 0 };
185
+ for (const line of transcriptLines) {
186
+ metrics.messages++;
187
+ // Count tool_use blocks (top-level OR nested under message.content[]).
188
+ // Text scan is independent — tool_use blocks carry `input`, not `.text`,
189
+ // so extractText() won't double-count them.
190
+ for (const name of extractToolNames(line)) {
191
+ if (SEARCH_TOOLS.has(name)) metrics.search_count++;
192
+ }
193
+ const text = extractText(line);
194
+ if (!text) continue;
195
+ metrics.urls += countUrls(text);
196
+ for (const cmd of SEARCH_CMDS) if (text.includes(cmd)) metrics.search_count++;
197
+ for (const cmd of INGEST_CMDS) if (text.includes(cmd)) metrics.ingest_count++;
198
+ for (const cmd of FEEDBACK_CMDS) if (text.includes(cmd)) metrics.feedback_count++;
199
+ }
200
+ return metrics;
201
+ }
202
+
203
+ export function classify(metrics, ageDays, maxAgeDays) {
204
+ if (Number.isFinite(ageDays) && ageDays > maxAgeDays) return 'staleness-skip';
205
+ if (metrics.urls >= 2 && metrics.ingest_count === 0) return 'ingest-missed';
206
+ if (metrics.search_count >= 5) return 'search-many';
207
+ if (metrics.search_count === 0) return 'search-0';
208
+ return 'normal';
209
+ }
210
+
211
+ // ── main audit ───────────────────────────────────────────────────────────────
212
+
213
+ export function auditEntries(entries, { maxAgeDays = 30, limit = 50, now = Date.now() } = {}) {
214
+ const sorted = [...entries].sort((a, b) =>
215
+ (b.recorded_at || '').localeCompare(a.recorded_at || ''),
216
+ );
217
+ const slice = sorted.slice(0, limit);
218
+ const results = [];
219
+ for (const entry of slice) {
220
+ const lines = readTranscriptLines(entry.transcript_path);
221
+ const metrics = computeMetrics(lines);
222
+ const recordedAt = entry.recorded_at ? Date.parse(entry.recorded_at) : NaN;
223
+ const ageDays = Number.isFinite(recordedAt) ? (now - recordedAt) / (24 * 60 * 60 * 1000) : NaN;
224
+ const classification = classify(metrics, ageDays, maxAgeDays);
225
+ results.push({
226
+ session_id: entry.session_id,
227
+ source: entry.source,
228
+ recorded_at: entry.recorded_at,
229
+ age_days: Number.isFinite(ageDays) ? +ageDays.toFixed(2) : null,
230
+ metrics,
231
+ classification,
232
+ });
233
+ }
234
+ return results;
235
+ }
236
+
237
+ function isMain() {
238
+ try {
239
+ return import.meta.url === `file://${process.argv[1]}`;
240
+ } catch {
241
+ return false;
242
+ }
243
+ }
244
+
245
+ if (isMain()) {
246
+ const args = parseArgs(process.argv);
247
+ const entries = loadSessionEntries(args.hypoDir, { fallbackAll: args.fallbackAll });
248
+ const results = auditEntries(entries, { maxAgeDays: args.maxAgeDays, limit: args.limit });
249
+
250
+ if (args.json) {
251
+ console.log(
252
+ JSON.stringify({ hypo_dir: args.hypoDir, count: results.length, results }, null, 2),
253
+ );
254
+ process.exit(0);
255
+ }
256
+
257
+ if (results.length === 0) {
258
+ console.log('No sessions found.');
259
+ console.log(` Looked in: ${join(args.hypoDir, '.cache', 'sessions', 'index.jsonl')}`);
260
+ console.log(` Fallback: ~/.claude/projects/<encoded>/*.jsonl`);
261
+ process.exit(0);
262
+ }
263
+
264
+ const tally = {};
265
+ for (const r of results) tally[r.classification] = (tally[r.classification] || 0) + 1;
266
+
267
+ console.log(`Audited ${results.length} session(s) from ${results[0].source}:`);
268
+ for (const [k, v] of Object.entries(tally)) console.log(` ${k.padEnd(14)} ${v}`);
269
+ console.log('');
270
+ console.log('Recent sessions:');
271
+ for (const r of results.slice(0, 10)) {
272
+ const m = r.metrics;
273
+ console.log(
274
+ ` [${r.classification.padEnd(14)}] ${r.session_id} search=${m.search_count} ingest=${m.ingest_count} urls=${m.urls}`,
275
+ );
276
+ }
277
+ }
@@ -0,0 +1,224 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * scripts/smoke-pack.mjs — pre-publish smoke test
4
+ *
5
+ * Runs `npm pack`, installs the resulting tarball into an isolated temp
6
+ * directory, and exercises the installed CLI to verify the published
7
+ * artifact actually works. Catches mistakes that local tests miss:
8
+ * - `files:` / `.npmignore` excluding required assets
9
+ * - `bin` entry pointing to a missing file
10
+ * - runtime requires on paths not shipped in the tarball
11
+ *
12
+ * Usage:
13
+ * node scripts/smoke-pack.mjs [--keep]
14
+ *
15
+ * --keep Leave the temp directory in place for inspection on success.
16
+ */
17
+
18
+ import { spawnSync } from 'node:child_process';
19
+ import {
20
+ mkdtempSync,
21
+ rmSync,
22
+ readFileSync,
23
+ writeFileSync,
24
+ existsSync,
25
+ readdirSync,
26
+ renameSync,
27
+ } from 'node:fs';
28
+ import { join } from 'node:path';
29
+ import { tmpdir } from 'node:os';
30
+ import { fileURLToPath } from 'node:url';
31
+
32
+ const REPO = join(fileURLToPath(new URL('.', import.meta.url)), '..');
33
+ const KEEP = process.argv.includes('--keep');
34
+
35
+ const PKG = JSON.parse(readFileSync(join(REPO, 'package.json'), 'utf-8'));
36
+ const PKG_NAME = PKG.name;
37
+
38
+ function run(cmd, args, opts = {}) {
39
+ const res = spawnSync(cmd, args, { stdio: 'pipe', encoding: 'utf-8', ...opts });
40
+ if (res.status !== 0) {
41
+ process.stderr.write(res.stdout || '');
42
+ process.stderr.write(res.stderr || '');
43
+ throw new Error(`${cmd} ${args.join(' ')} exited ${res.status}`);
44
+ }
45
+ return res;
46
+ }
47
+
48
+ function step(msg) {
49
+ console.log(`\n▸ ${msg}`);
50
+ }
51
+
52
+ const work = mkdtempSync(join(tmpdir(), 'hypo-smoke-'));
53
+ const sandboxHome = join(work, 'home');
54
+ const installRoot = join(work, 'install');
55
+ const wikiDir = join(work, 'wiki');
56
+ let cleanupOk = false;
57
+
58
+ try {
59
+ step('npm pack');
60
+ const pack = run('npm', ['pack', '--json'], { cwd: REPO });
61
+ const meta = JSON.parse(pack.stdout)[0];
62
+ const tarball = join(REPO, meta.filename);
63
+ console.log(` → ${meta.filename} (${meta.size} bytes, ${meta.entryCount} entries)`);
64
+
65
+ step(`install into ${installRoot}`);
66
+ run('mkdir', ['-p', installRoot]);
67
+ writeFileSync(
68
+ join(installRoot, 'package.json'),
69
+ JSON.stringify({
70
+ name: 'hypo-smoke-host',
71
+ version: '0.0.0',
72
+ private: true,
73
+ }) + '\n',
74
+ );
75
+ run('npm', ['install', '--no-audit', '--no-fund', '--silent', tarball], { cwd: installRoot });
76
+
77
+ // Move tarball into the work dir so it's not left in the repo.
78
+ renameSync(tarball, join(work, meta.filename));
79
+
80
+ const cliBin = join(installRoot, 'node_modules', '.bin', 'hypomnema');
81
+ if (!existsSync(cliBin)) {
82
+ throw new Error(`bin not installed at ${cliBin}`);
83
+ }
84
+
85
+ step('hypomnema --help (installed)');
86
+ const help = run(cliBin, ['--help']);
87
+ if (!help.stdout.includes('Usage: hypomnema')) {
88
+ throw new Error('--help output did not match expected banner');
89
+ }
90
+
91
+ step('hypomnema init --dry-run (installed)');
92
+ const dryEnv = { ...process.env, HOME: sandboxHome, HYPO_DIR: wikiDir };
93
+ const dry = run(
94
+ cliBin,
95
+ [
96
+ 'init',
97
+ '--dry-run',
98
+ `--hypo-dir=${wikiDir}`,
99
+ '--no-hooks',
100
+ '--no-commands',
101
+ '--no-git-init',
102
+ '--no-shell',
103
+ ],
104
+ { env: dryEnv },
105
+ );
106
+ if (!/dry[\s-]?run/i.test(dry.stdout + dry.stderr)) {
107
+ console.log(dry.stdout);
108
+ throw new Error('init --dry-run did not announce dry-run mode');
109
+ }
110
+
111
+ step('verify shipped assets are present');
112
+ const required = [
113
+ 'scripts/init.mjs',
114
+ 'scripts/upgrade.mjs',
115
+ 'hooks',
116
+ 'commands',
117
+ 'templates',
118
+ 'README.md',
119
+ ];
120
+ const pkgRoot = join(installRoot, 'node_modules', PKG_NAME);
121
+ for (const rel of required) {
122
+ if (!existsSync(join(pkgRoot, rel))) {
123
+ throw new Error(`shipped tarball missing: ${rel}`);
124
+ }
125
+ }
126
+
127
+ step('hypomnema feedback-sync (installed) — check/write idempotency + bootstrap');
128
+ // Seed a fixture wiki + claude-home and exercise the feedback-sync surface
129
+ // through the installed CLI (proves the subcommand is routed + shipped and the
130
+ // Phase D bootstrap helper runs end-to-end). --check exits 1 on a fresh
131
+ // (un-written) projection, so use spawnSync (run() throws on non-zero).
132
+ const fbWiki = join(work, 'fb-wiki');
133
+ const fbHome = join(work, 'fb-home');
134
+ const fbProject = 'smoke';
135
+ run('mkdir', ['-p', join(fbWiki, 'pages', 'feedback')]);
136
+ run('mkdir', ['-p', join(fbHome, 'projects', fbProject, 'memory')]);
137
+ writeFileSync(
138
+ join(fbWiki, 'hypo-config.md'),
139
+ '---\ntitle: config\ntype: reference\n---\n# config\n',
140
+ );
141
+ writeFileSync(
142
+ join(fbWiki, 'pages', 'feedback', 'smoke-rule.md'),
143
+ [
144
+ '---',
145
+ 'title: Smoke Rule',
146
+ 'type: feedback',
147
+ 'status: active',
148
+ 'scope: global',
149
+ 'tier: L1',
150
+ 'targets: [project-memory, claude-learned]',
151
+ 'sensitivity: public',
152
+ 'priority: 4',
153
+ 'memory_summary: smoke rule for the packed CLI',
154
+ 'global_summary: always smoke-test the packed CLI',
155
+ 'promote_to_global: true',
156
+ 'reason: catch packaging regressions',
157
+ 'source: session:2026-05-21',
158
+ 'updated: 2026-05-21',
159
+ '---',
160
+ 'body',
161
+ '',
162
+ ].join('\n'),
163
+ );
164
+ writeFileSync(
165
+ join(fbHome, 'CLAUDE.md'),
166
+ '# Global\n<learned_behaviors>\n- [2026-05-20] legacy hand rule — 이유: bootstrap source\n</learned_behaviors>\n',
167
+ );
168
+ writeFileSync(join(fbHome, 'projects', fbProject, 'memory', 'MEMORY.md'), '# Memory Index\n');
169
+
170
+ const fbArgs = [`--hypo-dir=${fbWiki}`, `--claude-home=${fbHome}`, `--project-id=${fbProject}`];
171
+ const fbRun = (extra) =>
172
+ spawnSync(cliBin, ['feedback-sync', ...extra, ...fbArgs], { encoding: 'utf-8' });
173
+
174
+ const fbCheck1 = fbRun(['--check']);
175
+ if (fbCheck1.status !== 1) {
176
+ throw new Error(
177
+ `feedback-sync --check on fresh projection expected exit 1, got ${fbCheck1.status}`,
178
+ );
179
+ }
180
+ const fbWrite = fbRun(['--write']);
181
+ if (fbWrite.status !== 0) {
182
+ throw new Error(
183
+ `feedback-sync --write expected exit 0, got ${fbWrite.status}: ${fbWrite.stderr}`,
184
+ );
185
+ }
186
+ if (
187
+ !readFileSync(join(fbHome, 'CLAUDE.md'), 'utf-8').includes(
188
+ 'HYPO:FEEDBACK-SYNC:START source=smoke-rule',
189
+ )
190
+ ) {
191
+ throw new Error('feedback-sync --write did not project the managed block into CLAUDE.md');
192
+ }
193
+ const fbCheck2 = fbRun(['--check']);
194
+ if (fbCheck2.status !== 0) {
195
+ throw new Error(
196
+ `feedback-sync --check after --write expected clean exit 0, got ${fbCheck2.status}`,
197
+ );
198
+ }
199
+ const fbBootstrap = fbRun(['--bootstrap', '--dry-run']);
200
+ if (fbBootstrap.status !== 0 || !/would create draft/.test(fbBootstrap.stderr)) {
201
+ throw new Error(
202
+ `feedback-sync --bootstrap --dry-run did not announce drafts (exit ${fbBootstrap.status})`,
203
+ );
204
+ }
205
+
206
+ console.log('\n✓ smoke-pack passed');
207
+ cleanupOk = true;
208
+ } catch (err) {
209
+ console.error(`\n✗ smoke-pack failed: ${err.message}`);
210
+ console.error(` work dir preserved: ${work}`);
211
+ process.exitCode = 1;
212
+ } finally {
213
+ if (cleanupOk && !KEEP) {
214
+ rmSync(work, { recursive: true, force: true });
215
+ } else if (KEEP) {
216
+ console.log(`\nwork dir kept: ${work}`);
217
+ }
218
+ // Sweep any stray tarballs left in the repo root from a crashed run.
219
+ for (const f of readdirSync(REPO)) {
220
+ if (f.startsWith(`${PKG_NAME}-`) && f.endsWith('.tgz')) {
221
+ rmSync(join(REPO, f), { force: true });
222
+ }
223
+ }
224
+ }
package/scripts/stats.mjs CHANGED
@@ -24,7 +24,7 @@ function parseArgs(argv) {
24
24
  const args = { hypoDir: null, json: false };
25
25
  for (const arg of argv.slice(2)) {
26
26
  if (arg.startsWith('--hypo-dir=')) args.hypoDir = expandHome(arg.slice(11));
27
- else if (arg === '--json') args.json = true;
27
+ else if (arg === '--json') args.json = true;
28
28
  }
29
29
  if (!args.hypoDir) args.hypoDir = resolveHypoRoot();
30
30
  return args;
@@ -52,14 +52,17 @@ function parseFrontmatter(content) {
52
52
  for (const line of m[1].split('\n')) {
53
53
  const idx = line.indexOf(':');
54
54
  if (idx < 0) continue;
55
- fm[line.slice(0, idx).trim()] = line.slice(idx + 1).trim().replace(/^["']|["']$/g, '');
55
+ fm[line.slice(0, idx).trim()] = line
56
+ .slice(idx + 1)
57
+ .trim()
58
+ .replace(/^["']|["']$/g, '');
56
59
  }
57
60
  return fm;
58
61
  }
59
62
 
60
63
  function listDirs(dir) {
61
64
  if (!existsSync(dir)) return [];
62
- return readdirSync(dir).filter(e => {
65
+ return readdirSync(dir).filter((e) => {
63
66
  if (e.startsWith('.')) return false;
64
67
  return statSync(join(dir, e)).isDirectory();
65
68
  });
@@ -69,7 +72,7 @@ function getLastActivity(hypoDir) {
69
72
  const logPath = join(hypoDir, 'log.md');
70
73
  if (!existsSync(logPath)) return null;
71
74
  const content = readFileSync(logPath, 'utf-8');
72
- const dates = [...content.matchAll(/\b(\d{4}-\d{2}-\d{2})\b/g)].map(m => m[1]);
75
+ const dates = [...content.matchAll(/\b(\d{4}-\d{2}-\d{2})\b/g)].map((m) => m[1]);
73
76
  return dates.length ? dates[dates.length - 1] : null;
74
77
  }
75
78
 
@@ -79,9 +82,13 @@ const args = parseArgs(process.argv);
79
82
 
80
83
  const ignorePatterns = loadHypoIgnore(args.hypoDir);
81
84
  const pageFiles = collectMdFiles(join(args.hypoDir, 'pages'), [], args.hypoDir, ignorePatterns);
82
- const projects = listDirs(join(args.hypoDir, 'projects'));
83
- const sources = existsSync(join(args.hypoDir, 'sources'))
84
- ? readdirSync(join(args.hypoDir, 'sources')).filter(e => !e.startsWith('.') && !isIgnored(join(args.hypoDir, 'sources', e), args.hypoDir, ignorePatterns))
85
+ const projects = listDirs(join(args.hypoDir, 'projects'));
86
+ const sources = existsSync(join(args.hypoDir, 'sources'))
87
+ ? readdirSync(join(args.hypoDir, 'sources')).filter(
88
+ (e) =>
89
+ !e.startsWith('.') &&
90
+ !isIgnored(join(args.hypoDir, 'sources', e), args.hypoDir, ignorePatterns),
91
+ )
85
92
  : [];
86
93
 
87
94
  const typeCounts = {};
@@ -89,9 +96,16 @@ let missingFrontmatter = 0;
89
96
 
90
97
  for (const f of pageFiles) {
91
98
  let content;
92
- try { content = readFileSync(f, 'utf-8'); } catch { continue; }
99
+ try {
100
+ content = readFileSync(f, 'utf-8');
101
+ } catch {
102
+ continue;
103
+ }
93
104
  const fm = parseFrontmatter(content);
94
- if (!fm) { missingFrontmatter++; continue; }
105
+ if (!fm) {
106
+ missingFrontmatter++;
107
+ continue;
108
+ }
95
109
  const t = fm.type || 'unknown';
96
110
  typeCounts[t] = (typeCounts[t] || 0) + 1;
97
111
  }
@@ -100,7 +114,7 @@ let adrCount = 0;
100
114
  for (const p of projects) {
101
115
  const decisionsDir = join(args.hypoDir, 'projects', p, 'decisions');
102
116
  if (existsSync(decisionsDir)) {
103
- adrCount += readdirSync(decisionsDir).filter(f => f.endsWith('.md')).length;
117
+ adrCount += readdirSync(decisionsDir).filter((f) => f.endsWith('.md')).length;
104
118
  }
105
119
  }
106
120