hypomnema 1.2.1 → 1.3.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 (38) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/commands/crystallize.md +23 -6
  4. package/commands/feedback.md +1 -1
  5. package/docs/CONTRIBUTING.md +96 -11
  6. package/hooks/hypo-auto-commit.mjs +3 -3
  7. package/hooks/hypo-auto-minimal-crystallize.mjs +8 -3
  8. package/hooks/hypo-cwd-change.mjs +2 -2
  9. package/hooks/hypo-first-prompt.mjs +1 -1
  10. package/hooks/hypo-personal-check.mjs +57 -7
  11. package/hooks/hypo-session-start.mjs +51 -4
  12. package/hooks/hypo-shared.mjs +137 -12
  13. package/hooks/version-check.mjs +204 -6
  14. package/package.json +5 -2
  15. package/scripts/bump-version.mjs +9 -3
  16. package/scripts/check-bilingual.mjs +115 -0
  17. package/scripts/crystallize.mjs +124 -15
  18. package/scripts/doctor.mjs +45 -9
  19. package/scripts/feedback-sync.mjs +44 -15
  20. package/scripts/feedback.mjs +5 -5
  21. package/scripts/fix-status-verify.mjs +256 -0
  22. package/scripts/init.mjs +45 -4
  23. package/scripts/install-git-hooks.mjs +258 -0
  24. package/scripts/lib/adr-corpus.mjs +79 -0
  25. package/scripts/lib/check-bilingual.mjs +141 -0
  26. package/scripts/lib/extensions.mjs +3 -3
  27. package/scripts/lib/feedback-scope.mjs +21 -0
  28. package/scripts/lib/fix-manifest.mjs +109 -0
  29. package/scripts/lib/fix-status-verify.mjs +438 -0
  30. package/scripts/lib/pre-commit-format.mjs +251 -0
  31. package/scripts/lib/project-create.mjs +2 -2
  32. package/scripts/lint.mjs +48 -8
  33. package/scripts/pre-commit-format.mjs +198 -0
  34. package/scripts/smoke-pack.mjs +16 -0
  35. package/scripts/upgrade.mjs +55 -23
  36. package/skills/crystallize/SKILL.md +13 -2
  37. package/templates/hypo-config.md +1 -1
  38. package/templates/hypo-guide.md +4 -0
@@ -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.2.1",
14
+ "version": "1.3.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.2.1",
3
+ "version": "1.3.0",
4
4
  "description": "LLM-native personal wiki system — session-aware knowledge base for Claude Code",
