hypomnema 1.3.4 → 1.4.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": "hypo",
12
12
  "source": "./",
13
13
  "description": "LLM-native personal wiki — session-aware knowledge base for Claude Code",
14
- "version": "1.3.4",
14
+ "version": "1.4.0",
15
15
  "homepage": "https://github.com/sk-lim19f/Hypomnema"
16
16
  }
17
17
  ]
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hypo",
3
- "version": "1.3.4",
3
+ "version": "1.4.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
@@ -23,7 +23,7 @@ _Claude에게 기록을 맡기세요 — 그리고 그 기록이 실제로 쌓
23
23
 
24
24
  > **아래에서 자주 쓰이는 용어 간단 정리.** *프런트매터(frontmatter)* = 마크다운 파일 맨 위의 YAML 블록. *위키링크(wikilink)* = `[[페이지-슬러그]]` 형태의 교차 참조. *ADR* = "Architecture Decision Record" — 어떤 설계 결정을 *왜* 했는지 짧게 적은 마크다운 페이지. *projection*(투영) = 한 방향 자동 파생(`pages/feedback/*.md` → `MEMORY.md` / `<learned_behaviors>`). *훅(hook)* = Claude Code가 라이프사이클 이벤트에서 자동으로 실행하는 스크립트. *hot.md* / *session-state.md* = "방금 무엇을 했는지"와 "다음에 무엇을 할지"를 담는 프로젝트별 캐시 파일 — 멈춘 프로젝트를 한 번에 이어 받을 수 있게 합니다. 전체 용어 풀이는 [용어 사전](#용어-사전) 참조.
25
25
 
26
- > **현재 자동화 범위와 다음 목표.** v1.3.0(현재) 트리거 모델을 솔직하게 정리합니다. 위키 작업(자료 정리·검색·세션 마무리)은 여전히 사용자가 `/hypo:*` 명령어를 직접 입력해 시작합니다. 다만 **v1.1.0**부터 위키가 한 세션에서 얼마나 활용됐는지를 측정하는 *관측성 지표(observability score)* 가 들어갔고, **v1.2.0**은 그 위에 사용자가 시키지 않아도 자동으로 동작하는 영역 4개를 추가했습니다:
26
+ > **현재 자동화 범위와 다음 목표.** v1.4.0(현재 릴리스) 트리거 모델을 솔직하게 정리합니다. 위키 작업(자료 정리·검색·세션 마무리)은 여전히 사용자가 `/hypo:*` 명령어를 직접 입력해 시작합니다. 다만 **v1.1.0**부터 위키가 한 세션에서 얼마나 활용됐는지를 측정하는 *관측성 지표(observability score)* 가 들어갔고, **v1.2.0**은 그 위에 사용자가 시키지 않아도 자동으로 동작하는 영역 4개를 추가했습니다:
27
27
  > - **`feedback` 페이지를 단일 원천(source of truth)으로** — `pages/feedback/`에 한 번만 적으면, 위키가 `MEMORY.md`와 `~/.claude/CLAUDE.md`의 `<learned_behaviors>` 블록을 자동으로 갱신합니다.
28
28
  > - **확장 파일 동봉 동기화** — 위키 안의 `~/hypomnema/extensions/{agents,commands,hooks,skills}/`에 둔 파일을 자동으로 `~/.claude/`에 반영합니다. `--codex` 옵션을 추가하면 `hooks`·`commands`는 `~/.codex/`에도 반영되지만, `agents`와 `skills`는 Claude 전용이라 의도적으로 건너뜁니다.
29
29
  > - **프로젝트 자동 생성** — 작업 디렉터리를 git 저장소(`package.json`·`Cargo.toml` 등의 프로젝트 표식이 있는 곳)로 옮겼을 때 대응하는 위키 프로젝트가 없으면, 새로 만들지 물어봅니다.
@@ -250,7 +250,7 @@ v1.0에서는 `personal / shared / public` 3-mode를 만들었습니다. 현실
250
250
 
251
251
  이와 별도로 `SessionStart` 훅은 npm 레지스트리와 Claude Code 플러그인 마켓플레이스를 백그라운드에서 확인합니다(세션 시작을 막지 않습니다). 새 버전이 게시되어 있으면 다음 세션 시작 시 "Update available!" 안내가 한 줄 표시됩니다. `HYPO_NO_UPDATE_CHECK=1`, `NO_UPDATE_NOTIFIER=1`을 지정하거나 `CI=true` 환경에서 실행하면 점검을 건너뜁니다.
252
252
 
253
- 위 네 가지 레인 외의 v1.3 세부 수정(세션 마무리 lint를 건드린 파일로 스코프해 무관한 debt로 `/compact`가 막히지 않게 한 변경, `feedback` scope 검증기가 cwd 유래 project id를 수용하게 한 수정, `--strict`가 에러로 승격하는 안정적 lint 경고 ID `W1`/`W2`/`W4`이며 `--fix`로 자동복구되는 `W3`는 경고로 유지)은 [`CHANGELOG.md`](CHANGELOG.md)를 참고하세요. **v1.3.1**은 수정 전용 패치입니다: 업데이트 notifier 배너가 이제 보이지 않던 stderr 출력 대신 top-level `systemMessage` 채널로 실제 사용자에게 도달하고, `/hypo:upgrade`가 플러그인·dual(수동+플러그인) 설치에서 core 훅을 중복 등록하지 않으며, 세션 마무리가 두 프로젝트가 같은 최신 날짜를 가질 때 완료된 close를 false-block 하지 않습니다. **v1.3.2**는 비-프로젝트(툴링·위키 전용) 세션을 무관한 프로젝트에 엮지 않고 닫는 1급 log-only close 경로와, 페이지 이름을 바꿀 때 해당하는 인바운드 위키링크를 갱신하는 `rename` 헬퍼를 추가합니다(모호하거나 append-only인 참조는 갱신하지 않고 보고합니다). 또한 세션 마무리 게이트(오늘 활동한 모든 프로젝트를 게이트, 세션별 마커와 `/compact`가 하나의 게이트를 공유, 도출 가능한 루트 `log.md` 항목 자동 도출)와 linter(`--json`이 파이프에서 잘리지 않음, 볼트 관습 위키링크를 오탐 대신 정상 해석)를 수정합니다. **v1.3.3**은 세션 마무리 게이트를 실수 발동에 대해 단단히 합니다: 모델이 실제 사용자 종료 신호 없이 세션을 닫을 수 없고, 종료 관련 텍스트를 읽는 것이 턴을 false-block하지 않으며, `--apply-session-close`가 사용자 종료 신호가 있는 close에서 payload 커밋과 마커 기록을 한 번에 끝내고, 일상적 트래커 bookkeeping이 무관한 프로젝트의 `/compact`를 cross-block하지 않습니다. 또한 `rename`을 디렉터리 서브트리 전체 이동으로 확장합니다(`/hypo:rename` 커맨드와 대상 경로가 이미 있을 때의 merge/renumber 충돌 리포트 포함). **v1.3.4**는 위키 위생 수정 전용 패치입니다: `init`이 손수 쓴 알려진 등가본 옆에 중복 stock 페이지를 떨구지 않고(`wiki-automation.md`나 `wiki-help.md`가 보이면 그 페이지를 유지하고 stock `hypo-*`를 건너뛰며 머지 경고를 크게 냅니다), 볼트 루트의 재생성 가능한 리포트 산출물(업그레이드 `MIGRATION-v*.md` 리포트, 예약된 `GRAPH_REPORT.md` 이름 포함)을 정상 커밋되게 두면서 카탈로그 스캔에서 제외합니다.
253
+ 위 네 가지 레인 외의 v1.3 세부 수정(세션 마무리 lint를 건드린 파일로 스코프해 무관한 debt로 `/compact`가 막히지 않게 한 변경, `feedback` scope 검증기가 cwd 유래 project id를 수용하게 한 수정, `--strict`가 에러로 승격하는 안정적 lint 경고 ID `W1`/`W2`/`W4`이며 `--fix`로 자동복구되는 `W3`는 경고로 유지)은 [`CHANGELOG.md`](CHANGELOG.md)를 참고하세요. **v1.3.1**은 수정 전용 패치입니다: 업데이트 notifier 배너가 이제 보이지 않던 stderr 출력 대신 top-level `systemMessage` 채널로 실제 사용자에게 도달하고, `/hypo:upgrade`가 플러그인·dual(수동+플러그인) 설치에서 core 훅을 중복 등록하지 않으며, 세션 마무리가 두 프로젝트가 같은 최신 날짜를 가질 때 완료된 close를 false-block 하지 않습니다. **v1.3.2**는 비-프로젝트(툴링·위키 전용) 세션을 무관한 프로젝트에 엮지 않고 닫는 1급 log-only close 경로와, 페이지 이름을 바꿀 때 해당하는 인바운드 위키링크를 갱신하는 `rename` 헬퍼를 추가합니다(모호하거나 append-only인 참조는 갱신하지 않고 보고합니다). 또한 세션 마무리 게이트(오늘 활동한 모든 프로젝트를 게이트, 세션별 마커와 `/compact`가 하나의 게이트를 공유, 도출 가능한 루트 `log.md` 항목 자동 도출)와 linter(`--json`이 파이프에서 잘리지 않음, 볼트 관습 위키링크를 오탐 대신 정상 해석)를 수정합니다. **v1.3.3**은 세션 마무리 게이트를 실수 발동에 대해 단단히 합니다: 모델이 실제 사용자 종료 신호 없이 세션을 닫을 수 없고, 종료 관련 텍스트를 읽는 것이 턴을 false-block하지 않으며, `--apply-session-close`가 사용자 종료 신호가 있는 close에서 payload 커밋과 마커 기록을 한 번에 끝내고, 일상적 트래커 bookkeeping이 무관한 프로젝트의 `/compact`를 cross-block하지 않습니다. 또한 `rename`을 디렉터리 서브트리 전체 이동으로 확장합니다(`/hypo:rename` 커맨드와 대상 경로가 이미 있을 때의 merge/renumber 충돌 리포트 포함). **v1.3.4**는 위키 위생 수정 전용 패치입니다: `init`이 손수 쓴 알려진 등가본 옆에 중복 stock 페이지를 떨구지 않고(`wiki-automation.md`나 `wiki-help.md`가 보이면 그 페이지를 유지하고 stock `hypo-*`를 건너뛰며 머지 경고를 크게 냅니다), 볼트 루트의 재생성 가능한 리포트 산출물(업그레이드 `MIGRATION-v*.md` 리포트, 예약된 `GRAPH_REPORT.md` 이름 포함)을 정상 커밋되게 두면서 카탈로그 스캔에서 제외합니다. **v1.4.0**은 피드백 페이지에 선택적 `failure_type` 분류(여덟 값 중 하나, `hypomnema stats`가 유형별 개수로 노출)를 추가해 반복되는 실수 유형이 보이게 합니다. `SCHEMA`는 이 필드를 안 쓰는 페이지는 건드리지 않는 추가형 선택 필드로 2.1이 됩니다(이미 자유서술 `failure_type`을 손으로 적어 둔 페이지는 여덟 값 중 하나로 다시 분류해야 합니다). 또한 auto-commit이 머지 충돌을 만나면 절반만 머지된 볼트를 남기지 않고 깔끔히 abort하며, `lint`이 invalid YAML frontmatter(따옴표 없는 `: ` 값, 중복 top-level 키)를 조용히 통과시키지 않고, 중첩 매핑 키가 페이지 `type`을 가리지 못하게 합니다. 내부적으로는 릴리스 파이프라인이 Claude Code 플러그인 채널까지 게이트하고(버전 일치, 네트워크 없는 플러그인 smoke, README 버전 floor), 공유 `lib/wikilink.mjs` resolver가 `lint`·`rename`·`graph`·`crystallize`의 페이지 순회 로직을 통합합니다.
254
254
 
255
255
  ### 셋업 & 유지보수
256
256
 
package/README.md CHANGED
@@ -23,7 +23,7 @@ _Make Claude take notes — and measure whether it actually does._
23
23
 
24
24
  > **Quick decoder for terms used below.** *frontmatter* = the YAML block at the top of a markdown file; *wikilink* = a `[[page-slug]]` cross-reference; *ADR* = "Architecture Decision Record", a short markdown page that records *why* a design choice was made; *projection* = a one-way derive (`pages/feedback/*.md` → `MEMORY.md` / `<learned_behaviors>`); *hook* = a script that Claude Code runs automatically on lifecycle events; *hot.md* / *session-state.md* = the per-project cache files that hold "what just happened" and "what's next" so a paused project resumes in one read. Full glossary lives under [Term decoder](#term-decoder).
25
25
 
26
- > **Current state vs. v2 vision.** v1.3.0 (today) is honest about its trigger model: most wiki behavior — ingest, query, session-close — still fires on **explicit `/hypo:*` commands**, but the auto-behavior surface is growing. The v2 thesis is *fully autonomous* — Claude reading, writing, and synthesizing the wiki without being asked. **v1.1.0** shipped the **observability score** that measures how often the wiki is actually used per session (ingest / query / session-close / citation rates). **v1.2.0** adds four load-bearing autonomous lanes on top:
26
+ > **Current state vs. v2 vision.** v1.4.0 (current release) is honest about its trigger model: most wiki behavior — ingest, query, session-close — still fires on **explicit `/hypo:*` commands**, but the auto-behavior surface is growing. The v2 thesis is *fully autonomous* — Claude reading, writing, and synthesizing the wiki without being asked. **v1.1.0** shipped the **observability score** that measures how often the wiki is actually used per session (ingest / query / session-close / citation rates). **v1.2.0** adds four load-bearing autonomous lanes on top:
27
27
  > - **feedback-as-SoT with one-way projections** — `pages/feedback/` becomes the single source-of-truth (SoT) for behavior corrections; the wiki one-way derives `MEMORY.md` and the `<learned_behaviors>` block inside `~/.claude/CLAUDE.md`, so you edit one place and the projections refresh on their own.
28
28
  > - **extensions companion sync** — anything you drop under `~/hypomnema/extensions/{agents,commands,hooks,skills}/` is mirrored into `~/.claude/` automatically; the optional `--codex` flag additionally mirrors `hooks` and `commands` into `~/.codex/` (agents/skills are Claude-only and skipped on the Codex target by design).
29
29
  > - **auto-project creation on cwd match** — when you `cd` into a git repo with a project marker (`package.json`, `Cargo.toml`, etc.) and no matching wiki project exists, Hypomnema offers to scaffold one for you.
@@ -250,7 +250,7 @@ All hooks resolve the wiki root via `HYPO_DIR` env → `hypo-config.md` scan →
250
250
 
251
251
  Additionally, the `SessionStart` hook performs a non-blocking background check against npm and the Claude Code plugin marketplace and prints an "Update available!" banner the next time a newer Hypomnema version has been published. Opt out with `HYPO_NO_UPDATE_CHECK=1`, `NO_UPDATE_NOTIFIER=1`, or by running under `CI=true`.
252
252
 
253
- For fix-level v1.3 detail beyond the lanes above (session-close lint scoped to touched files so `/compact` is no longer blocked by unrelated debt, the `feedback` scope validator accepting cwd-derived project ids, and the stable lint warning IDs `W1`/`W2`/`W4` that `--strict` promotes to errors while `W3`, auto-repaired by `--fix`, stays a warning), see [`CHANGELOG.md`](CHANGELOG.md). **v1.3.1** is a fixes-only patch: update-notifier banners now actually reach the user via the top-level `systemMessage` channel instead of an invisible stderr write, `/hypo:upgrade` no longer double-registers core hooks for plugin or dual (manual + plugin) installs, and session-close no longer false-blocks a completed close when two projects share the latest date. **v1.3.2** adds a first-class log-only session-close path, so a non-project (tooling or wiki-only) session closes cleanly without being forced onto an unrelated project, plus a `rename` helper that rewrites the eligible inbound wikilinks when you rename a page (ambiguous or append-only references are reported, not rewritten). It also tightens the session-close gate (every project with activity today is gated, the per-session marker and `/compact` share one gate, and the derivable root `log.md` entry auto-derives) and the linter (`--json` no longer truncates on a pipe, and vault-convention wikilinks resolve instead of being false-flagged). **v1.3.3** hardens the session-close gate against accidental triggers: the model can no longer mark a session closed without a real user close signal, reading close-related text no longer false-blocks the turn, `--apply-session-close` commits its payload and writes the marker in one step on a close that carries a user close signal, and routine tracker bookkeeping no longer cross-blocks an unrelated project's `/compact`. It also extends `rename` to move a whole directory subtree (with a `/hypo:rename` command and a merge/renumber collision report when a destination already exists). **v1.3.4** is a fixes-only patch for wiki hygiene: `init` no longer drops a duplicate stock page next to a known hand-authored equivalent (when it sees `wiki-automation.md` or `wiki-help.md` it keeps that page and skips the stock `hypo-*` one, with a loud merge notice), and regenerable report output at the vault root (the upgrade `MIGRATION-v*.md` report, plus the reserved `GRAPH_REPORT.md` name) is excluded from the catalog scans while staying fully committable.
253
+ For fix-level v1.3 detail beyond the lanes above (session-close lint scoped to touched files so `/compact` is no longer blocked by unrelated debt, the `feedback` scope validator accepting cwd-derived project ids, and the stable lint warning IDs `W1`/`W2`/`W4` that `--strict` promotes to errors while `W3`, auto-repaired by `--fix`, stays a warning), see [`CHANGELOG.md`](CHANGELOG.md). **v1.3.1** is a fixes-only patch: update-notifier banners now actually reach the user via the top-level `systemMessage` channel instead of an invisible stderr write, `/hypo:upgrade` no longer double-registers core hooks for plugin or dual (manual + plugin) installs, and session-close no longer false-blocks a completed close when two projects share the latest date. **v1.3.2** adds a first-class log-only session-close path, so a non-project (tooling or wiki-only) session closes cleanly without being forced onto an unrelated project, plus a `rename` helper that rewrites the eligible inbound wikilinks when you rename a page (ambiguous or append-only references are reported, not rewritten). It also tightens the session-close gate (every project with activity today is gated, the per-session marker and `/compact` share one gate, and the derivable root `log.md` entry auto-derives) and the linter (`--json` no longer truncates on a pipe, and vault-convention wikilinks resolve instead of being false-flagged). **v1.3.3** hardens the session-close gate against accidental triggers: the model can no longer mark a session closed without a real user close signal, reading close-related text no longer false-blocks the turn, `--apply-session-close` commits its payload and writes the marker in one step on a close that carries a user close signal, and routine tracker bookkeeping no longer cross-blocks an unrelated project's `/compact`. It also extends `rename` to move a whole directory subtree (with a `/hypo:rename` command and a merge/renumber collision report when a destination already exists). **v1.3.4** is a fixes-only patch for wiki hygiene: `init` no longer drops a duplicate stock page next to a known hand-authored equivalent (when it sees `wiki-automation.md` or `wiki-help.md` it keeps that page and skips the stock `hypo-*` one, with a loud merge notice), and regenerable report output at the vault root (the upgrade `MIGRATION-v*.md` report, plus the reserved `GRAPH_REPORT.md` name) is excluded from the catalog scans while staying fully committable. **v1.4.0** adds an optional `failure_type` classification to feedback pages (one of eight values, surfaced as per-type counts by `hypomnema stats`) so recurring mistake types become visible — the `SCHEMA` moves to 2.1 as an additive, optional field that leaves any page not using it untouched (a page that already carried a hand-written free-text `failure_type` must be relabeled to one of the eight values). It also makes an auto-commit that hits a merge conflict abort cleanly instead of leaving a half-merged vault, stops `lint` from silently green-passing invalid YAML frontmatter (an unquoted `: ` value or a duplicate top-level key), and prevents a nested mapping key from shadowing the page `type`. Under the hood the release pipeline now also gates the Claude Code plugin channel (version consistency, a network-free plugin smoke check, and a README-version floor) and a shared `lib/wikilink.mjs` resolver consolidates the page-walking logic across `lint`, `rename`, `graph`, and `crystallize`.
254
254
 
255
255
  ### Setup & maintenance
256
256
 
@@ -26,6 +26,7 @@ If the user did not provide them, ask. The classification fields are required so
26
26
  6. **Targets**: "Where should this project?" → `project-memory` (MEMORY.md) and/or `claude-learned` (global CLAUDE.md). Default `project-memory`.
27
27
  7. **Priority** (1–5, higher sorts first; default 3).
28
28
  8. **Sensitivity**: `public` (default) or `sanitized` (redacted secrets/paths). `private` is not allowed — the wiki is git-pushed.
29
+ 9. **Failure type** (optional): if this correction came from a real failure incident, classify it — `hallucination` | `false-completion` | `process-stall` | `over-caution` | `overreach` | `incompleteness` | `instruction-miss` | `convention-violation`. Omit it for a pure preference or a brand-new convention ("always do X"). When several fit, take the most specific (the list is in precedence order; see SCHEMA §3.1).
29
30
 
30
31
  If **claude-learned** is among the targets, the page must be `scope: global` + `tier: L1`, and you must also collect:
31
32
  - **Global summary**: a one-line summary for the CLAUDE.md learned-behaviors entry.
@@ -61,6 +62,7 @@ node <package-root>/scripts/feedback.mjs \
61
62
  --memory-summary="<one-line MEMORY.md summary>" \
62
63
  --reason="<why this rule exists>" \
63
64
  [--global-summary="<one-line CLAUDE.md summary>" --promote-to-global] \
65
+ [--failure-type="<enum>"] \
64
66
  [--source="session:<date>"] \
65
67
  [--hypo-dir="<path>"] \
66
68
  [--dry-run]
@@ -68,6 +70,8 @@ node <package-root>/scripts/feedback.mjs \
68
70
 
69
71
  When `--targets` includes `claude-learned`, `--global-summary` and `--promote-to-global` are required (and `--scope=global --tier=L1`).
70
72
 
73
+ `--failure-type` is optional (one of the eight values above). On **append** to an existing topic it is set only if the page has none; if the page already carries a different `failure_type` the command errors (a page holds a single failure_type — use a separate topic for a different one). Without the flag, an append leaves the frontmatter untouched as before.
74
+
71
75
  > **`scope: project:<project-id>` 주의.** `<project-id>`는 `feedback-sync`가 resolve한 project-id와 정확히 일치해야 한다 (default: cwd의 `/`,`.` → `-` 치환; `--project-id=<id>` 로 override). 일치하지 않으면 그 페이지는 해당 project의 MEMORY로 projection되지 **않는다** (silent skip — lint error 아님). v1.3.0부터 scope regex(`^(global|project:[A-Za-z0-9_-]+)$`)가 cwd-derived id 형식(`-Users-...`)을 그대로 허용하므로 lint 통과를 위해 `--project-id=<slug>`를 override할 필요는 없다. 단 cwd에 공백 등 `[A-Za-z0-9_-]` 밖 문자가 있으면 그 id는 여전히 거부되니 그때만 `--project-id=<id>`로 override한다.
72
76
 
73
77
  On a real (non-dry-run) write, the script automatically runs `feedback-sync --write` to refresh MEMORY.md / CLAUDE.md. If that post-step reports drift it prints a one-line warning — the page is still saved; reconcile with `hypomnema feedback-sync --check`.
@@ -115,7 +115,7 @@ Hooks run automatically at Claude Code lifecycle events. They are deployed to `~
115
115
  | `hypo-personal-check` | PreCompact validation: lint blockers, uncommitted changes, missing session-log entries → block compact |
116
116
  | `hypo-auto-stage` | After Write/Edit on a wiki path, run `git add` (skips paths matching `.hypoignore`) |
117
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) |
118
+ | `hypo-session-record` | At session stop, append `{session_id, transcript_path, recorded_at, cwd, device}` to `.cache/sessions/index.jsonl` (primary source for the observability audit) |
119
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) |
120
120
  | `hypo-cwd-change` | When working directory changes, re-resolve the active project and inject its `hot.md` |
121
121
  | `hypo-file-watch` | Notify on external wiki edits so the in-session view stays consistent |
@@ -335,7 +335,7 @@ journal/weekly/<YYYY-Www>.md ← committed report (heuristic v0, s
335
335
 
336
336
  `session-audit.mjs` reads transcripts from two locations, in priority order:
337
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 }`.
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, device }`. `device` (PRAC-17) is `os.hostname()` for multi-machine audit; the index lives under `.cache/` (gitignored in a normal vault) so it is a local-only per-session record.
339
339
  2. **Fallback:** `~/.claude/projects/<encoded>/*.jsonl` — scanned when the index is missing or empty (legacy / freshly-installed wikis).
340
340
 
341
341
  ### Classification
@@ -366,6 +366,8 @@ The score is a **proxy, not ground truth**. The four-week baseline plan (capture
366
366
 
367
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
368
 
369
+ **PRAC-17 audit fields (`device`, `session_id`).** `device` (`os.hostname()`) is recorded in two places with different sync boundaries, by design. In `.cache/sessions/index.jsonl` it is per-session-accurate but **local-only** (`.cache/` is gitignored in a normal vault). In the seeded session-log daily-shard frontmatter it is an **intentional synced field** — a creator-only stamp (the session/machine that first created the day's shard), carried across machines so the wiki itself records which machine opened each day. `session_id` (the Claude session UUID) is seeded into that same synced frontmatter, but only on the Stop-chain close path that passes `--session-id`; on manual/skill closes it is omitted. This deliberately puts a hostname (and, on the automated path, the session UUID) into git-tracked, synced content; it is acceptable for a single-user private vault where multi-machine identification is the goal. Operators who do not want a hostname synced can override `os.hostname()` at the OS level or treat the field as a pseudonymous label.
370
+
369
371
  ### Growth metrics (Lane B)
370
372
 
371
373
  A separate, lightweight counter — distinct from the audit pipeline — runs at every Stop / SessionStart pair:
@@ -232,28 +232,56 @@ Hypomnema uses semver. Releases are automated via `release.yml` on `v*` tag push
232
232
 
233
233
  ### Cutting a release
234
234
 
235
+ The maintainer drives this checklist with a personal `/ship` command (it lives in
236
+ `~/.claude/commands/`, alongside `qa-before-ship` — it is maintainer tooling, not
237
+ a shipped plugin command, since an OSS user has no reason to release Hypomnema
238
+ itself). The sequence below is that same checklist, and is the authoritative
239
+ in-repo reference; follow it whether or not you have the `/ship` convenience
240
+ command.
241
+
235
242
  Every Hypomnema release must carry a Korean summary alongside the English body
236
243
  in **both** the CHANGELOG section AND the git tag annotation. The release
237
244
  workflow enforces this with `scripts/check-bilingual.mjs`; lightweight tags or
238
- a missing `### 한글 요약` section will block `npm publish`.
245
+ a missing `### 한글 요약` section will block `npm publish`. It also requires the
246
+ release version to appear in **both** `README.md` and `README.ko.md`
247
+ (`scripts/check-readme-version.mjs`) — the floor that stops the README reconcile
248
+ from being dropped.
239
249
 
240
250
  ```bash
241
251
  # 1. Bump the version across package.json, plugin.json, marketplace.json,
242
252
  # and templates/hypo-config.md. Takes a concrete semver (not patch/minor/major).
243
253
  node scripts/bump-version.mjs <new-semver> # e.g. 1.2.2 or 1.3.0-rc.1
244
254
 
255
+ # 1b. bump-version does NOT touch package-lock.json — sync it so `npm ci` and the
256
+ # version-consistency gate stay green (the lock carries the version twice).
257
+ npm install --package-lock-only
258
+
245
259
  # 2. Edit CHANGELOG.md — the new section MUST include a "### 한글 요약"
246
260
  # sub-section with at least 10 Hangul characters of real summary text.
247
261
  $EDITOR CHANGELOG.md
248
262
 
249
- # 3. Verify locally before tagging (same check that runs in CI)
250
- node scripts/check-bilingual.mjs --changelog
251
-
252
- # 4. Commit
253
- git add package.json CHANGELOG.md
263
+ # 3. Reconcile BOTH READMEs add a v<version> sentence to the rolling version
264
+ # narrative in README.md AND README.ko.md, and update the first-viewport
265
+ # "current release" pointer. (Dropped 3x historically; check:readme is the floor.)
266
+ $EDITOR README.md README.ko.md
267
+
268
+ # 4. Verify locally before tagging (the full gate set CI + prepublishOnly run)
269
+ npm test # unit suite
270
+ npm run lint # vault linter
271
+ npm run check:versions # all version-carrying files (incl. package-lock) agree
272
+ npm run check:bilingual # CHANGELOG section has a 한글 요약
273
+ npm run check:readme # v<version> present in BOTH READMEs
274
+ npm run smoke:plugin # plugin manifest + hooks/commands/skills load-valid
275
+ npm run smoke-pack # packed tarball installs and resolves
276
+ npm run check:tracker-ids # no private-tracker pointers leaked into shipped files
277
+
278
+ # 5. Commit — include EVERY file the bump touched, the READMEs, and the lockfile.
279
+ git add package.json package-lock.json .claude-plugin/ templates/hypo-config.md \
280
+ CHANGELOG.md README.md README.ko.md
254
281
  git commit -m "chore: release v<version>"
282
+ # Then open the release PR, pass review + green CI, and squash-merge to main.
255
283
 
256
- # 5. Tag with an ANNOTATED tag — never a lightweight tag.
284
+ # 6. Tag the merge commit with an ANNOTATED tag — never a lightweight tag.
257
285
  # Annotation body shape: English summary, then "---" on its own line,
258
286
  # then a Korean summary block.
259
287
  git tag -a v<version> -m "$(cat <<'EOF'
@@ -269,22 +297,33 @@ Hypomnema v<version> — <한 줄 한글 요약>
269
297
  EOF
270
298
  )"
271
299
 
272
- # 6. Verify the tag annotation locally (same check that runs in CI)
300
+ # 7. Rehearse the release locally (same checks CI runs against the tag)
273
301
  node scripts/check-bilingual.mjs --tag v<version>
302
+ node scripts/check-versions.mjs --tag v<version>
303
+ npm publish --dry-run --access public # packs + prepublishOnly, NO registry PUT
274
304
 
275
- # 7. Push
276
- git push origin main --tags
305
+ # 8. Push the SPECIFIC tag alone — NOT --tags (that would push stale local tags
306
+ # and could trigger releases for versions you did not intend).
307
+ git push origin v<version>
277
308
  ```
278
309
 
279
310
  The `release.yml` workflow then:
280
311
 
281
- 1. Verifies the tag matches `package.json` version.
282
- 2. Validates the CHANGELOG section AND the tag annotation are bilingual
283
- (`scripts/check-bilingual.mjs`).
284
- 3. Runs `npm test` and `npm run lint`.
285
- 4. Publishes to npm with `npm publish --access public --provenance`.
286
-
287
- `NPM_TOKEN` must be set as a repository secret.
312
+ 1. Verifies the tag matches the version in EVERY version-carrying file —
313
+ `package.json`, `package-lock.json`, `.claude-plugin/{plugin,marketplace}.json`,
314
+ and `templates/hypo-config.md` (`scripts/check-versions.mjs --tag`).
315
+ 2. Smokes the plugin channel — manifest, `hooks/hooks.json` targets, and
316
+ command/skill component files (`scripts/smoke-plugin.mjs`).
317
+ 3. Validates the CHANGELOG section AND the tag annotation are bilingual
318
+ (`scripts/check-bilingual.mjs`), and that the version is present in both
319
+ READMEs (`scripts/check-readme-version.mjs`).
320
+ 4. Runs `npm test` and `npm run lint`.
321
+ 5. Publishes to npm with `npm publish --access public --provenance` and creates
322
+ the GitHub Release from the tag body.
323
+
324
+ `NPM_TOKEN` must be set as a repository secret. After rotating it, run the
325
+ `release.yml` `workflow_dispatch` "publish credential pre-check" — it verifies the
326
+ token authenticates without publishing anything.
288
327
 
289
328
  ### Versioning rules
290
329
 
@@ -6,14 +6,10 @@
6
6
  */
7
7
 
8
8
  import { spawnSync } from 'child_process';
9
- import { HYPO_DIR, appendSyncFailure, commitWikiChanges } from './hypo-shared.mjs';
10
-
11
- function git(...args) {
12
- return spawnSync('git', ['-C', HYPO_DIR, ...args], { encoding: 'utf-8', timeout: 30000 });
13
- }
9
+ import { HYPO_DIR, syncRemote, commitWikiChanges } from './hypo-shared.mjs';
14
10
 
15
11
  function hasRemote() {
16
- const r = git('remote');
12
+ const r = spawnSync('git', ['-C', HYPO_DIR, 'remote'], { encoding: 'utf-8', timeout: 30000 });
17
13
  return (r.stdout || '').trim().length > 0;
18
14
  }
19
15
 
@@ -28,12 +24,10 @@ if (!result.committed) {
28
24
 
29
25
  if (hasRemote()) {
30
26
  // pull/push failures must not stop the session, but they can no longer be
31
- // swallowed silently — record each to .cache/sync-state.json so session-start
32
- // and doctor can surface them next session.
33
- const pull = git('pull', '--no-rebase', '-q');
34
- if (pull.status !== 0) appendSyncFailure(HYPO_DIR, 'pull', pull.stderr || pull.stdout);
35
- const push = git('push');
36
- if (push.status !== 0) appendSyncFailure(HYPO_DIR, 'push', push.stderr || push.stdout);
27
+ // swallowed silently — syncRemote records each to .cache/sync-state.json and,
28
+ // on a merge conflict, aborts the merge so the tree is never left half-merged
29
+ // (FEAT-17 hardening). session-start + doctor surface the result next session.
30
+ syncRemote(HYPO_DIR);
37
31
  }
38
32
 
39
33
  console.log(JSON.stringify({ continue: true, suppressOutput: true }));
@@ -12,6 +12,7 @@
12
12
 
13
13
  import { existsSync, mkdirSync, appendFileSync } from 'fs';
14
14
  import { dirname, join } from 'path';
15
+ import { hostname } from 'os';
15
16
  import { HYPO_DIR } from './hypo-shared.mjs';
16
17
 
17
18
  const INDEX_PATH = join(HYPO_DIR, '.cache', 'sessions', 'index.jsonl');
@@ -50,6 +51,10 @@ process.stdin.on('end', () => {
50
51
  transcript_path: transcriptPath,
51
52
  recorded_at: new Date().toISOString(),
52
53
  cwd: payload.cwd || process.cwd(),
54
+ // PRAC-17: machine identity for multi-machine audit. index.jsonl lives
55
+ // under .cache/ (gitignored in a normal vault), so this is a LOCAL-only
56
+ // per-session record — accurate for every session, no sync/privacy cost.
57
+ device: hostname() || 'unknown',
53
58
  };
54
59
  appendFileSync(INDEX_PATH, JSON.stringify(entry) + '\n');
55
60
  } catch (err) {
@@ -243,6 +243,13 @@ function syncStateNotice(pullOk) {
243
243
  return '';
244
244
  }
245
245
  const last = entries[entries.length - 1];
246
+ if (last.op === 'conflict') {
247
+ return (
248
+ `[WIKI: remote diverged — auto-merge was aborted to protect your edits ` +
249
+ `(your local work is committed and safe; the other machine's version is on the remote). ` +
250
+ `Resolve manually: \`git -C ${HYPO_DIR} pull --no-rebase\`, fix conflicts, then push.]`
251
+ );
252
+ }
246
253
  return `[WIKI: last sync failed: ${last.op || '?'} — ${last.error || 'unknown'}]`;
247
254
  }
248
255
  const GLOBAL_HOT = join(HYPO_DIR, 'hot.md');
@@ -846,7 +846,10 @@ function syncStatePath(hypoDir) {
846
846
  * failure-log must not break the Stop hook that calls it.
847
847
  *
848
848
  * @param {string} hypoDir
849
- * @param {'pull'|'push'} op
849
+ * @param {'pull'|'push'|'conflict'|'conflict-unresolved'} op 'conflict' = a
850
+ * merge conflict was detected and aborted (the tree was left clean at the
851
+ * local commit); 'conflict-unresolved' = the abort itself failed and the tree
852
+ * may still be half-merged (rare). See syncRemote.
850
853
  * @param {string} error raw stderr/stdout; first non-empty line is kept
851
854
  */
852
855
  export function appendSyncFailure(hypoDir, op, error) {
@@ -870,6 +873,70 @@ export function appendSyncFailure(hypoDir, op, error) {
870
873
  }
871
874
  }
872
875
 
876
+ /**
877
+ * Pull + push the wiki against its remote, guaranteeing the working tree is
878
+ * never left half-merged. Called by the auto-commit Stop hook after a local
879
+ * commit succeeds.
880
+ *
881
+ * Failure policy (v1.4 "sync hardening", tracker FEAT-17):
882
+ * - clean fast-forward / conflict-free merge → push.
883
+ * - MERGE CONFLICT (`git pull --no-rebase` leaves unmerged paths): abort the
884
+ * merge so the tree returns to the just-committed local state ("ours"),
885
+ * record op='conflict', and do NOT push (a diverged branch cannot
886
+ * fast-forward, so the push would only add a noisy second failure). No data
887
+ * is lost: ours stays committed locally, "theirs" stays on the remote, and
888
+ * the divergence is surfaced by session-start + doctor until the user merges
889
+ * manually. Inline auto-resolution (preserving the losing version as a
890
+ * `.conflict-*` sibling) is deferred — see tracker PRAC-18.
891
+ * - non-conflict pull failure (network/auth: no unmerged paths) → record
892
+ * op='pull', then still attempt push (a transient pull blip should not block
893
+ * an otherwise-pushable commit); record op='push' if that also fails.
894
+ *
895
+ * Best-effort: never throws — a sync failure must not break the Stop hook.
896
+ *
897
+ * @param {string} hypoDir
898
+ * @returns {{pulled: boolean, pushed: boolean, conflict: boolean}}
899
+ */
900
+ export function syncRemote(hypoDir) {
901
+ const git = (...args) =>
902
+ spawnSync('git', ['-C', hypoDir, ...args], { encoding: 'utf-8', timeout: 30000 });
903
+ const result = { pulled: false, pushed: false, conflict: false };
904
+ try {
905
+ const pull = git('pull', '--no-rebase', '-q');
906
+ if (pull.status === 0) {
907
+ result.pulled = true;
908
+ } else {
909
+ // A merge conflict leaves unmerged index entries; a network/auth failure
910
+ // leaves none. Only the former must be aborted to keep the tree clean.
911
+ const unmerged = git('ls-files', '-u');
912
+ const hasConflict = unmerged.status === 0 && (unmerged.stdout || '').trim().length > 0;
913
+ if (hasConflict) {
914
+ // Abort to return the tree to the just-committed local state. Verify the
915
+ // abort actually cleaned up: if it fails (filesystem/concurrent-mutation
916
+ // edge), the tree may still be half-merged, so record that distinctly
917
+ // ('conflict-unresolved') rather than masking it as a clean abort.
918
+ const abort = git('merge', '--abort');
919
+ const stillUnmerged = git('ls-files', '-u');
920
+ const aborted = abort.status === 0 && (stillUnmerged.stdout || '').trim().length === 0;
921
+ appendSyncFailure(
922
+ hypoDir,
923
+ aborted ? 'conflict' : 'conflict-unresolved',
924
+ pull.stderr || pull.stdout,
925
+ );
926
+ result.conflict = true;
927
+ return result; // do not push from a diverged branch
928
+ }
929
+ appendSyncFailure(hypoDir, 'pull', pull.stderr || pull.stdout);
930
+ }
931
+ const push = git('push');
932
+ if (push.status === 0) result.pushed = true;
933
+ else appendSyncFailure(hypoDir, 'push', push.stderr || push.stdout);
934
+ } catch {
935
+ // best-effort — never break the Stop hook
936
+ }
937
+ return result;
938
+ }
939
+
873
940
  /**
874
941
  * Stage + commit every non-.hypoignore change in the wiki. Does NOT pull/push —
875
942
  * remote sync stays in the auto-commit Stop hook (commit is local + cheap; sync is
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hypomnema",
3
- "version": "1.3.4",
3
+ "version": "1.4.0",
4
4
  "description": "LLM-native personal wiki system for Claude Code",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -42,12 +42,15 @@
42
42
  "fix:verify": "node scripts/fix-status-verify.mjs",
43
43
  "graph": "node scripts/graph.mjs",
44
44
  "smoke-pack": "node scripts/smoke-pack.mjs",
45
+ "check:versions": "node scripts/check-versions.mjs",
46
+ "smoke:plugin": "node scripts/smoke-plugin.mjs",
45
47
  "check:bilingual": "node scripts/check-bilingual.mjs --changelog",
48
+ "check:readme": "node scripts/check-readme-version.mjs",
46
49
  "check:tracker-ids": "node scripts/check-tracker-ids.mjs --all",
47
50
  "format": "prettier --write .",
48
51
  "format:check": "prettier --check .",
49
52
  "prepare": "node scripts/install-git-hooks.mjs",
50
- "prepublishOnly": "npm test && npm run lint && npm run smoke-pack && npm run check:bilingual && npm run check:tracker-ids"
53
+ "prepublishOnly": "npm test && npm run lint && npm run check:versions && npm run smoke:plugin && npm run smoke-pack && npm run check:bilingual && npm run check:readme && npm run check:tracker-ids"
51
54
  },
52
55
  "devDependencies": {
53
56
  "prettier": "^3.8.3"
@@ -51,13 +51,27 @@ for (const { path, pattern } of targets) {
51
51
  console.log(`✓ ${path}: ${current} → ${next}`);
52
52
  }
53
53
 
54
+ console.log(`\nThis bumps the 4 manifest files only. The full release checklist lives in`);
55
+ console.log(`docs/CONTRIBUTING.md "Cutting a release" — follow it, or these steps:`);
54
56
  console.log(`\nNext steps:`);
55
- console.log(` 1. Edit CHANGELOG.md ensure the "## [${next}]" section has a`);
57
+ console.log(` 1. Sync package-lock (bump-version does NOT touch it; the lock carries the`);
58
+ console.log(` version twice and a stale lock fails check:versions + breaks npm ci):`);
59
+ console.log(` npm install --package-lock-only`);
60
+ console.log(` 2. Edit CHANGELOG.md — ensure the "## [${next}]" section has a`);
56
61
  console.log(` "### 한글 요약" sub-section (release.yml check-bilingual gate).`);
57
- console.log(` 2. node scripts/check-bilingual.mjs --changelog # local pre-check`);
58
- console.log(` 3. git add -A && git commit -m "chore(release): v${next}"`);
59
- console.log(` 4. Create an ANNOTATED tag (lightweight tags are rejected by CI):`);
62
+ console.log(` 3. Reconcile BOTH READMEs add a v${next} sentence to README.md AND`);
63
+ console.log(` README.ko.md, and update the first-viewport "current release" pointer.`);
64
+ console.log(` (This step was dropped 3x; check:readme is now the floor gate.)`);
65
+ console.log(` 4. Local pre-checks — the full gate set CI + prepublishOnly run:`);
66
+ console.log(` npm test && npm run lint && npm run check:versions \\`);
67
+ console.log(` && npm run check:bilingual && npm run check:readme \\`);
68
+ console.log(` && npm run smoke:plugin && npm run smoke-pack && npm run check:tracker-ids`);
69
+ console.log(` 5. git add package.json package-lock.json .claude-plugin/ \\`);
70
+ console.log(` templates/hypo-config.md CHANGELOG.md README.md README.ko.md`);
71
+ console.log(` git commit -m "chore(release): v${next}"`);
72
+ console.log(` 6. Create an ANNOTATED tag (lightweight tags are rejected by CI):`);
60
73
  console.log(` git tag -a v${next} -m "<English body>\\n\\n---\\n\\n<한글 요약>"`);
61
74
  console.log(` See docs/CONTRIBUTING.md "Cutting a release" for the full template.`);
62
- console.log(` 5. node scripts/check-bilingual.mjs --tag v${next} # local pre-check`);
63
- console.log(` 6. git push origin <branch> --tags`);
75
+ console.log(` 7. node scripts/check-bilingual.mjs --tag v${next} # local pre-check`);
76
+ console.log(` 8. git push origin <branch> # then push the tag ALONE to trigger release:`);
77
+ console.log(` git push origin v${next} # NOT --tags (avoid triggering stale tags)`);
@@ -0,0 +1,126 @@
1
+ #!/usr/bin/env node
2
+ // check-readme-version.mjs — assert the release version string is present in BOTH
3
+ // README.md and README.ko.md. This is the machine FLOOR for the README-reconcile
4
+ // step that was dropped three times (v1.2 / v1.3.0 / v1.3.1): a publish of vX.Y.Z
5
+ // must not go out unless vX.Y.Z is written into both READMEs' version narrative.
6
+ //
7
+ // SCOPE (honest): this catches the GROSS drop — shipping a version whose sentence
8
+ // was never added to the README at all. It does NOT verify the narrative is
9
+ // accurate or that the first-viewport "current release" pointer was updated; that
10
+ // stays a judgment step owned by the release checklist (docs/CONTRIBUTING.md
11
+ // "Cutting a release"). README/CHANGELOG carry
12
+ // prose version HISTORY (every past vX.Y.Z), so check-versions.mjs intentionally
13
+ // excludes them — this script is their complement, not a duplicate.
14
+ //
15
+ // Usage:
16
+ // node scripts/check-readme-version.mjs # version from package.json
17
+ // node scripts/check-readme-version.mjs --version 1.4.0 # explicit version
18
+ // node scripts/check-readme-version.mjs --root <dir> # point at a fixture (tests)
19
+ // node scripts/check-readme-version.mjs --json
20
+ //
21
+ // Exit 0 = both READMEs mention the version. Exit 1 = missing in one/both, or unreadable.
22
+
23
+ import { readFileSync } from 'fs';
24
+ import { join, dirname } from 'path';
25
+ import { fileURLToPath } from 'url';
26
+
27
+ function parseArgs(argv) {
28
+ const args = { root: null, version: null, json: false };
29
+ for (let i = 0; i < argv.length; i++) {
30
+ const a = argv[i];
31
+ if (a.startsWith('--root=')) args.root = a.slice(7);
32
+ else if (a === '--root') args.root = argv[++i];
33
+ else if (a.startsWith('--version=')) args.version = a.slice(10);
34
+ else if (a === '--version') args.version = argv[++i];
35
+ else if (a === '--json') args.json = true;
36
+ }
37
+ return args;
38
+ }
39
+
40
+ const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
41
+
42
+ const README_FILES = ['README.md', 'README.ko.md'];
43
+
44
+ // Build a boundary-aware matcher so a version does not spuriously match inside a
45
+ // LONGER version token yet still matches at a sentence boundary. The checked
46
+ // version must be a standalone token, NOT a prefix of another version:
47
+ // - "1.3.40", "v11.3.4" → reject (digit continuation)
48
+ // - "1.3.4.5" → reject (dot-then-digit continuation)
49
+ // - "1.3.4-rc.1", "1.3.4+b" → reject (a stable check must NOT pass on a
50
+ // prerelease/build string — that defeats the floor)
51
+ // - "1.3.0-rc.1a", "1.3.0-rc.10", "1.3.0-rc.1.alpha" → a prerelease check must
52
+ // reject these continuation tokens too
53
+ // - "shipped v1.3.4." / "v1.3.4**" / "(v1.3.4)" → match (sentence/markup boundary)
54
+ // Leading: no digit/dot immediately before (an optional "v" is fine — it is
55
+ // neither). Trailing: not a semver-continuation char ([0-9A-Za-z+-]) and not a
56
+ // dot-then-alphanumeric (".5" / ".alpha" continuation); a sentence period
57
+ // (dot followed by space / punctuation / end) is allowed.
58
+ function versionPresent(text, version) {
59
+ const escaped = version.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
60
+ return new RegExp(`(?<![\\d.])${escaped}(?![\\dA-Za-z+-])(?!\\.[\\dA-Za-z])`).test(text);
61
+ }
62
+
63
+ function checkReadmeVersions(root, version) {
64
+ const results = README_FILES.map((file) => {
65
+ const abs = join(root, file);
66
+ try {
67
+ const text = readFileSync(abs, 'utf-8');
68
+ return { file, present: versionPresent(text, version) };
69
+ } catch (err) {
70
+ return { file, present: false, error: err?.message ?? String(err) };
71
+ }
72
+ });
73
+ const ok = results.every((r) => r.present);
74
+ return { ok, version, results };
75
+ }
76
+
77
+ function main() {
78
+ const args = parseArgs(process.argv.slice(2));
79
+ const root = args.root || REPO_ROOT;
80
+
81
+ let version = args.version;
82
+ if (!version) {
83
+ try {
84
+ version = JSON.parse(readFileSync(join(root, 'package.json'), 'utf-8')).version;
85
+ } catch (err) {
86
+ const msg = `cannot read package.json for version: ${err?.message ?? err}`;
87
+ if (args.json) console.log(JSON.stringify({ ok: false, error: msg }, null, 2));
88
+ else console.error(`✗ ${msg}`);
89
+ process.exit(1);
90
+ }
91
+ }
92
+ if (typeof version !== 'string' || !version) {
93
+ const msg = 'no version (—version empty and package.json has no "version" field)';
94
+ if (args.json) console.log(JSON.stringify({ ok: false, error: msg }, null, 2));
95
+ else console.error(`✗ ${msg}`);
96
+ process.exit(1);
97
+ }
98
+
99
+ const report = checkReadmeVersions(root, version);
100
+
101
+ if (args.json) {
102
+ console.log(JSON.stringify(report, null, 2));
103
+ } else {
104
+ for (const r of report.results) {
105
+ const mark = r.error ? `ERROR: ${r.error}` : r.present ? 'found' : 'MISSING';
106
+ console.log(` ${r.file.padEnd(13)} ${mark}`);
107
+ }
108
+ if (report.ok) {
109
+ console.log(`\n✓ both READMEs mention version ${version}`);
110
+ } else {
111
+ const missing = report.results
112
+ .filter((r) => !r.present)
113
+ .map((r) => (r.error ? `${r.file} (unreadable)` : r.file))
114
+ .join(', ');
115
+ console.error(
116
+ `\n✗ version ${version} missing from: ${missing}\n` +
117
+ ` Reconcile the version narrative in both READMEs before releasing ` +
118
+ `(the README reconcile step — see docs/CONTRIBUTING.md "Cutting a release").`,
119
+ );
120
+ }
121
+ }
122
+
123
+ process.exit(report.ok ? 0 : 1);
124
+ }
125
+
126
+ main();