hypomnema 1.0.1 → 1.1.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.
@@ -11,7 +11,7 @@
11
11
  "name": "hypomnema",
12
12
  "source": "./",
13
13
  "description": "LLM-native personal wiki — session-aware knowledge base for Claude Code",
14
- "version": "1.0.1",
14
+ "version": "1.1.0",
15
15
  "homepage": "https://github.com/sk-lim19f/Hypomnema"
16
16
  }
17
17
  ]
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hypomnema",
3
- "version": "1.0.1",
3
+ "version": "1.1.0",
4
4
  "description": "LLM-native personal wiki system — session-aware knowledge base for Claude Code",
5
5
  "author": {
6
6
  "name": "sk-lim19f",
package/README.ko.md CHANGED
@@ -10,17 +10,19 @@
10
10
  [![npm downloads](https://img.shields.io/npm/dm/hypomnema?color=blue)](https://www.npmjs.com/package/hypomnema)
11
11
  [![Node.js](https://img.shields.io/node/v/hypomnema?color=43853d&logo=node.js&logoColor=white)](https://nodejs.org)
12
12
  [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](https://opensource.org/licenses/MIT)
13
- [![Tests](https://img.shields.io/badge/tests-51%2F51-brightgreen)](tests/runner.mjs)
13
+ [![CI](https://github.com/sk-lim19f/Hypomnema/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/sk-lim19f/Hypomnema/actions/workflows/ci.yml)
14
14
  [![GitHub stars](https://img.shields.io/github/stars/sk-lim19f/Hypomnema?style=flat&color=yellow)](https://github.com/sk-lim19f/Hypomnema/stargazers)
15
15
 
16
16
  **Claude Code를 위한 LLM 네이티브 개인 위키. 복리로 성장하는 지식.**
17
17
 
18
- _노트하지 마세요. Claude가 합성하게 하세요._
18
+ _Claude가 노트하게 만드세요 그리고 그게 실제로 일어나는지 측정하세요._
19
19
 
20
20
  [빠른 시작](#빠른-시작) • [다른 시스템과의 비교](#다른-시스템과의-비교) • [설계 결정](#설계-결정) • [기능](#기능) • [아키텍처](docs/ARCHITECTURE.md) • [기여](docs/CONTRIBUTING.md)
21
21
 
22
22
  > Andrej Karpathy의 "LLM 네이티브 위키" 스케치에서 영감을 받아, 10개월간의 개인 운영 경험으로 다듬어진 도구입니다. 캡처 → 합성 → 검색 → 세션 재개로 이어지는 전체 라이프사이클을 Claude Code 명령어와 라이프사이클 훅으로 제공합니다.
23
23
 
24
+ > **현재 상태 vs. v2 비전.** v1.0.1(현재)은 트리거 모델을 정직하게 밝힙니다: 대부분의 위키 동작 — 인제스트, 쿼리, 세션 클로즈 — 은 **명시적 `/hypo:*` 커맨드**로 시작되며, 일부 훅이 자동 staging·세션 상태 주입·룩업 신호를 처리합니다. v2 thesis는 *완전 자율* — Claude가 요청 없이 위키를 읽고·쓰고·합성하는 상태입니다. **v1.1.0**은 그 램프의 첫 단계로, 자율을 주장하는 대신 **observability 점수**를 출력해 세션당 위키가 실제로 얼마나 쓰였는지(ingest 수, query 수, session-close 비율, citation 비율)를 측정·공개합니다. v2 구현 이전에 자율 vs. 수동 비율을 사용자가 직접 확인할 수 있게 하기 위함입니다.
25
+
24
26
  ---
25
27
 
26
28
  ## 빠른 시작
@@ -105,6 +107,7 @@ Hypomnema ───► 합성 · 마크다운 · git · 훅 · 로컬
105
107
  | **포맷** | 평문 마크다운 + frontmatter | 마크다운 | 독자 포맷 | 벡터 스토어 | 독자 포맷 | HTML |
106
108
  | **백엔드** | 로컬 파일 + git | 로컬 파일 | SaaS | 서비스 / DB | SaaS | 서비스 |
107
109
  | **행동 튜닝** | `/hypo:feedback` → 영구 규칙 | 없음 | 없음 | 없음 | 일부 | 없음 |
110
+ | **자율 동작(auto-behavior)** | 현재는 명시적 `/hypo:*` 트리거 기반; **v1.1에서 observability 점수 도입**, v2 목표 = 완전 자율 | 없음 | 없음 | 없음 | 블랙박스 | 없음 |
108
111
  | **셋업 비용** | 명령 1개 | 설치 1번 | 가입 | 파이프라인 구축 | 가입 | 레포 연결 |
109
112
  | **락인** | 0 (마크다운 + git) | 낮음 | 높음 | 중간 | 높음 | 중간 |
110
113
 
@@ -295,6 +298,8 @@ Claude가 잘못하거나 정확히 맞았을 때마다 `/hypo:feedback`을 실
295
298
 
296
299
  `.hypoignore`는 훅이 무시할 경로를 정의합니다 (기본: `*.pdf`, `*.zip`, `*.pem`, `*.env` 등). 직접 편집하면 됩니다 — privacy mode 플래그는 없습니다. 단일 파일, 단일 진실 소스.
297
300
 
301
+ > **git sync 범위.** Hypomnema는 `~/hypomnema/` 위키 자체만 git sync합니다. Claude Code 설정(`~/.claude/`)은 의도적으로 Hypomnema가 **관리하지 않습니다** — agent·skill·settings의 기기 간 동기화는 [chezmoi](https://www.chezmoi.io/) 같은 별도 dotfiles 매니저 사용을 권장합니다.
302
+
298
303
  ### `/hypo:*` 커맨드는 어디서 오는가?
299
304
 
300
305
  | 설치 경로 | 슬래시 커맨드 위치 |
@@ -315,7 +320,7 @@ Claude가 잘못하거나 정확히 맞았을 때마다 `/hypo:feedback`을 실
315
320
 
316
321
  ## 상태
317
322
 
318
- - **테스트:** 51 / 51 통과 `tests/runner.mjs`
323
+ - **테스트:** `npm test` 참조lane이 ship될 때마다 카운트가 변하므로 러너가 단일 진실 공급원
319
324
  - **CI:** 7개 독립 job (test matrix, lint, init/upgrade snapshots, replay, hypo-absent, uninstall-smoke)
320
325
  - **릴리스:** `v*` 태그 push 시 `npm publish --provenance` 자동 실행
321
326
 
package/README.md CHANGED
@@ -10,17 +10,19 @@ English | [한국어](README.ko.md)
10
10
  [![npm downloads](https://img.shields.io/npm/dm/hypomnema?color=blue)](https://www.npmjs.com/package/hypomnema)
11
11
  [![Node.js](https://img.shields.io/node/v/hypomnema?color=43853d&logo=node.js&logoColor=white)](https://nodejs.org)
12
12
  [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](https://opensource.org/licenses/MIT)
13
- [![Tests](https://img.shields.io/badge/tests-51%2F51-brightgreen)](tests/runner.mjs)
13
+ [![CI](https://github.com/sk-lim19f/Hypomnema/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/sk-lim19f/Hypomnema/actions/workflows/ci.yml)
14
14
  [![GitHub stars](https://img.shields.io/github/stars/sk-lim19f/Hypomnema?style=flat&color=yellow)](https://github.com/sk-lim19f/Hypomnema/stargazers)
15
15
 
16
16
  **LLM-native personal wiki for Claude Code. Knowledge that compounds.**
17
17
 
18
- _Don't take notes. Let Claude synthesize them._
18
+ _Make Claude take notes and measure whether it actually does._
19
19
 
20
20
  [Quick Start](#quick-start) • [How It Compares](#how-it-compares) • [Design Decisions](#design-decisions) • [Features](#features) • [Architecture](docs/ARCHITECTURE.md) • [Contributing](docs/CONTRIBUTING.md)
21
21
 
22
22
  > Inspired by Andrej Karpathy's "LLM-native wiki" sketch and shaped by ten months of personal use, Hypomnema ships the full lifecycle — capture, synthesis, retrieval, session-resume — as Claude Code commands and lifecycle hooks.
23
23
 
24
+ > **Current state vs. v2 vision.** v1.0.1 (today) is honest about its trigger model: most wiki behavior — ingest, query, session-close — fires on **explicit `/hypo:*` commands**, with a handful of hooks providing auto-staging, session-state injection, and lookup signals. The v2 thesis is *fully autonomous* — Claude reading, writing, and synthesizing the wiki without being asked. **v1.1.0** is the first step on that ramp: instead of claiming auto-behavior, it ships an **observability score** that measures how often the wiki is actually used per session (ingest count, query count, session-close rate, citation rate) so you can see the auto-vs-manual ratio with your own eyes before the v2 work lands.
25
+
24
26
  ---
25
27
 
26
28
  ## Quick Start
@@ -105,6 +107,7 @@ Hypomnema ───► synthesis · markdown · git · hooks · local
105
107
  | **Format** | Plain markdown + frontmatter | Markdown | Proprietary | Vector store | Proprietary | HTML |
106
108
  | **Backend** | Local file + git | Local file | SaaS | Service / DB | SaaS | Service |
107
109
  | **Behavior tuning** | `/hypo:feedback` → permanent rules | None | None | None | Sometimes | None |
110
+ | **Auto-behavior** | Explicit `/hypo:*` triggers today; **v1.1 ships an observability score**, v2 target = fully autonomous | None | None | None | Black box | None |
108
111
  | **Setup cost** | One command | One install | Sign-up | Pipeline build | Sign-up | Repo connect |
109
112
  | **Lock-in** | Zero (markdown + git) | Low | High | Medium | High | Medium |
110
113
 
@@ -295,6 +298,8 @@ Place a `hypo-config.md` at the wiki root to make it portable across machines wi
295
298
 
296
299
  `.hypoignore` controls which paths the hooks ignore (default: `*.pdf`, `*.zip`, `*.pem`, `*.env`, …). Edit it directly — there is no privacy mode flag; one file, one source of truth.
297
300
 
301
+ > **Scope of git sync.** Hypomnema git-syncs only the `~/hypomnema/` wiki itself. Your Claude Code configuration (`~/.claude/`) is intentionally **not** managed by Hypomnema — for cross-machine sync of agents, skills, and settings, the recommended pattern is a separate dotfiles manager such as [chezmoi](https://www.chezmoi.io/).
302
+
298
303
  ### Where do `/hypo:*` commands live?
299
304
 
300
305
  | Install path | Slash commands served from |
@@ -315,7 +320,7 @@ No external services. No API keys. No vector databases.
315
320
 
316
321
  ## Status
317
322
 
318
- - **Tests:** 51 / 51 passing see `tests/runner.mjs`
323
+ - **Tests:** see `npm test`exact totals shift as lanes ship, so the runner is the source of truth
319
324
  - **CI:** 7 independent jobs (test matrix, lint, init/upgrade snapshots, replay, hypo-absent, uninstall-smoke)
320
325
  - **Release:** `npm publish --provenance` on `v*` tag push
321
326
 
@@ -0,0 +1,46 @@
1
+ ---
2
+ description: Run the observability audit on recent sessions and (optionally) write the weekly report
3
+ ---
4
+
5
+ You are running `/hypo:audit`. Inspect recent Claude Code sessions to see how much of the wiki's value motion is actually happening (search, ingest, citation, feedback), then either show the result inline or write a weekly observability page.
6
+
7
+ ## What this shows
8
+
9
+ - Per-session metrics — search count, ingest count, URL mentions, feedback count
10
+ - Classification — `normal` / `search-0` / `search-many` / `ingest-missed` / `staleness-skip`
11
+ - Optional weekly aggregation with an autonomy score (heuristic v0)
12
+
13
+ Definition: [[pages/observability/_index]].
14
+
15
+ ---
16
+
17
+ ## Step 1 — Locate package root
18
+
19
+ Locate the Hypomnema package root (the directory containing this file's parent `commands/`).
20
+
21
+ If the user specified a Hypomnema directory, pass it as `--hypo-dir="<path>"`. Otherwise omit the flag.
22
+
23
+ ---
24
+
25
+ ## Step 2 — Decide the mode
26
+
27
+ - **Per-session view (default)** — show recent sessions with metrics + classification:
28
+ ```bash
29
+ node <package-root>/scripts/session-audit.mjs [--hypo-dir="<path>"] [--limit=20]
30
+ ```
31
+ - **Weekly report (when the user asks for "weekly", "score", or names a week)** — write the report to `pages/observability/<YYYY-WW>.md`:
32
+ ```bash
33
+ node <package-root>/scripts/weekly-report.mjs [--hypo-dir="<path>"] [--week=YYYY-WW] --write
34
+ ```
35
+ - **JSON for tooling** — append `--json` to either script.
36
+
37
+ ---
38
+
39
+ ## Step 3 — Report results
40
+
41
+ Show the output verbatim. Then add a brief interpretation:
42
+
43
+ - If `staleness-skip` dominates: "Most sessions audited are older than 30 days — score reflects backlog, not current behavior."
44
+ - If `search-0` count is high: "Sessions ran without consulting the wiki — consider whether `/hypo:query` was the right reflex."
45
+ - If `ingest-missed` is non-zero: "Sessions discussed URLs but no `/hypo:ingest` ran — those captures got lost."
46
+ - Otherwise: "Audit window looks healthy; weekly report committed to the wiki for trend tracking."
@@ -53,7 +53,7 @@ hypomnema/
53
53
  │ ├── Home.md, Overview.md, hypo-automation.md, hypo-help.md
54
54
  │ ├── pages/_index.md
55
55
  │ └── projects/_template/
56
- ├── tests/runner.mjs ← no-dependency test runner (51 tests)
56
+ ├── tests/runner.mjs ← no-dependency test runner
57
57
  ├── docs/ ← ARCHITECTURE.md, CONTRIBUTING.md
58
58
  ├── .claude-plugin/plugin.json← plugin manifest
59
59
  └── package.json ← npm metadata, no runtime deps
@@ -100,7 +100,7 @@ Hooks run automatically at Claude Code lifecycle events. They are deployed to `~
100
100
  | `UserPromptSubmit` | `hypo-first-prompt.mjs` → `hypo-lookup.mjs` → `hypo-compact-guard.mjs` |
101
101
  | `PreCompact` | `hypo-personal-check.mjs` |
102
102
  | `PostToolUse` (Write/Edit) | `hypo-auto-stage.mjs` |
103
- | `Stop` | `hypo-hot-rebuild.mjs` → `hypo-auto-commit.mjs` |
103
+ | `Stop` | `hypo-hot-rebuild.mjs` → `hypo-session-record.mjs` → `hypo-auto-commit.mjs` |
104
104
  | `CwdChanged` | `hypo-cwd-change.mjs` |
105
105
  | `FileChanged` | `hypo-file-watch.mjs` |
106
106
 
@@ -113,9 +113,10 @@ Hooks run automatically at Claude Code lifecycle events. They are deployed to `~
113
113
  | `hypo-lookup` | BM25 search over the wiki on every prompt. **HIT** → inject top-3 page snippets (≤2000 chars each, with verify-by-date warnings). **MISS** → emit closest-slug signal that prompts Claude to research + `/hypo:ingest` |
114
114
  | `hypo-compact-guard` | Detect `/compact` invocations → enforce session-close checklist before allowing compact |
115
115
  | `hypo-personal-check` | PreCompact validation: lint blockers, uncommitted changes, missing session-log entries → block compact |
116
- | `hypo-auto-stage` | After Write/Edit on a wiki path, run `git add` (filtered by `.hypoignore`) |
117
- | `hypo-hot-rebuild` | At session stop, regenerate root `hot.md` from recent activity |
118
- | `hypo-auto-commit` | At session stop, commit staged changes + `git pull --rebase` + `git push` (silent fail on missing remote) |
116
+ | `hypo-auto-stage` | After Write/Edit on a wiki path, run `git add` (skips paths matching `.hypoignore`) |
117
+ | `hypo-hot-rebuild` | At session stop, regenerate root `hot.md` from recent activity; emit growth metrics + cache for next SessionStart |
118
+ | `hypo-session-record` | At session stop, append `{session_id, transcript_path, recorded_at, cwd}` to `.cache/sessions/index.jsonl` (primary source for the observability audit) |
119
+ | `hypo-auto-commit` | At session stop, filter changed paths through `.hypoignore`, commit non-ignored changes, `git pull --no-rebase` + `git push` (silent fail on missing remote) |
119
120
  | `hypo-cwd-change` | When working directory changes, re-resolve the active project and inject its `hot.md` |
120
121
  | `hypo-file-watch` | Notify on external wiki edits so the in-session view stays consistent |
121
122
 
@@ -289,8 +290,9 @@ SessionStart
289
290
  │ └─► hypo-cwd-change.mjs (re-inject project hot.md)
290
291
 
291
292
  └─► Stop
292
- ├─► hypo-hot-rebuild.mjs (regenerate root hot.md)
293
- └─► hypo-auto-commit.mjs (commit + pull --rebase + push)
293
+ ├─► hypo-hot-rebuild.mjs (regenerate root hot.md + growth cache)
294
+ ├─► hypo-session-record.mjs (append .cache/sessions/index.jsonl)
295
+ └─► hypo-auto-commit.mjs (.hypoignore-filtered stage + commit + pull + push)
294
296
  ```
295
297
 
296
298
  ---
@@ -307,6 +309,78 @@ Promotion is intentionally manual to keep `<learned_behaviors>` curated. The pip
307
309
 
308
310
  ---
309
311
 
312
+ ## Observability (v1.1)
313
+
314
+ v1.1 ships an **observability wedge** — the wiki measures whether it's actually being used per session, rather than claiming autonomy it can't yet deliver.
315
+
316
+ ### Data flow
317
+
318
+ ```
319
+ Stop hook (hypo-session-record.mjs)
320
+ │ appends one JSONL entry per session
321
+
322
+ <hypo-root>/.cache/sessions/index.jsonl ← primary source
323
+
324
+
325
+ scripts/session-audit.mjs ← per-session metrics + classification
326
+
327
+
328
+ scripts/weekly-report.mjs ← aggregated weekly autonomy score
329
+
330
+
331
+ pages/observability/<YYYY-WW>.md ← committed report (heuristic v0)
332
+ ```
333
+
334
+ ### Transcript dual-source (ADR 0019)
335
+
336
+ `session-audit.mjs` reads transcripts from two locations, in priority order:
337
+
338
+ 1. **Primary:** `<hypo-root>/.cache/sessions/index.jsonl` — written by the Stop hook `hypo-session-record.mjs`. Each line: `{ session_id, transcript_path, recorded_at, cwd }`.
339
+ 2. **Fallback:** `~/.claude/projects/<encoded>/*.jsonl` — scanned when the index is missing or empty (legacy / freshly-installed wikis).
340
+
341
+ ### Classification
342
+
343
+ | Class | Rule |
344
+ |---|---|
345
+ | `staleness-skip` | `recorded_at` older than `--max-age-days` (default 30) |
346
+ | `ingest-missed` | `urls >= 2` and `ingest_count == 0` |
347
+ | `search-many` | `search_count >= 5` (heavy retrieval; suggests missing synthesis) |
348
+ | `search-0` | `search_count == 0` |
349
+ | `normal` | otherwise |
350
+
351
+ Counted tool names: `Grep`, `WebSearch`, `WebFetch`. Counted slash commands: `/hypo:query`, `/hypo:ingest`, `/hypo:feedback`. A single transcript record contributes to exactly one of (tool-use search OR text-based command search) — `computeMetrics` short-circuits after a tool-use match to prevent double counting.
352
+
353
+ ### Autonomy score (heuristic v0)
354
+
355
+ `weekly-report.mjs` aggregates the week's results into a 0–100 score. The score is **clamped to `[0, 100]`** and skips `staleness-skip` sessions. Formula sketch (see `pages/observability/_index.md` for the formal definition):
356
+
357
+ ```
358
+ numerator = Σ min(search,3) + ingest*3 + feedback*2
359
+ denominator = Σ 1 + (urls > 0 ? min(urls,5)*2 : 0)
360
+ score = clamp(round(num/den * 100), 0, 100)
361
+ ```
362
+
363
+ The score is a **proxy, not ground truth**. The four-week baseline plan (capture v0 numbers, then revisit with LLM-judge classification before v2) is recorded in the same `_index.md`.
364
+
365
+ ### Privacy
366
+
367
+ The observability pipeline reads but never republishes raw transcripts. Weekly reports only emit `session_id` plus aggregate counts — no transcript content, no URLs, no tool inputs. Transcripts themselves live under `~/.claude/projects/` or `.cache/sessions/` which `.hypoignore` already excludes from any sync.
368
+
369
+ ### Growth metrics (Lane B)
370
+
371
+ A separate, lightweight counter — distinct from the audit pipeline — runs at every Stop / SessionStart pair:
372
+
373
+ - **Stop** (`hypo-hot-rebuild.mjs`) computes `{ addedPages, updatedPages, newWikilinks }` by reading `git status --porcelain` plus a conditional `git diff HEAD --unified=0`, writes the result to `<hypo-root>/.cache/last-session-growth.json`, and echoes one line to stderr.
374
+ - **SessionStart** (`hypo-session-start.mjs`) reads the cache and surfaces the same line in both stderr (cyan) and the LLM's `additionalContext` so user and model see the same "직전 세션" prefix.
375
+
376
+ If `git status` shows no `.md` changes, the diff step is skipped — Stop hook fast path.
377
+
378
+ ### Citation convention
379
+
380
+ The six writer-side skills (`crystallize`, `query`, `ingest`, `verify`, `graph`, `lint`) carry an identical footer instructing Claude to cite wiki pages inline as `[[page-slug]]`. The audit script counts these citations as a "wiki was actually consulted" signal in future iterations.
381
+
382
+ ---
383
+
310
384
  ## Privacy & exclusions
311
385
 
312
386
  `.hypoignore` is the **only** privacy mechanism. The v1.0 `personal / shared / public` mode matrix was deleted in v1.1 — every privacy decision turned out to be a per-path question, and a single ignore file handles per-path natively.
@@ -329,9 +403,9 @@ Default patterns: `*.pdf`, `*.zip`, `*.pem`, `*.env`, `*.key`, `*.crt`, `*creden
329
403
  | `upgrade.mjs` regression | ~10 (includes migration fixture) |
330
404
  | `lint.mjs` (fix / json / session-state) | ~10 |
331
405
  | Misc (`expandHome`, `resolveHypoRoot`, …) | ~remainder |
332
- | **Total** | **51 / 51 PASS** |
406
+ | **Total** | Run `npm test` for the live count |
333
407
 
334
- Run with `npm test`. The runner uses only Node.js built-ins; tests create scoped temp dirs and clean up after themselves.
408
+ Run with `npm test`. The runner uses only Node.js built-ins; tests create scoped temp dirs and clean up after themselves. The count above is a layout sketch — exact totals shift as lanes ship, so `npm test` is the source of truth.
335
409
 
336
410
  ---
337
411
 
@@ -22,7 +22,7 @@ No build step. The package is plain ESM with **zero npm runtime dependencies**.
22
22
  git clone https://github.com/sk-lim19f/Hypomnema.git
23
23
  cd Hypomnema
24
24
  npm install # installs dev tooling only — runtime deps are zero
25
- npm test # 51/51 should pass
25
+ npm test # all tests should pass — exact count shifts as lanes ship
26
26
  npm run lint # frontmatter + wikilink validation
27
27
  ```
28
28
 
package/hooks/hooks.json CHANGED
@@ -72,6 +72,15 @@
72
72
  }
73
73
  ]
74
74
  },
75
+ {
76
+ "hooks": [
77
+ {
78
+ "type": "command",
79
+ "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/hypo-session-record.mjs",
80
+ "timeout": 10
81
+ }
82
+ ]
83
+ },
75
84
  {
76
85
  "hooks": [
77
86
  {
@@ -6,7 +6,8 @@
6
6
  */
7
7
 
8
8
  import { spawnSync } from 'child_process';
9
- import { HYPO_DIR } from './hypo-shared.mjs';
9
+ import { HYPO_DIR, loadHypoIgnore, isIgnored } from './hypo-shared.mjs';
10
+ import { join } from 'path';
10
11
 
11
12
  function git(...args) {
12
13
  return spawnSync('git', ['-C', HYPO_DIR, ...args], { encoding: 'utf-8', timeout: 30000 });
@@ -17,7 +18,19 @@ function hasRemote() {
17
18
  return (r.stdout || '').trim().length > 0;
18
19
  }
19
20
 
20
- git('add', '-A');
21
+ // `.hypoignore` is the project privacy boundary. `git add -A` ignores it, so
22
+ // enumerate changed paths, drop ignored ones, then stage explicitly.
23
+ const ignorePatterns = loadHypoIgnore(HYPO_DIR);
24
+ const porcelain = git('status', '--porcelain', '-uall');
25
+ const paths = [];
26
+ for (const line of (porcelain.stdout || '').split('\n')) {
27
+ if (!line) continue;
28
+ const file = line.slice(3).replace(/^"|"$/g, '').split(' -> ').pop().trim();
29
+ if (!file) continue;
30
+ if (ignorePatterns.length > 0 && isIgnored(join(HYPO_DIR, file), HYPO_DIR, ignorePatterns)) continue;
31
+ paths.push(file);
32
+ }
33
+ if (paths.length > 0) git('add', '--', ...paths);
21
34
  const staged = git('diff', '--cached', '--name-only').stdout?.trim() || '';
22
35
  if (staged) {
23
36
  const today = new Date().toISOString().slice(0, 10);
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  import { spawnSync } from 'child_process';
9
- import { HYPO_DIR } from './hypo-shared.mjs';
9
+ import { HYPO_DIR, loadHypoIgnore, isIgnored } from './hypo-shared.mjs';
10
10
 
11
11
  let input = {};
12
12
  try {
@@ -24,7 +24,10 @@ try {
24
24
  const filePath = input.tool_input?.file_path ?? '';
25
25
 
26
26
  if (filePath.startsWith(HYPO_DIR + '/') || filePath === HYPO_DIR) {
27
- spawnSync('git', ['-C', HYPO_DIR, 'add', filePath], { stdio: 'ignore' });
27
+ const patterns = loadHypoIgnore(HYPO_DIR);
28
+ if (patterns.length === 0 || !isIgnored(filePath, HYPO_DIR, patterns)) {
29
+ spawnSync('git', ['-C', HYPO_DIR, 'add', filePath], { stdio: 'ignore' });
30
+ }
28
31
  }
29
32
 
30
33
  console.log(JSON.stringify({ continue: true, suppressOutput: true }));
@@ -10,11 +10,12 @@
10
10
  * This script manages: frontmatter, H2 structure, date fields.
11
11
  */
12
12
 
13
- import { readFileSync, writeFileSync, existsSync } from 'fs';
13
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
14
14
  import { join } from 'path';
15
- import { HYPO_DIR } from './hypo-shared.mjs';
15
+ import { HYPO_DIR, computeSessionGrowth, formatGrowthMetrics } from './hypo-shared.mjs';
16
16
 
17
- const HOT_PATH = join(HYPO_DIR, 'hot.md');
17
+ const HOT_PATH = join(HYPO_DIR, 'hot.md');
18
+ const GROWTH_CACHE = join(HYPO_DIR, '.cache', 'last-session-growth.json');
18
19
 
19
20
  function parseFrontmatter(content) {
20
21
  const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
@@ -88,8 +89,18 @@ ${tableRows}
88
89
  if (canonical !== current) writeFileSync(HOT_PATH, canonical);
89
90
  }
90
91
 
91
- try {
92
- rebuild();
93
- } catch {}
92
+ function emitGrowth() {
93
+ if (!existsSync(HYPO_DIR)) return;
94
+ const stats = computeSessionGrowth(HYPO_DIR);
95
+ const line = formatGrowthMetrics('stop', stats);
96
+ if (line) process.stderr.write(`${line}\n`);
97
+ try {
98
+ mkdirSync(join(HYPO_DIR, '.cache'), { recursive: true });
99
+ writeFileSync(GROWTH_CACHE, JSON.stringify({ ...stats, ts: Date.now() }));
100
+ } catch {}
101
+ }
102
+
103
+ try { rebuild(); } catch {}
104
+ try { emitGrowth(); } catch {}
94
105
 
95
106
  try { console.log(JSON.stringify({ continue: true, suppressOutput: true })); } catch {}
@@ -0,0 +1,54 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * hypo-session-record.mjs — Stop hook
4
+ *
5
+ * Appends an entry to ~/hypomnema/.cache/sessions/index.jsonl for each
6
+ * completed session. The index.jsonl is the **primary** source for
7
+ * scripts/session-audit.mjs (which falls back to ~/.claude/projects/<encoded>/
8
+ * if the index is empty or missing — see ADR 0019).
9
+ *
10
+ * Silent: never blocks, never emits user-visible output.
11
+ */
12
+
13
+ import { existsSync, mkdirSync, appendFileSync } from 'fs';
14
+ import { dirname, join } from 'path';
15
+ import { HYPO_DIR } from './hypo-shared.mjs';
16
+
17
+ const INDEX_PATH = join(HYPO_DIR, '.cache', 'sessions', 'index.jsonl');
18
+
19
+ function emitContinue() {
20
+ console.log(JSON.stringify({ continue: true, suppressOutput: true }));
21
+ }
22
+
23
+ let raw = '';
24
+ process.stdin.setEncoding('utf-8');
25
+ process.stdin.on('data', chunk => raw += chunk);
26
+ process.stdin.on('end', () => {
27
+ try {
28
+ let payload = {};
29
+ try { payload = JSON.parse(raw) || {}; } catch {}
30
+
31
+ const transcriptPath = payload.transcript_path || payload.transcriptPath || null;
32
+ const sessionId = payload.session_id || payload.sessionId || null;
33
+ if (!transcriptPath || !sessionId) {
34
+ // Older Claude Code (no transcript_path) — fallback path in
35
+ // scripts/session-audit.mjs handles this case.
36
+ emitContinue();
37
+ return;
38
+ }
39
+
40
+ if (!existsSync(HYPO_DIR)) { emitContinue(); return; }
41
+ mkdirSync(dirname(INDEX_PATH), { recursive: true });
42
+
43
+ const entry = {
44
+ session_id: sessionId,
45
+ transcript_path: transcriptPath,
46
+ recorded_at: new Date().toISOString(),
47
+ cwd: payload.cwd || process.cwd(),
48
+ };
49
+ appendFileSync(INDEX_PATH, JSON.stringify(entry) + '\n');
50
+ } catch {
51
+ // Audit is best-effort observability — never let it block session close.
52
+ }
53
+ emitContinue();
54
+ });
@@ -11,9 +11,20 @@ import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from '
11
11
  import { homedir, tmpdir } from 'os';
12
12
  import { join } from 'path';
13
13
  import { spawnSync } from 'child_process';
14
- import { HYPO_DIR, buildOutput, SESSION_STATE_NEXT_HEADINGS } from './hypo-shared.mjs';
14
+ import { HYPO_DIR, buildOutput, SESSION_STATE_NEXT_HEADINGS, formatGrowthMetrics } from './hypo-shared.mjs';
15
15
 
16
- const PROJECTS_DIR = join(HYPO_DIR, 'projects');
16
+ const PROJECTS_DIR = join(HYPO_DIR, 'projects');
17
+ const GROWTH_CACHE = join(HYPO_DIR, '.cache', 'last-session-growth.json');
18
+
19
+ function readLastGrowthLine() {
20
+ if (!existsSync(GROWTH_CACHE)) return '';
21
+ try {
22
+ const stats = JSON.parse(readFileSync(GROWTH_CACHE, 'utf-8'));
23
+ return formatGrowthMetrics('start', stats);
24
+ } catch {
25
+ return '';
26
+ }
27
+ }
17
28
 
18
29
  function gitPull(dir) {
19
30
  if (!existsSync(join(dir, '.git'))) return;
@@ -97,6 +108,13 @@ process.stdin.on('end', () => {
97
108
  try { data = JSON.parse(raw); } catch {}
98
109
 
99
110
  gitPull(HYPO_DIR);
111
+ const growthLine = readLastGrowthLine();
112
+ // Intentional dual emit: stderr (cyan) is the human-visible nudge in the
113
+ // terminal; growthPrefix injects the same plain-text line into the LLM's
114
+ // additionalContext so model and user start the session looking at the
115
+ // same state. ANSI escapes are kept out of additionalContext on purpose.
116
+ const growthPrefix = growthLine ? `${growthLine}\n\n` : '';
117
+ if (growthLine) process.stderr.write(`\n\x1b[36m${growthLine}\x1b[0m\n`);
100
118
  const cwd = data.cwd || data.directory || process.cwd();
101
119
  const sessionId = data.session_id || 'default';
102
120
  const MARKER_FILE = join(tmpdir(), `hypo-session-marker-${sessionId}.json`);
@@ -113,26 +131,30 @@ process.stdin.on('end', () => {
113
131
  if (hotContent) parts.push(`[HOT]\n${hotContent}`);
114
132
  if (stateContent) parts.push(`[SESSION STATE — 다음 작업]\n${stateContent}`);
115
133
  console.log(JSON.stringify(
116
- buildOutput(`[WIKI HOT CACHE: project=${hit.proj}]\n\n${parts.join('\n\n')}`, { continue: true, suppressOutput: true })
134
+ buildOutput(`${growthPrefix}[WIKI HOT CACHE: project=${hit.proj}]\n\n${parts.join('\n\n')}`, { continue: true, suppressOutput: true })
117
135
  ));
118
136
  } else {
119
137
  process.stderr.write(`\n\x1b[36m[Hypomnema]\x1b[0m project: \x1b[1m${hit.proj}\x1b[0m (no snapshot yet)\n\n`);
120
138
  writeFileSync(MARKER_FILE, JSON.stringify({ proj: hit.proj, hotPath: null, ts: Date.now() }));
121
139
  console.log(JSON.stringify(
122
- buildOutput(`[WIKI HOT CACHE: project=${hit.proj}, no snapshot yet]`, { continue: true, suppressOutput: true })
140
+ buildOutput(`${growthPrefix}[WIKI HOT CACHE: project=${hit.proj}, no snapshot yet]`, { continue: true, suppressOutput: true })
123
141
  ));
124
142
  }
125
143
  return;
126
144
  }
127
145
 
128
146
  if (!existsSync(GLOBAL_HOT)) {
129
- console.log(JSON.stringify({ continue: true, suppressOutput: true }));
147
+ if (growthLine) {
148
+ console.log(JSON.stringify(buildOutput(growthLine, { continue: true, suppressOutput: true })));
149
+ } else {
150
+ console.log(JSON.stringify({ continue: true, suppressOutput: true }));
151
+ }
130
152
  return;
131
153
  }
132
154
 
133
155
  const globalContent = readFileSync(GLOBAL_HOT, 'utf-8').slice(0, HOT_CHARS);
134
156
  console.log(JSON.stringify(
135
- buildOutput(`[WIKI HOT CACHE: global — no project matched cwd=${cwd}]\n\n${globalContent}`, { continue: true, suppressOutput: true })
157
+ buildOutput(`${growthPrefix}[WIKI HOT CACHE: global — no project matched cwd=${cwd}]\n\n${globalContent}`, { continue: true, suppressOutput: true })
136
158
  ));
137
159
 
138
160
  } catch {
@@ -160,6 +160,102 @@ export function buildOutput(context, extra = {}) {
160
160
  return { ...extra, additionalContext: context };
161
161
  }
162
162
 
163
+ // ── growth metrics (F2 + E4) ───────────────────────────────────────────────
164
+ // Single formatter used by Stop (hot-rebuild) and SessionStart hooks so the
165
+ // "[hypo] +N pages, ~M updated, K wikilinks" line stays consistent at both
166
+ // ends of a session. See ADR-0018 / Lane B.
167
+
168
+ /**
169
+ * Format a growth-metrics one-liner. Returns '' when all counts are 0 so
170
+ * callers can no-op silently.
171
+ *
172
+ * @param {'stop'|'start'} mode
173
+ * @param {{addedPages?:number, updatedPages?:number, newWikilinks?:number}} stats
174
+ * @returns {string}
175
+ */
176
+ export function formatGrowthMetrics(mode, stats) {
177
+ const a = Number(stats?.addedPages) || 0;
178
+ const u = Number(stats?.updatedPages) || 0;
179
+ const w = Number(stats?.newWikilinks) || 0;
180
+ if (a === 0 && u === 0 && w === 0) return '';
181
+ const body = `+${a} pages, ~${u} updated, ${w} wikilinks`;
182
+ if (mode === 'stop') return `[hypo] ${body}`;
183
+ if (mode === 'start') return `[hypo] 직전 세션: ${body}. 이어서 볼까요?`;
184
+ return '';
185
+ }
186
+
187
+ /**
188
+ * Compute session growth by inspecting the wiki repo's working tree against
189
+ * HEAD. Counts every modified/added/untracked markdown file under `pages/`
190
+ * or `projects/` and totals net-new `[[wikilink]]` occurrences in the diff.
191
+ *
192
+ * @param {string} hypoDir
193
+ * @returns {{addedPages:number, updatedPages:number, newWikilinks:number}}
194
+ */
195
+ export function computeSessionGrowth(hypoDir) {
196
+ const empty = { addedPages: 0, updatedPages: 0, newWikilinks: 0 };
197
+ if (!existsSync(join(hypoDir, '.git'))) return empty;
198
+ try {
199
+ // Single `git status --porcelain` enumerates tracked + untracked. On a
200
+ // clean tree (no .md changes at all) we return early and skip the much
201
+ // more expensive `git diff HEAD --unified=0` — Stop hook P95 win.
202
+ // `-uall` expands untracked directories so a brand-new `pages/new.md`
203
+ // isn't hidden behind a single `?? pages/` line.
204
+ const porcelain = spawnSync('git', ['-C', hypoDir, 'status', '--porcelain', '-uall'], { encoding: 'utf-8', timeout: 5000 });
205
+ if (porcelain.status !== 0) return empty;
206
+ let addedPages = 0, updatedPages = 0;
207
+ let hasTrackedMdChange = false;
208
+ const untrackedMd = [];
209
+ // Growth metrics describe wiki page activity, so restrict to the two
210
+ // page-bearing trees. Top-level files like README.md or root hot.md are
211
+ // intentionally excluded — they're scaffolding, not page growth.
212
+ const inPagesScope = (file) =>
213
+ file.endsWith('.md') && (file.startsWith('pages/') || file.startsWith('projects/'));
214
+ for (const line of (porcelain.stdout || '').split('\n')) {
215
+ if (!line) continue;
216
+ const xy = line.slice(0, 2);
217
+ const file = line.slice(3).replace(/^"|"$/g, '').split(' -> ').pop().trim();
218
+ if (!inPagesScope(file)) continue;
219
+ if (xy === '??') { untrackedMd.push(file); addedPages++; continue; }
220
+ hasTrackedMdChange = true;
221
+ if (xy.includes('A')) addedPages++;
222
+ else if (xy.includes('M') || xy.includes('R')) updatedPages++;
223
+ }
224
+ if (!hasTrackedMdChange && untrackedMd.length === 0) return empty;
225
+
226
+ let plus = 0, minus = 0;
227
+ if (hasTrackedMdChange) {
228
+ // pathspec keeps non-Markdown / out-of-scope diffs from polluting the
229
+ // wikilink count. Without it, a `[[…]]` string in a script.js diff was
230
+ // being credited as a new wikilink.
231
+ const diff = spawnSync(
232
+ 'git',
233
+ ['-C', hypoDir, 'diff', 'HEAD', '--unified=0', '--', 'pages/', 'projects/'],
234
+ { encoding: 'utf-8', timeout: 10000 }
235
+ );
236
+ if (diff.status === 0) {
237
+ for (const line of (diff.stdout || '').split('\n')) {
238
+ if (line.startsWith('+++') || line.startsWith('---')) continue;
239
+ const matches = line.match(/\[\[[^\]\n]+\]\]/g);
240
+ if (!matches) continue;
241
+ if (line.startsWith('+')) plus += matches.length;
242
+ else if (line.startsWith('-')) minus += matches.length;
243
+ }
244
+ }
245
+ }
246
+ for (const f of untrackedMd) {
247
+ try {
248
+ const body = readFileSync(join(hypoDir, f), 'utf-8');
249
+ const matches = body.match(/\[\[[^\]\n]+\]\]/g);
250
+ if (matches) plus += matches.length;
251
+ } catch {}
252
+ }
253
+ return { addedPages, updatedPages, newWikilinks: Math.max(0, plus - minus) };
254
+ } catch {
255
+ return empty;
256
+ }
257
+ }
258
+
163
259
  // ── .hypoignore support ────────────────────────────────────────────────────
164
260
  // Inlined here so deployed hooks (~/.claude/hooks/) don't need scripts/lib/.
165
261