5
5
  "author": {
6
6
  "name": "sk-lim19f",
@@ -11,7 +11,20 @@ You are running `/hypo:crystallize`. This command serves two purposes:
11
11
 
12
12
  ## Step 1 — Detect context
13
13
 
14
- If the user invoked `/hypo:crystallize` to close a session (phrases like "세션 종료", "오늘 작업 마무리", "session close", or "wrap up"), run Steps 2–4 (session-close mechanical apply + recovery) **before** the synthesis scan. Otherwise skip to Step 5.
14
+ If the user invoked `/hypo:crystallize` to close a session (phrases like "세션 종료", "오늘 작업 마무리", "session close", or "wrap up"), run Step 1a (advisory reflections) then Steps 2–4 (session-close mechanical apply + recovery) **before** the synthesis scan. Otherwise skip to Step 5.
15
+
16
+ ---
17
+
18
+ ## Step 1a — Session-close advisory reflections
19
+
20
+ Before composing the payload (Step 2), run these four reflections and surface each to the user. Every one is **advisory** (ADR 0029 identity guard) — the user confirms or declines, and none performs an automatic action, writes a file on its own, or bypasses the mandatory gate.
21
+
22
+ 1. **Trivial-session check (#44)** — Was this session trivial (a single bug fix, a single-file edit, or Q&A with no durable artifact)? If so, recommend skipping session-close: *"이 세션은 trivial해 보입니다 — session-close를 건너뛸까요?"* and proceed only if the user wants a close. A trivial skip is a recommendation, **not** a bypass: it must not mark the session closed, must not run `--mark-session-closed`, and must not claim `/compact` can pass. Any real close still requires all 5 mandatory files.
23
+ 2. **ADR-candidate check (#41)** — Did this session make an architectural or design decision (a new pattern, a tradeoff chosen, a convention established)? If yes, ask whether it warrants an ADR and, if so, capture that intent in the `sessionLog` entry you compose in Step 2. If nothing rose to ADR level, record `ADR 없음 — <one-line reason>` in that same `sessionLog` entry. **Never auto-write an ADR file** — recording the decision (or its absence) in the session-log payload is the only action here.
24
+ 3. **design-history staleness check (#42)** — If `projects/<name>/design-history.md` exists and this session changed design decisions it does not yet reflect, recommend updating it (the W8 lint warning flags this mechanically; an active-project W8 can also block at PreCompact). If the file does not exist, skip silently — do **not** create it just for this check. Never auto-update it.
25
+ 4. **Ingest check (#43)** — Did this session consume trustworthy external knowledge (a fetched URL, official docs, or code you verified directly)? If so, recommend running `/hypo:ingest` to capture it under `sources/`. Proceed only on the user's confirmation.
26
+
27
+ These are judgment calls; when uncertain, surface the question rather than skip it. None of the four blocks the close or writes on its own.
15
28
 
16
29
  ---
17
30
 
@@ -89,10 +102,14 @@ synthesis (no session-close intent) — the marker is then simply not written.
89
102
  | `--apply-session-close --payload=<path> --session-id=<id>` | Same as above, **plus** writes the per-session closed marker on success (clean git required). The Stop-chain Layer 3 path. |
90
103
  | `--apply-session-close --force` | Skips the probe early-exit. `--payload` still required for any actual apply work. |
91
104
 
92
- **Two lint gates run automatically (fix #40):**
105
+ **Two lint gates run automatically (fix #40), scoped to the files this close writes:**
106
+
107
+ Both gates judge only the **payload files** (the 5 mandatory close files + `open-questions.md`). Lint debt in other projects or shared `pages/` this close did not author is reported as a non-blocking `notices[]` entry, never gated — so an unrelated broken page elsewhere cannot block your close.
108
+
109
+ 1. **Preflight** — `lint.mjs --json` runs **before** any payload bytes are written. Errors in overwrite targets (sessionState / projectHot / rootHot / openQuestions) are filtered (about to be replaced). Errors in an **append target** (session-log / log.md) still block (appending can't repair existing corruption) → exit 1 with `stage='preflight-lint'`. Errors outside the payload files → `notices[]`, apply proceeds.
110
+ 2. **Post-apply** — lint re-runs after the writes. Blocks only on **errors** in payload files (a payload-introduced malformed body / bad frontmatter); pre-existing errors elsewhere → `notices[]`. A lint crash (unparseable output) always blocks. Broken wikilinks are lint **warnings** (W4 — forward references to planned pages are normal) and are not gated here. Surfaces as `stage='post-apply-lint'` (or `'post-apply-verification+lint'` if freshness also fails).
93
111
 
94
- 1. **Preflight** `lint.mjs --json` runs **before** any payload bytes are written. Errors in files this payload will overwrite (sessionState / projectHot / rootHot / openQuestions) are filtered (they're about to be replaced anyway). Errors in any other file exit 1 with `stage='preflight-lint'`, no apply occurs.
95
- 2. **Post-apply** — lint re-runs after the writes. Catches payloads that introduce a broken wikilink or malformed body. Surfaces as `stage='post-apply-lint'` (or `'post-apply-verification+lint'` if the freshness gate also fails).
112
+ > **Manual close (direct Write tool calls)** clears the Stop-chain block via `--mark-session-closed --session-id=<id>`. Pass `--transcript-path=<path>` (the Stop hook surfaces it in its block message) so the marker is refused when a file **this session edited** still has lint errors keeping the marker coherent with the PreCompact gate. Without `--transcript-path` it falls back to freshness + clean-git only (lint left to PreCompact).
96
113
 
97
114
  ---
98
115
 
@@ -102,9 +119,9 @@ The result JSON includes a `stage` field when `ok: false`. Branch on it:
102
119
 
103
120
  | `stage` | What broke | How to recover |
104
121
  |---|---|---|
105
- | `preflight-lint` | A non-payload file in the wiki has a blocking lint error. | Fix the lint error in that file (separate from session-close), then re-run. No payload bytes were written. |
122
+ | `preflight-lint` | A payload file (append target session-log / log.md) has a pre-existing blocking lint error. | Fix the lint error in that file, then re-run. No payload bytes were written. (Debt outside the payload files is a non-blocking notice, not this stage.) |
106
123
  | `post-apply-verification` | A mandatory file's `updated:` frontmatter is stale (≠ today) after apply. | Edit the payload's stale `content` (or supply correct `date`), then re-run. Writes are idempotent — re-applying a corrected payload is safe. |
107
- | `post-apply-lint` | The payload itself introduced a lint blocker (broken wikilink, bad frontmatter). | Fix the offending content in the payload, then re-run. |
124
+ | `post-apply-lint` | The payload introduced an error-level lint blocker in a payload file (malformed body / bad frontmatter), or lint crashed. | Fix the offending content in the payload, then re-run. (Broken wikilinks are W4 warnings — not gated.) |
108
125
  | `post-apply-verification+lint` | Both above. | Fix both; re-run. |
109
126
 
110
127
  Once `ok: true`, report:
@@ -68,7 +68,7 @@ node <package-root>/scripts/feedback.mjs \
68
68
 
69
69
  When `--targets` includes `claude-learned`, `--global-summary` and `--promote-to-global` are required (and `--scope=global --tier=L1`).
70
70
 
71
- > **`scope: project:<project-id>` 주의 (v1.2.0).** `<project-id>`는 `feedback-sync`가 resolve한 project-id와 정확히 일치해야 한다 (default: cwd의 `/`,`.` → `-` 치환; `--project-id=<id>` 로 override). 일치하지 않으면 그 페이지는 해당 project의 MEMORY로 projection되지 **않는다** (silent skip — lint error 아님). 다만 현재 lint scope regex(`^project:[a-z0-9][a-z0-9-]*$`) cwd-derived id 형식(`-Users-...`)을 거부하므로, **`project:*` scope를 사용하려면 slug-safe id로 `--project-id=<slug>`를 override해서 wiki 디렉터리도 id맞추는 운영 패턴이 필요하다**. resolved-id slug 정합화는 v1.3.0 트랙에서 다룸.
71
+ > **`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
72
 
73
73
  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`.
74
74
 
@@ -119,10 +119,37 @@ If you need to share new logic, prefer extending an existing helper over adding
119
119
  ## Testing
120
120
 
121
121
  ```bash
122
- npm test # tests/runner.mjs — unit + smoke + contract tests
123
- npm run lint # scripts/lint.mjs — frontmatter + wikilink validation + W8 (design-history stale vs session-log)
122
+ npm test # tests/runner.mjs — unit + smoke + contract tests
123
+ npm run lint # scripts/lint.mjs — frontmatter + wikilink validation + W8 (design-history stale vs session-log)
124
+ npm run fix:verify # Phase 1 of learned_behavior #6 — verifies fix #N status claims in
125
+ # a wiki spec against `// @fix #N: <test-name>` anchors in
126
+ # tests/runner.mjs. Maintainer dogfood; needs a local wiki at
127
+ # $HYPO_DIR or ~/hypomnema. Does NOT grep ADR core decision lines.
124
128
  ```
125
129
 
130
+ > **`fix:verify` needs an explicit `--spec`.** The default path
131
+ > (`projects/hypomnema/spec-v1.2.md`) is now a `type: reference` redirect stub —
132
+ > the real spec moved to `archive/`. Running the bare command fails with
133
+ > `STUB_SPEC` by design (a stub carries zero status claims, so greening it would
134
+ > be vacuous). Point it at the real spec:
135
+ >
136
+ > ```bash
137
+ > npm run fix:verify -- --spec ~/hypomnema/projects/hypomnema/archive/spec-v1.2.md
138
+ > ```
139
+
140
+ ### `// @fix #N:` anchor convention
141
+
142
+ When a test verifies behavior tied to a numbered fix in the wiki spec, add an anchor immediately above the `suite(...)` or `test(...)` call:
143
+
144
+ ```js
145
+ // @fix #25: replay-compact-guard-detects-slash-clear: /clear with incomplete wiki → WIKI_AUTOCLOSE
146
+ test('replay-compact-guard-detects-slash-clear: /clear with incomplete wiki → WIKI_AUTOCLOSE', () => { ... });
147
+ ```
148
+
149
+ The anchor's body must be the EXACT test name (whole rest of the line; no comma splitting). Multiple anchor lines per fix # accumulate. For fixes whose verification is behavioral / prompt-driven (no automated test by design), use the sentinel `// @fix #N: NO_AUTO_TEST`.
150
+
151
+ `npm run fix:verify` reads these anchors plus the spec status claims and reports any drift (`NO_ANCHOR`, `MISSING_TEST`, `FAILING_TEST`, `ORPHAN_ANCHOR`, `STUB_SPEC`). Plain `// fix #N: …` comments without the `@` prefix are treated as prose and ignored by the verifier.
152
+
126
153
  The test runner uses only Node.js built-ins. Tests create scoped temp directories and clean up after themselves; you can run the suite without any environment setup.
127
154
 
128
155
  When adding a feature, add a corresponding test. For hook-event behavior that's hard to unit-test, document the manual verification step in the PR description (see the manual verification section below).
@@ -139,6 +166,34 @@ Some hook behavior is only observable inside a Claude Code session. Document the
139
166
 
140
167
  ---
141
168
 
169
+ ## Pre-commit auto-format hook
170
+
171
+ `npm install` in this checkout installs a git `pre-commit` hook that runs `prettier --write` on staged files only. The hook is **non-blocking**: formatter failures print a notice but the commit still proceeds. The only block is when `git add` itself fails during restage (true index corruption).
172
+
173
+ **Requirements**: Git ≥ 2.13 (uses `--absolute-git-dir`; `--git-common-dir` is 2.5+).
174
+
175
+ **Path-locked to your checkout.** The shim embeds the absolute paths of your `HYPOMNEMA_ROOT` and `.git/` directory at install time. If you `mv` the checkout, re-run `npm install` to regenerate the shim — until then it safely no-ops.
176
+
177
+ **Main worktree only.** `git worktree add` checkouts silently skip — the shared `.git/hooks/pre-commit` can only point at one embedded root at a time. Commit from the main worktree to get auto-format, or accept the no-op in linked worktrees.
178
+
179
+ **CI is skipped.** `npm ci` runs `prepare`, but the installer detects `CI=true` and exits 0 without touching `.git/hooks/`. CI runs never mutate hooks.
180
+
181
+ **Symlink-safe.** If `.git/hooks/` is a symlink, or an existing `pre-commit` is a symlink, the installer refuses to write through it.
182
+
183
+ **Shared `core.hooksPath` safe.** The shim verifies both `--show-toplevel` and `--absolute-git-dir` against the embedded values before executing. Foreign repos that share your global `core.hooksPath` will silently no-op.
184
+
185
+ **Env-override defense.** The Node side strips every `GIT_*` env from `git rev-parse --local-env-vars` (plus `GIT_NAMESPACE`, `GIT_CEILING_DIRECTORIES`, `GIT_CONFIG_*`) before its own git spawns. Inherited `GIT_INDEX_FILE` is preserved **only** when invoked from the installed shell shim (signalled via a sentinel env var). Direct `node scripts/pre-commit-format.mjs` invocation drops `GIT_INDEX_FILE` and falls back to the default `.git/index`, closing the class of attacks that try to drive the formatter against a crafted alternate index.
186
+
187
+ **Existing non-marker pre-commit?** If you have your own `pre-commit` hook (no `# hypomnema-pre-commit-marker v1` on line 2), the installer logs a notice and never overwrites it.
188
+
189
+ **Skip a single commit**: `git commit --no-verify`. AI agents must not use this without explicit user authorization.
190
+
191
+ **Verbose install logs**: `HYPOMNEMA_HOOK_VERBOSE=1 npm install` prints skip/install reasons to stderr.
192
+
193
+ **Distinction from `hooks/hypo-pre-commit.mjs`**: that file is the git pre-commit worker template that `scripts/init.mjs` installs into `<wiki>/.git/hooks/pre-commit` when a user runs `hypomnema init` in their own wiki repo. It lives in the *user's wiki* repo, not in this package repo. The two hooks never interact.
194
+
195
+ ---
196
+
142
197
  ## Branch and commit conventions
143
198
 
144
199
  - One logical change per branch.
@@ -177,27 +232,57 @@ Hypomnema uses semver. Releases are automated via `release.yml` on `v*` tag push
177
232
 
178
233
  ### Cutting a release
179
234
 
235
+ Every Hypomnema release must carry a Korean summary alongside the English body
236
+ in **both** the CHANGELOG section AND the git tag annotation. The release
237
+ workflow enforces this with `scripts/check-bilingual.mjs`; lightweight tags or
238
+ a missing `### 한글 요약` section will block `npm publish`.
239
+
180
240
  ```bash
181
- # 1. Bump the version (writes package.json + CHANGELOG.md)
182
- node scripts/bump-version.mjs <patch|minor|major>
241
+ # 1. Bump the version across package.json, plugin.json, marketplace.json,
242
+ # and templates/hypo-config.md. Takes a concrete semver (not patch/minor/major).
243
+ node scripts/bump-version.mjs <new-semver> # e.g. 1.2.2 or 1.3.0-rc.1
244
+
245
+ # 2. Edit CHANGELOG.md — the new section MUST include a "### 한글 요약"
246
+ # sub-section with at least 10 Hangul characters of real summary text.
247
+ $EDITOR CHANGELOG.md
183
248
 
184
- # 2. Review the diff and edit CHANGELOG.md if needed
185
- git diff
249
+ # 3. Verify locally before tagging (same check that runs in CI)
250
+ node scripts/check-bilingual.mjs --changelog
186
251
 
187
- # 3. Commit
252
+ # 4. Commit
188
253
  git add package.json CHANGELOG.md
189
254
  git commit -m "chore: release v<version>"
190
255
 
191
- # 4. Tag and push
192
- git tag v<version>
256
+ # 5. Tag with an ANNOTATED tag — never a lightweight tag.
257
+ # Annotation body shape: English summary, then "---" on its own line,
258
+ # then a Korean summary block.
259
+ git tag -a v<version> -m "$(cat <<'EOF'
260
+ Hypomnema v<version> — <one-line English summary>
261
+
262
+ <a few lines of English body — what shipped, links, etc.>
263
+
264
+ ---
265
+
266
+ Hypomnema v<version> — <한 줄 한글 요약>
267
+
268
+ <몇 줄의 한글 요약 본문.>
269
+ EOF
270
+ )"
271
+
272
+ # 6. Verify the tag annotation locally (same check that runs in CI)
273
+ node scripts/check-bilingual.mjs --tag v<version>
274
+
275
+ # 7. Push
193
276
  git push origin main --tags
194
277
  ```
195
278
 
196
279
  The `release.yml` workflow then:
197
280
 
198
281
  1. Verifies the tag matches `package.json` version.
199
- 2. Runs `npm test` and `npm run lint`.
200
- 3. Publishes to npm with `npm publish --access public --provenance`.
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`.
201
286
 
202
287
  `NPM_TOKEN` must be set as a repository secret.
203
288
 
@@ -43,9 +43,9 @@ if (staged) {
43
43
  }
44
44
 
45
45
  if (hasRemote()) {
46
- // fix #9: pull/push failures must not stop the session, but they can no
47
- // longer be swallowed silently — record each to .cache/sync-state.json so
48
- // session-start (#10) and doctor (#11) can surface them next session.
46
+ // pull/push failures must not stop the session, but they can no longer be
47
+ // swallowed silently — record each to .cache/sync-state.json so session-start
48
+ // and doctor can surface them next session.
49
49
  const pull = git('pull', '--no-rebase', '-q');
50
50
  if (pull.status !== 0) appendSyncFailure(HYPO_DIR, 'pull', pull.stderr || pull.stdout);
51
51
  const push = git('push');
@@ -54,12 +54,17 @@ function emitContinue() {
54
54
  console.log(JSON.stringify({ continue: true, suppressOutput: true }));
55
55
  }
56
56
 
57
- function emitBlock(sessionId) {
57
+ function emitBlock(sessionId, transcriptPath) {
58
58
  // One-line, skill-first. /hypo:crystallize is the documented session-close
59
59
  // alias; passing --session-id there writes the per-session marker that clears
60
60
  // this block. CLI fallback + bypass live in commands/crystallize.md, not here
61
61
  // — keep the Stop reason terse so the actionable instruction stands out.
62
- const reason = `[WIKI_AUTOCLOSE] session-close 미완료 /hypo:crystallize 실행으로 마무리 (session_id=${sessionId}).`;
62
+ // Surface the transcript path so the close can pass --transcript-path=<path>,
63
+ // which scopes the marker's lint gate to this session's own files (Bug A
64
+ // coherence: a marker written without lint would only let Stop pass for
65
+ // /compact to immediately re-block on the same errors).
66
+ const transcriptHint = transcriptPath ? ` --transcript-path=${transcriptPath}` : '';
67
+ const reason = `[WIKI_AUTOCLOSE] session-close 미완료 — /hypo:crystallize 실행으로 마무리 (session_id=${sessionId}${transcriptHint}).`;
63
68
  console.log(
64
69
  JSON.stringify({
65
70
  decision: 'block',
@@ -136,7 +141,7 @@ process.stdin.on('end', () => {
136
141
  return;
137
142
  }
138
143
 
139
- emitBlock(sessionId);
144
+ emitBlock(sessionId, transcriptPath);
140
145
  } catch (err) {
141
146
  // Fail-open on any unexpected error.
142
147
  process.stderr.write(`[hypo-auto-minimal-crystallize] error: ${err?.message ?? String(err)}\n`);
@@ -99,11 +99,11 @@ process.stdin.on('end', () => {
99
99
  if (newHit) {
100
100
  const fromFile = readIfNotIgnored(newHit.hotPath, ignorePatterns);
101
101
  const content = fromFile ?? '(no hot.md yet — will be created at session close)';
102
- // fix #13: arm the first-prompt marker so the NEXT user prompt re-triggers
102
+ // arm the first-prompt marker so the NEXT user prompt re-triggers
103
103
  // hypo-first-prompt, which forces a "Resuming <project>" summary line.
104
104
  // Only arm when real hot content was actually injected — if hot.md is
105
105
  // missing or .hypoignore'd (fromFile null), there is nothing for the LLM
106
- // to summarize, so forcing "Resuming" would be empty noise (codex review).
106
+ // to summarize, so forcing "Resuming" would be empty noise.
107
107
  if (fromFile) {
108
108
  try {
109
109
  writeFileSync(
@@ -49,7 +49,7 @@ process.stdin.on('end', () => {
49
49
 
50
50
  const hasSnapshot = marker.hasSnapshot ?? (marker.hotPath && existsSync(marker.hotPath));
51
51
  const snapshotNote = hasSnapshot ? '' : ' (no snapshot yet — first session)';
52
- // fix #13: a cwd-change re-trigger says "Resuming"; a fresh session start
52
+ // a cwd-change re-trigger says "Resuming"; a fresh session start
53
53
  // (default source) says "Previously working on".
54
54
  const verb = marker.source === 'cwd-change' ? 'Resuming' : 'Previously working on';
55
55
  // marker.proj originates from a wiki directory name read by findProjectFiles;
@@ -33,6 +33,9 @@ import {
33
33
  isGateSkipped,
34
34
  isClosePattern,
35
35
  extractUserMessages,
36
+ extractTouchedWikiFiles,
37
+ closeFileTargets,
38
+ partitionLintScope,
36
39
  } from './hypo-shared.mjs';
37
40
 
38
41
  const WARNING_FILE = join(homedir(), '.claude', 'state', 'wiki-context-warning.json');
@@ -92,7 +95,7 @@ process.stdin.on('end', () => {
92
95
 
93
96
  const gitStatus = hypoIsClean();
94
97
  const hotStatus = hotMdIsClean();
95
- // fix #17: strict session-close (steps 1~6 of the 11-step crystallize
98
+ // strict session-close (steps 1~6 of the 11-step crystallize
96
99
  // checklist). closeFiles gates the 5 mandatory files (steps 1-4 + log.md);
97
100
  // open-questions.md (step 5) is conditional ("변경 시") and intentionally
98
101
  // ungated — see hypo-shared.mjs sessionCloseFileStatus and spec §5.2.7.
@@ -107,6 +110,7 @@ process.stdin.on('end', () => {
107
110
  const lintPath = PKG_ROOT ? join(PKG_ROOT, 'scripts', 'lint.mjs') : null;
108
111
  let lintBlockers = [];
109
112
  let lintW8 = [];
113
+ let lintNotices = []; // pre-existing debt in files this session did not touch
110
114
  let lintSkipped = false;
111
115
  if (!lintPath || !existsSync(lintPath)) {
112
116
  lintSkipped = true;
@@ -118,8 +122,35 @@ process.stdin.on('end', () => {
118
122
  timeout: 30000,
119
123
  });
120
124
  const parsed = JSON.parse(r.stdout || '{}');
121
- lintBlockers = parsed.errors || [];
122
- lintW8 = (parsed.warns || []).filter((w) => w.id === 'W8');
125
+ const allErrors = parsed.errors || [];
126
+ const allW8 = (parsed.warns || []).filter((w) => w.id === 'W8');
127
+ // Bug B: judge this session on the files IT touched, not the whole vault.
128
+ // A readable transcript lets us scope (edited files ∪ mandatory close
129
+ // files); a missing/unreadable transcript falls back to the conservative
130
+ // global gate (never weaker than before).
131
+ const haveTranscript = !!(transcriptPath && existsSync(transcriptPath));
132
+ if (haveTranscript) {
133
+ const scope = new Set([
134
+ ...extractTouchedWikiFiles(transcriptPath, HYPO_DIR),
135
+ ...closeFileTargets(HYPO_DIR),
136
+ ]);
137
+ const part = partitionLintScope(allErrors, scope);
138
+ lintBlockers = part.blocking;
139
+ lintNotices = part.notice;
140
+ // W8 (design-history stale) is the CURRENT project's close
141
+ // responsibility, not cross-project debt — block on the active
142
+ // project's, surface others' as notices.
143
+ if (closeFiles.project) {
144
+ const mine = `projects/${closeFiles.project}/design-history.md`;
145
+ lintW8 = allW8.filter((w) => w.file === mine);
146
+ lintNotices.push(...allW8.filter((w) => w.file !== mine));
147
+ } else {
148
+ lintW8 = allW8;
149
+ }
150
+ } else {
151
+ lintBlockers = allErrors;
152
+ lintW8 = allW8;
153
+ }
123
154
  } catch (err) {
124
155
  /* fail-open */
125
156
  process.stderr.write(`[hypo-personal-check] error: ${err?.message ?? String(err)}\n`);
@@ -128,6 +159,16 @@ process.stdin.on('end', () => {
128
159
 
129
160
  const lintOk = lintBlockers.length === 0;
130
161
  const designHistoryOk = lintW8.length === 0;
162
+ // Non-blocking heads-up about pre-existing lint debt in untouched files (other
163
+ // projects / shared pages). Surfaced so it is visible but never blocks compact.
164
+ const noticeText =
165
+ lintNotices.length > 0
166
+ ? `[WIKI CHECK] ${lintNotices.length} pre-existing lint issue(s) in files this session did not touch (not blocking): ${[
167
+ ...new Set(lintNotices.map((b) => b.file)),
168
+ ]
169
+ .slice(0, 5)
170
+ .join(', ')}${lintNotices.length > 5 ? ', …' : ''} — clean up when convenient.`
171
+ : '';
131
172
 
132
173
  // ── fix #37 Phase C: feedback projection drift (ADR 0031) ──
133
174
  // Single blocking gate invariant (spec §7.5): integrate into THIS hook, never
@@ -169,8 +210,8 @@ process.stdin.on('end', () => {
169
210
  // ONLY when some target has a genuine, actionable issue (drift,
170
211
  // conflict, over-cap, or a malformed managed region). buildError is
171
212
  // never actionable here, so any mix that lacks a real issue fails open
172
- // — including memory:clean + claude:buildError (codex review: the prior
173
- // `every(buildError)` predicate wrongly blocked that case). Mirrors
213
+ // — including memory:clean + claude:buildError, where the prior
214
+ // `every(buildError)` predicate wrongly blocked that case. Mirrors
174
215
  // doctor's buildError→warn (non-fatal) handling.
175
216
  let report = null;
176
217
  try {
@@ -210,7 +251,13 @@ process.stdin.on('end', () => {
210
251
  closeFiles.ok &&
211
252
  feedbackOk
212
253
  ) {
213
- console.log(JSON.stringify({ continue: true, suppressOutput: true }));
254
+ console.log(
255
+ JSON.stringify(
256
+ noticeText
257
+ ? { continue: true, systemMessage: noticeText }
258
+ : { continue: true, suppressOutput: true },
259
+ ),
260
+ );
214
261
  return;
215
262
  }
216
263
 
@@ -267,7 +314,9 @@ process.stdin.on('end', () => {
267
314
  ` [ ] 9. hot.md — update projects/<name>/hot.md (no exceptions)`,
268
315
  ` [ ] 10. root hot.md — update ~/hypomnema/hot.md active project table`,
269
316
  ` [ ] 11. updated: field — verify today's date on all touched .md files`,
270
- ` [ ] 12. git commit & push`,
317
+ ` [ ] 12. lint run scripts/lint.mjs; fix errors in files YOU touched`,
318
+ ` (other projects' / shared-page debt is reported as non-blocking notice)`,
319
+ ` [ ] 13. git commit & push`,
271
320
  ].join('\n');
272
321
 
273
322
  const closeIntentNote = hasCloseIntent
@@ -282,6 +331,7 @@ process.stdin.on('end', () => {
282
331
  `Run the checklist below in order, then retry /compact:`,
283
332
  ``,
284
333
  checklistText,
334
+ ...(noticeText ? ['', noticeText] : []),
285
335
  ``,
286
336
  `Trivial session? Bypass with HYPO_SKIP_GATE=1`,
287
337
  ].join('\n'),
@@ -37,6 +37,10 @@ import {
37
37
  computeNotice,
38
38
  markNotified,
39
39
  isOptedOut,
40
+ resolveCliOnPath,
41
+ computeSiblingNotice,
42
+ siblingAlreadyNotified,
43
+ markSiblingNotified,
40
44
  } from './version-check.mjs';
41
45
 
42
46
  // Privacy guard: refuse to read+inject .hypoignore-matched
@@ -119,6 +123,45 @@ function buildUpdateNotice() {
119
123
  }
120
124
  }
121
125
 
126
+ /**
127
+ * Stale-sibling notice (ADR 0038, D3). The update-notifier above only knows
128
+ * whether the ACTIVE install is behind latest — it is blind to an OLDER sibling
129
+ * that owns the `hypomnema` bin on PATH. That sibling is the live footgun:
130
+ * running `hypomnema init`/`upgrade` through it downgrades the active hooks.
131
+ *
132
+ * This is the ONLY surface that reaches a user already in that state, because it
133
+ * runs from the (newer) active hook — `doctor` invoked via the stale CLI would
134
+ * run the stale doctor. fs-only (no npm/which spawn). Throttled via the cache so
135
+ * it nags once per (cliPath@cliVersion → activeVersion) tuple. Best-effort.
136
+ */
137
+ function buildSiblingNotice() {
138
+ try {
139
+ if (isOptedOut()) return '';
140
+ // Active install identity = hypo-pkg.json (what init/upgrade write). This is
141
+ // the authoritative pkgRoot+version; ACTIVE_ROOT (~/.claude) has no package.json.
142
+ let active = null;
143
+ try {
144
+ active = JSON.parse(readFileSync(join(homedir(), '.claude', 'hypo-pkg.json'), 'utf-8'));
145
+ } catch {
146
+ return ''; // no active metadata → nothing to compare a sibling against
147
+ }
148
+ if (!active || !active.pkgVersion) return '';
149
+ const cli = resolveCliOnPath('hypomnema');
150
+ const notice = computeSiblingNotice(cli, {
151
+ pkgRoot: active.pkgRoot,
152
+ version: active.pkgVersion,
153
+ });
154
+ if (!notice) return '';
155
+ const cachePath = defaultCachePath();
156
+ const cache = readCache(cachePath);
157
+ if (siblingAlreadyNotified(cache, notice.key)) return '';
158
+ markSiblingNotified(cachePath, notice.key);
159
+ return notice.line;
160
+ } catch {
161
+ return '';
162
+ }
163
+ }
164
+
122
165
  const PROJECTS_DIR = join(HYPO_DIR, 'projects');
123
166
  const GROWTH_CACHE = join(HYPO_DIR, '.cache', 'last-session-growth.json');
124
167
 
@@ -170,7 +213,7 @@ function gitPull(dir) {
170
213
 
171
214
  /**
172
215
  * fix #10: surface unresolved sync failures recorded by a prior session's
173
- * Stop hook (#9). The entry is cleared only once this session's pull has
216
+ * Stop hook (fix #9). The entry is cleared only once this session's pull has
174
217
  * succeeded AND there is no unpushed commit left behind by a failed push
175
218
  * (`[ahead N]`).
176
219
  *
@@ -289,23 +332,27 @@ process.stdin.on('end', () => {
289
332
  const pullOk = gitPull(HYPO_DIR);
290
333
  const syncLine = syncStateNotice(pullOk);
291
334
  const growthLine = readLastGrowthLine();
292
- // fix #25 PR-A2 (ADR 0022 amendment): on source='clear', surface the dying
335
+ // ADR 0022 amendment: on source='clear', surface the dying
293
336
  // session's identity that hypo-session-end stashed so Claude can recover
294
337
  // session-close work that /clear skipped. One-shot: marker is unlinked
295
338
  // immediately after read.
296
339
  const clearRecoveryLine = buildClearRecoveryLine(data.source);
297
340
  const updateLine = buildUpdateNotice();
341
+ const siblingLine = buildSiblingNotice();
298
342
  // Intentional dual emit: stderr (yellow/cyan) is the human-visible nudge in
299
343
  // the terminal; noticePrefix injects the same plain-text lines into the
300
344
  // LLM's additionalContext so model and user start the session looking at
301
345
  // the same state. ANSI escapes are kept out of additionalContext on purpose.
302
- const notices = [syncLine, growthLine, clearRecoveryLine, updateLine].filter(Boolean);
346
+ const notices = [syncLine, growthLine, clearRecoveryLine, updateLine, siblingLine].filter(
347
+ Boolean,
348
+ );
303
349
  let noticePrefix = notices.length ? `${notices.join('\n\n')}\n\n` : '';
304
350
  if (syncLine) process.stderr.write(`\n\x1b[33m${syncLine}\x1b[0m\n`);
305
351
  if (growthLine) process.stderr.write(`\n\x1b[36m${growthLine}\x1b[0m\n`);
306
352
  if (clearRecoveryLine)
307
353
  process.stderr.write(`\n\x1b[33m${clearRecoveryLine.split('\n')[0]}\x1b[0m\n`);
308
354
  if (updateLine) process.stderr.write(`\n\x1b[33m${updateLine}\x1b[0m\n`);
355
+ if (siblingLine) process.stderr.write(`\n\x1b[33m${siblingLine}\x1b[0m\n`);
309
356
  const cwd = data.cwd || data.directory || process.cwd();
310
357
  const sessionId = data.session_id || 'default';
311
358
  const MARKER_FILE = sessionMarkerPath(sessionId);
@@ -389,7 +436,7 @@ process.stdin.on('end', () => {
389
436
  if (!globalContent) {
390
437
  // GLOBAL_HOT exists but is empty or .hypoignore'd — still surface any
391
438
  // pending notices (sync state, growth, AND the auto-project offer), which
392
- // would otherwise be silently dropped here (codex review 2026-05-22).
439
+ // would otherwise be silently dropped here.
393
440
  const notice = notices.join('\n\n');
394
441
  if (notice) {
395
442
  console.log(JSON.stringify(buildOutput(notice, { continue: true, suppressOutput: true })));