hypomnema 1.4.2 → 1.5.1

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 (70) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.ko.md +35 -18
  4. package/README.md +25 -8
  5. package/commands/audit.md +2 -2
  6. package/commands/crystallize.md +22 -14
  7. package/commands/doctor.md +1 -1
  8. package/commands/feedback.md +2 -2
  9. package/commands/graph.md +1 -1
  10. package/commands/ingest.md +1 -1
  11. package/commands/init.md +2 -2
  12. package/commands/lint.md +1 -1
  13. package/commands/query.md +1 -1
  14. package/commands/rename.md +1 -1
  15. package/commands/resume.md +1 -1
  16. package/commands/stats.md +1 -1
  17. package/commands/uninstall.md +4 -2
  18. package/commands/upgrade.md +1 -1
  19. package/commands/verify.md +1 -1
  20. package/docs/ARCHITECTURE.md +4 -4
  21. package/docs/CONTRIBUTING.md +8 -7
  22. package/hooks/hypo-auto-commit.mjs +2 -2
  23. package/hooks/hypo-auto-minimal-crystallize.mjs +7 -7
  24. package/hooks/hypo-compact-guard.mjs +2 -2
  25. package/hooks/hypo-cwd-change.mjs +27 -37
  26. package/hooks/hypo-lookup.mjs +37 -2
  27. package/hooks/hypo-personal-check.mjs +37 -22
  28. package/hooks/hypo-session-end.mjs +1 -1
  29. package/hooks/hypo-session-record.mjs +2 -2
  30. package/hooks/hypo-session-start.mjs +62 -42
  31. package/hooks/hypo-shared.mjs +430 -85
  32. package/hooks/version-check.mjs +1 -1
  33. package/package.json +5 -1
  34. package/scripts/check-tracker-ids.mjs +69 -31
  35. package/scripts/crystallize.mjs +151 -53
  36. package/scripts/doctor.mjs +7 -7
  37. package/scripts/feedback-sync.mjs +5 -5
  38. package/scripts/feedback.mjs +9 -10
  39. package/scripts/init.mjs +7 -7
  40. package/scripts/lib/check-tracker-ids.mjs +90 -32
  41. package/scripts/lib/design-history-stale.mjs +1 -1
  42. package/scripts/lib/extensions.mjs +7 -7
  43. package/scripts/lib/failure-type.mjs +1 -1
  44. package/scripts/lib/feedback-scope.mjs +1 -1
  45. package/scripts/lib/page-usage.mjs +141 -0
  46. package/scripts/lib/project-create.mjs +2 -2
  47. package/scripts/lib/template-schema-version.mjs +1 -1
  48. package/scripts/lib/wd-match.mjs +181 -0
  49. package/scripts/lib/wikilink.mjs +1 -1
  50. package/scripts/lint.mjs +5 -5
  51. package/scripts/rename.mjs +1 -1
  52. package/scripts/resume.mjs +20 -22
  53. package/scripts/session-audit.mjs +1 -1
  54. package/scripts/stats.mjs +3 -3
  55. package/scripts/uninstall.mjs +3 -3
  56. package/scripts/upgrade.mjs +85 -18
  57. package/skills/crystallize/SKILL.md +17 -11
  58. package/skills/graph/SKILL.md +3 -3
  59. package/skills/ingest/SKILL.md +5 -5
  60. package/skills/lint/SKILL.md +3 -3
  61. package/skills/query/SKILL.md +3 -3
  62. package/skills/verify/SKILL.md +3 -3
  63. package/templates/SCHEMA.md +1 -1
  64. package/templates/hypo-config.md +1 -1
  65. package/templates/hypo-guide.md +21 -15
  66. package/templates/projects/_template/hot.md +1 -1
  67. package/scripts/fix-status-verify.mjs +0 -256
  68. package/scripts/lib/adr-corpus.mjs +0 -79
  69. package/scripts/lib/fix-manifest.mjs +0 -109
  70. package/scripts/lib/fix-status-verify.mjs +0 -439
package/commands/stats.md CHANGED
@@ -15,7 +15,7 @@ You are running `/hypo:stats`. Display a summary of wiki health and activity.
15
15
 
16
16
  ## Step 1 — Run script
17
17
 
18
- The script path below resolves via `${CLAUDE_PLUGIN_ROOT}`, which the plugin harness expands to this package's absolute path before you see it, so run it as written. If it appears unexpanded (a literal `${CLAUDE_PLUGIN_ROOT}`), read the package root from the `hypo@hypomnema` installPath in `~/.claude/plugins/installed_plugins.json` rather than guessing from the cache layout.
18
+ Bundled scripts here run via `${CLAUDE_PLUGIN_ROOT}/scripts/`. To resolve that package root: if `${CLAUDE_PLUGIN_ROOT}` is already an absolute path, use it; otherwise read `pkgRoot` from `~/.claude/hypo-pkg.json` (only when non-empty and the target script exists under it); otherwise use the `hypo@hypomnema` (or legacy `hypomnema@hypomnema`) installPath in `~/.claude/plugins/installed_plugins.json`; if none resolve, stop and tell the user to run `hypomnema upgrade --apply` or reinstall instead of guessing the cache layout.
19
19
 
20
20
  If the user specified a Hypomnema directory, pass it as `--hypo-dir="<path>"`. Otherwise omit the flag.
21
21
 
@@ -20,13 +20,15 @@ Say:
20
20
 
21
21
  Default: yes (dry-run first)
22
22
 
23
+ Bundled scripts here run via `${CLAUDE_PLUGIN_ROOT}/scripts/`. To resolve that package root: if `${CLAUDE_PLUGIN_ROOT}` is already an absolute path, use it; otherwise read `pkgRoot` from `~/.claude/hypo-pkg.json` (only when non-empty and the target script exists under it); otherwise use the `hypo@hypomnema` (or legacy `hypomnema@hypomnema`) installPath in `~/.claude/plugins/installed_plugins.json`; if none resolve, stop and tell the user to run `hypomnema upgrade --apply` or reinstall instead of guessing the cache layout.
24
+
23
25
  ---
24
26
 
25
27
  ## Step 2 — Dry run
26
28
 
27
29
  Run:
28
30
  ```
29
- node scripts/uninstall.mjs
31
+ node ${CLAUDE_PLUGIN_ROOT}/scripts/uninstall.mjs
30
32
  ```
31
33
 
32
34
  Show the output to the user. Ask:
@@ -39,7 +41,7 @@ If no → abort and confirm nothing was changed.
39
41
  ## Step 3 — Apply (if confirmed)
40
42
 
41
43
  ```
42
- node scripts/uninstall.mjs --apply
44
+ node ${CLAUDE_PLUGIN_ROOT}/scripts/uninstall.mjs --apply
43
45
  ```
44
46
 
45
47
  If the user also wants Codex hooks removed, append `--codex`.
@@ -14,7 +14,7 @@ You are running `/hypo:upgrade`. Check if the installed Hypomnema wiki is out of
14
14
 
15
15
  ## Step 1 — Run script
16
16
 
17
- The script path below resolves via `${CLAUDE_PLUGIN_ROOT}`, which the plugin harness expands to this package's absolute path before you see it, so run it as written. If it appears unexpanded (a literal `${CLAUDE_PLUGIN_ROOT}`), read the package root from the `hypo@hypomnema` installPath in `~/.claude/plugins/installed_plugins.json` rather than guessing from the cache layout.
17
+ Bundled scripts here run via `${CLAUDE_PLUGIN_ROOT}/scripts/`. To resolve that package root: if `${CLAUDE_PLUGIN_ROOT}` is already an absolute path, use it; otherwise read `pkgRoot` from `~/.claude/hypo-pkg.json` (only when non-empty and the target script exists under it); otherwise use the `hypo@hypomnema` (or legacy `hypomnema@hypomnema`) installPath in `~/.claude/plugins/installed_plugins.json`; if none resolve, stop and tell the user to run `hypomnema upgrade --apply` or reinstall instead of guessing the cache layout.
18
18
 
19
19
  If the user specified a Hypomnema directory, pass it as `--hypo-dir="<path>"`. Otherwise omit the flag.
20
20
 
@@ -13,7 +13,7 @@ You are running `/hypo:verify`. Audit wiki pages for overdue or missing `verify_
13
13
 
14
14
  ## Step 1 — Run
15
15
 
16
- The script path below resolves via `${CLAUDE_PLUGIN_ROOT}`, which the plugin harness expands to this package's absolute path before you see it, so run it as written. If it appears unexpanded (a literal `${CLAUDE_PLUGIN_ROOT}`), read the package root from the `hypo@hypomnema` installPath in `~/.claude/plugins/installed_plugins.json` rather than guessing from the cache layout.
16
+ Bundled scripts here run via `${CLAUDE_PLUGIN_ROOT}/scripts/`. To resolve that package root: if `${CLAUDE_PLUGIN_ROOT}` is already an absolute path, use it; otherwise read `pkgRoot` from `~/.claude/hypo-pkg.json` (only when non-empty and the target script exists under it); otherwise use the `hypo@hypomnema` (or legacy `hypomnema@hypomnema`) installPath in `~/.claude/plugins/installed_plugins.json`; if none resolve, stop and tell the user to run `hypomnema upgrade --apply` or reinstall instead of guessing the cache layout.
17
17
 
18
18
  ```bash
19
19
  node ${CLAUDE_PLUGIN_ROOT}/scripts/verify.mjs [--hypo-dir="<path>"] [--file=<path>]
@@ -110,7 +110,7 @@ Hooks run automatically at Claude Code lifecycle events. They are deployed to `~
110
110
  |---|---|
111
111
  | `hypo-session-start` | Inject `index.md`, root `hot.md`, project `hot.md`/`session-state.md`. Run `git pull --ff-only` (silent fail on missing remote) |
112
112
  | `hypo-first-prompt` | Marker-based one-shot `hot.md` injection on first user prompt (10-min TTL) — for sessions that bypass `SessionStart` |
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` |
113
+ | `hypo-lookup` | BM25 search over the wiki on every prompt. **HIT** → inject top-3 page snippets (≤2000 chars each; a page whose `verify_by_date` is overdue gets a `[STALE verify_by_date=…]` marker prepended). **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
116
  | `hypo-auto-stage` | After Write/Edit on a wiki path, run `git add` (skips paths matching `.hypoignore`) |
@@ -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, 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.
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` 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,7 +366,7 @@ 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.
369
+ **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
370
 
371
371
  ### Growth metrics (Lane B)
372
372
 
@@ -379,7 +379,7 @@ If `git status` shows no `.md` changes, the diff step is skipped — Stop hook f
379
379
 
380
380
  ### Citation convention
381
381
 
382
- 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.
382
+ 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]]`, which keeps them connected in the graph. The observability audit does not scan for these inline citations: it scores sessions on tool and command usage recorded by `hypo-session-record` (search / ingest / feedback counts). Counting citations as a signal is a possible future iteration, not current behavior.
383
383
 
384
384
  ---
385
385
 
@@ -124,7 +124,8 @@ npm run lint # scripts/lint.mjs — frontmatter + wikilink validation + W8
124
124
  npm run fix:verify # Phase 1 of learned_behavior #6 — verifies fix #N status claims in
125
125
  # a wiki spec against `// @fix #N: <test-name>` anchors in
126
126
  # tests/runner.mjs. Maintainer dogfood; needs a local wiki at
127
- # $HYPO_DIR or ~/hypomnema. Does NOT grep ADR core decision lines.
127
+ # $HYPO_DIR or ~/hypomnema (source checkout only; not shipped in
128
+ # the npm package). Does NOT grep ADR core decision lines.
128
129
  ```
129
130
 
130
131
  > **`fix:verify` needs an explicit `--spec`.** The default path
@@ -242,7 +243,7 @@ After both blocks, language-neutral:
242
243
 
243
244
  - **PR title**: Conventional Commits plus a scope, e.g. `feat(feedback): add failure_type enum`. The type drives the CHANGELOG section (see the classification table below).
244
245
  - **Merge commit**: the squash-merge subject carries the PR number (`#123`). That is where `#N` comes from, not the PR title. The two conventions stay separate.
245
- - Internal tracker ids (`FEAT-`, `IMPR-`, `ISSUE-`, `PRAC-`) may appear in your local notes and in source or workflow comments (an internal reference, like an ADR anchor), but never on the published changelog and release surface: not in the CHANGELOG body, not in the PR `## Changelog` block, not in a tag annotation, not in a GitHub Release. The only identifier that ships in those is the PR number `#N`. `check-tracker-ids --tag` gates the tag body; the migration keeps the CHANGELOG body clean.
246
+ - Internal tracker ids (`FEAT-`, `IMPR-`, `ISSUE-`, `PRAC-`) may appear in your local notes and in `tests/` / `qa-runs/` (where they aid test-to-issue traceability and never reach an installed user), but not in shipped code or workflow comments, and never on the published changelog and release surface: not in the CHANGELOG body, not in the PR `## Changelog` block, not in a tag annotation, not in a GitHub Release. The only identifier that ships in those is the PR number `#N`. `check-tracker-ids` gates every surface (`--all`/`--staged` for files, `--commit-msg` for messages, `--tag` for the tag body); the migration keeps the CHANGELOG body clean.
246
247
 
247
248
  ### The `## Changelog` block
248
249
 
@@ -272,9 +273,9 @@ At and after the v1.2.0 cutoff, each non-empty gated section (`New Features`, `B
272
273
  ```
273
274
  ### New Features
274
275
  #### English
275
- - Feedback pages accept an optional `failure_type`. (#141)
276
+ - Feedback pages accept an optional `failure_type`. ([#141](https://github.com/sk-lim19f/Hypomnema/pull/141))
276
277
  #### 한국어
277
- - 피드백 페이지가 선택적 `failure_type`를 받습니다. (#141)
278
+ - 피드백 페이지가 선택적 `failure_type`를 받습니다. ([#141](https://github.com/sk-lim19f/Hypomnema/pull/141))
278
279
  ```
279
280
 
280
281
  Versions below the v1.2.0 cutoff (1.0.0, 1.0.1, 1.1.0) are English-only: the English body sits directly under the section header with no `####` sub-blocks. Korean is not back-filled for those releases.
@@ -283,12 +284,12 @@ The `### Changelog` index is language-neutral, one merged PR per line:
283
284
 
284
285
  ```
285
286
  ### Changelog
286
- - #141 feedback failure_type classification
287
- - #140 invalid-YAML lint guard
287
+ - [#141](https://github.com/sk-lim19f/Hypomnema/pull/141) feedback failure_type classification
288
+ - [#140](https://github.com/sk-lim19f/Hypomnema/pull/140) invalid-YAML lint guard
288
289
  Contributors: @handle
289
290
  ```
290
291
 
291
- Each line is `- #N <short title>`, no em dash. The `Contributors:` line lists that version's PR authors, de-duplicated; the release collector fills the handles from the GitHub API.
292
+ Each line is `- [#N](<pr-url>) <short title>`, no em dash: the collector links each `#N` to its GitHub PR (a repo markdown file does not autolink a bare `#N`), deriving the URL from the `repository` field in `package.json`. The `Contributors:` line lists that version's PR authors, de-duplicated; the release collector fills the handles from the GitHub API.
292
293
 
293
294
  A `### Known Issues` or `### Notes` block, when a version has one, is a trailing note (a caveat, not a change). It is not one of the gated sections, so it carries no `####` sub-blocks even at or after the cutoff; bilingual text there is recommended, not enforced.
294
295
 
@@ -14,7 +14,7 @@ function hasRemote() {
14
14
  }
15
15
 
16
16
  // Stage + commit via the shared helper (same .hypoignore filter the apply path
17
- // uses — ADR 0056). A real commit failure short-circuits before sync, exactly as
17
+ // uses). A real commit failure short-circuits before sync, exactly as
18
18
  // the inline logic did; "nothing to commit" is success and falls through to sync.
19
19
  const result = commitWikiChanges(HYPO_DIR);
20
20
  if (!result.committed) {
@@ -26,7 +26,7 @@ if (hasRemote()) {
26
26
  // pull/push failures must not stop the session, but they can no longer be
27
27
  // swallowed silently — syncRemote records each to .cache/sync-state.json and,
28
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.
29
+ // (part of the v1.4 sync hardening). session-start + doctor surface the result next session.
30
30
  syncRemote(HYPO_DIR);
31
31
  }
32
32
 
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * hypo-auto-minimal-crystallize.mjs — Stop hook (ADR 0022 Layer 3)
3
+ * hypo-auto-minimal-crystallize.mjs — Stop hook (Layer 3)
4
4
  *
5
5
  * Last hook in the Stop chain: a final-line defense that blocks `Stop` when
6
6
  * the current session did substantial work (mutation, or a high-volume
@@ -8,7 +8,7 @@
8
8
  * session-close. Forces Claude to run minimal session-close before the
9
9
  * conversation context evaporates.
10
10
  *
11
- * Decision flow (see ADR 0022 amendment 2026-05-19 Q1+Q2 + 2nd amendment Q-close-gate):
11
+ * Decision flow (see amendment 2026-05-19 Q1+Q2 + 2nd amendment Q-close-gate):
12
12
  *
13
13
  * 1. stop_hook_active === true → continue (loop guard; PoC 2026-05-14)
14
14
  * 2. wiki absent → continue (fail-open)
@@ -24,7 +24,7 @@
24
24
  * Close-intent gate (added after PR-C dogfooding revealed every-turn block —
25
25
  * codex 2-worker debate 2026-05-19, both REQUEST_CHANGES). Stop fires after
26
26
  * EVERY assistant turn, not at session end; blocking on "mutation + no marker"
27
- * alone nags the user on every turn of a long mutating session. ADR 0022's
27
+ * alone nags the user on every turn of a long mutating session. The hook's
28
28
  * real intent is "block when the session is ENDING and close is incomplete".
29
29
  * We approximate the end signal by reusing isClosePattern() over recent
30
30
  * user-message text (the same low-false-positive signal PreCompact uses):
@@ -82,11 +82,11 @@ function emitBlock(sessionId, transcriptPath, gate = null) {
82
82
  // The log-only escape hatch for a non-project (wiki/tooling-only)
83
83
  // session. Offered ONLY as an explicit alternative when a close blocker is
84
84
  // present — never as the default recovery, so a real project session is not
85
- // taught to bypass the ADR 0043 close invariant (codex design Finding 3).
85
+ // taught to bypass the close invariant (codex design Finding 3).
86
86
  const logOnlyCmd = cliBase
87
87
  ? `${cliBase} --mark-session-closed --log-only --session-id=${sessionId}${transcriptHint}`
88
88
  : `/hypo:crystallize --log-only (session_id=${sessionId}${transcriptHint})`;
89
- // ADR 0047: refine the message with the read-only /compact gate result.
89
+ // Refine the message with the read-only /compact gate result.
90
90
  // - gate green → the close is compact-ready and ONLY the marker is missing
91
91
  // (the hand-edit close case: files Written + committed directly, bypassing
92
92
  // the marker writer). Say so precisely + give the one command, instead of
@@ -112,7 +112,7 @@ function emitBlock(sessionId, transcriptPath, gate = null) {
112
112
  JSON.stringify({
113
113
  decision: 'block',
114
114
  reason,
115
- stopReason: 'session-close incomplete (ADR 0022 Layer 3)',
115
+ stopReason: 'session-close incomplete (Layer 3)',
116
116
  }),
117
117
  );
118
118
  }
@@ -186,7 +186,7 @@ process.stdin.on('end', () => {
186
186
  return;
187
187
  }
188
188
 
189
- // ADR 0047: read-only /compact gate (same precompactGateStatus the real
189
+ // Read-only /compact gate (same precompactGateStatus the real
190
190
  // PreCompact hook uses) sharpens the block message — distinguishes "close
191
191
  // is compact-ready, only the marker is missing" from "there are real
192
192
  // blockers". The hook NEVER writes the marker here (file-header invariant);
@@ -2,7 +2,7 @@
2
2
  /**
3
3
  * hypo-compact-guard.mjs — UserPromptSubmit hook
4
4
  *
5
- * Scope: detects "/compact" or "/clear" typed in chat only (ADR 0022 Layer 2).
5
+ * Scope: detects "/compact" or "/clear" typed in chat only (Layer 2).
6
6
  * The CLI built-in /compact does NOT fire UserPromptSubmit — use personal-wiki-check.mjs
7
7
  * (PreCompact hook) as the hard gate for that path. /clear has no PreCompact event, so
8
8
  * this hook is the only chat-side gate that can prompt session-close before context wipe.
@@ -42,7 +42,7 @@ process.stdin.on('end', () => {
42
42
  const gitStatus = hypoIsClean();
43
43
  const hotStatus = hotMdIsClean();
44
44
 
45
- // ADR 0056: block on uncommitted (real unsaved work); unpushed commits (ahead)
45
+ // Block on uncommitted (real unsaved work); unpushed commits (ahead)
46
46
  // are a soft, auto-synced state and must not block /compact or /clear — mirrors
47
47
  // the precompactGateStatus demote so the chat-side gate stays consistent.
48
48
  if (hasSession && !gitStatus.uncommitted && hotStatus.clean) {
@@ -6,8 +6,7 @@
6
6
  * project hot.md. Skips if still within the same project subtree.
7
7
  */
8
8
 
9
- import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from 'fs';
10
- import { homedir } from 'os';
9
+ import { readFileSync, writeFileSync, existsSync, realpathSync } from 'fs';
11
10
  import { join } from 'path';
12
11
  import {
13
12
  HYPO_DIR,
@@ -19,6 +18,9 @@ import {
19
18
  buildProjectSuggestionLine,
20
19
  recordSuggestionCooldown,
21
20
  sanitizeProjForPrompt,
21
+ pickProjectByCwd,
22
+ collectProjectWorkingDirs,
23
+ buildVaultOrientation,
22
24
  } from './hypo-shared.mjs';
23
25
 
24
26
  const PROJECTS_DIR = join(HYPO_DIR, 'projects');
@@ -33,42 +35,26 @@ function readIfNotIgnored(path, patterns) {
33
35
  return readFileSync(path, 'utf-8').slice(0, MAX_CHARS);
34
36
  }
35
37
 
36
- function parseFrontmatterField(content, key) {
37
- const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
38
- if (!match) return null;
39
- const line = match[1].split('\n').find((l) => l.startsWith(`${key}:`));
40
- if (!line) return null;
41
- return line
42
- .slice(key.length + 1)
43
- .trim()
44
- .replace(/^['"]|['"]$/g, '');
45
- }
46
-
47
38
  function findProjectHot(cwd) {
48
39
  if (!existsSync(PROJECTS_DIR)) return null;
49
- for (const proj of readdirSync(PROJECTS_DIR)) {
50
- const projDir = join(PROJECTS_DIR, proj);
51
- if (!statSync(projDir).isDirectory()) continue;
52
- const indexPath = join(projDir, 'index.md');
53
- if (!existsSync(indexPath)) continue;
54
- const content = readFileSync(indexPath, 'utf-8');
55
- const workingDir = parseFrontmatterField(content, 'working_dir');
56
- if (!workingDir) continue;
57
- const resolved = workingDir.startsWith('~/')
58
- ? join(homedir(), workingDir.slice(2))
59
- : workingDir;
60
- if (cwd === resolved || cwd.startsWith(resolved + '/')) {
61
- const hotPath = join(projDir, 'hot.md');
62
- const statePath = join(projDir, 'session-state.md');
63
- return {
64
- proj,
65
- hotPath: existsSync(hotPath) ? hotPath : null,
66
- statePath: existsSync(statePath) ? statePath : null,
67
- resolved,
68
- };
69
- }
40
+ let realpathCwd = null;
41
+ try {
42
+ realpathCwd = realpathSync(cwd);
43
+ } catch {
44
+ realpathCwd = null;
70
45
  }
71
- return null;
46
+ // Two-tier match (absolute prefix, then cross-machine unique basename) so a
47
+ // vault synced from another machine still resolves the cwd to its project.
48
+ const proj = pickProjectByCwd(collectProjectWorkingDirs(HYPO_DIR), cwd, { realpathCwd });
49
+ if (!proj) return null;
50
+ const projDir = join(PROJECTS_DIR, proj);
51
+ const hotPath = join(projDir, 'hot.md');
52
+ const statePath = join(projDir, 'session-state.md');
53
+ return {
54
+ proj,
55
+ hotPath: existsSync(hotPath) ? hotPath : null,
56
+ statePath: existsSync(statePath) ? statePath : null,
57
+ };
72
58
  }
73
59
 
74
60
  let raw = '';
@@ -123,10 +109,14 @@ process.stdin.on('end', () => {
123
109
  );
124
110
  }
125
111
  }
112
+ // Same vault orientation as session-start: when the new cwd is a project
113
+ // working_dir distinct from the vault, surface where wiki files live.
114
+ const vaultOrientation = buildVaultOrientation(newCwd);
115
+ const orientPrefix = vaultOrientation ? `${vaultOrientation}\n\n` : '';
126
116
  console.log(
127
117
  JSON.stringify(
128
118
  buildOutput(
129
- `[WIKI: cwd changed → project=${sanitizeProjForPrompt(newHit.proj)}]\n\n${content}`,
119
+ `${orientPrefix}[WIKI: cwd changed → project=${sanitizeProjForPrompt(newHit.proj)}]\n\n${content}`,
130
120
  {
131
121
  continue: true,
132
122
  suppressOutput: true,
@@ -137,7 +127,7 @@ process.stdin.on('end', () => {
137
127
  return;
138
128
  }
139
129
 
140
- // MISS: cwd matches no project. ADR 0023 — offer to create one
130
+ // MISS: cwd matches no project. Offer to create one
141
131
  // when the trigger conditions hold. Same nudge-only model as session-start.
142
132
  let suggestPrefix = '';
143
133
  if (shouldSuggestProjectCreation(newCwd, HYPO_DIR)) {
@@ -11,7 +11,15 @@
11
11
 
12
12
  import { readFileSync, existsSync, readdirSync, statSync } from 'fs';
13
13
  import { join, basename } from 'path';
14
- import { HYPO_DIR, buildOutput, loadHypoIgnore, isIgnored } from './hypo-shared.mjs';
14
+ import {
15
+ HYPO_DIR,
16
+ buildOutput,
17
+ loadHypoIgnore,
18
+ isIgnored,
19
+ staleMarkerFor,
20
+ pageUsageLoggingAllowed,
21
+ recordLookupUsage,
22
+ } from './hypo-shared.mjs';
15
23
 
16
24
  const INDEX_PATH = join(HYPO_DIR, 'index.md');
17
25
  const MAX_HITS = 3;
@@ -178,6 +186,8 @@ process.stdin.on('end', () => {
178
186
  try {
179
187
  const data = JSON.parse(input);
180
188
  const prompt = (data.prompt || '').trim();
189
+ // Runtime sends a session UUID; older payloads may omit it (parse defensively).
190
+ const sessionId = data.session_id || null;
181
191
 
182
192
  if (!prompt || !existsSync(INDEX_PATH)) {
183
193
  console.log(JSON.stringify({ continue: true, suppressOutput: true }));
@@ -235,14 +245,32 @@ process.stdin.on('end', () => {
235
245
  ),
236
246
  };
237
247
 
248
+ // UTC to match doctor.mjs' overdue set (D1/D2). STALE is advisory, so a
249
+ // one-day UTC/local skew never misleads.
250
+ const TODAY = new Date().toISOString().slice(0, 10);
251
+
238
252
  const injected = [];
253
+ const injectedSlugs = [];
239
254
  for (const { slug } of matched.slice(0, MAX_HITS)) {
240
255
  const path =
241
256
  pageMap[slug] ??
242
257
  pageMap[slug.replace(/^(pages|projects)\//, '')] ??
243
258
  pageMap[basename(slug)];
244
259
  if (path && existsSync(path)) {
245
- injected.push(`=== [[${slug}]] ===\n${readFileSync(path, 'utf-8').slice(0, MAX_CHARS)}`);
260
+ const raw = readFileSync(path, 'utf-8');
261
+ // Compute the marker on raw (pre-slice) so a long body can't truncate
262
+ // frontmatter out of reach. fail-open: a marker failure drops the marker,
263
+ // never the page.
264
+ let marker = '';
265
+ try {
266
+ marker = staleMarkerFor(raw, TODAY);
267
+ } catch {
268
+ marker = '';
269
+ }
270
+ injected.push(
271
+ `=== [[${slug}]] ===\n${marker ? marker + '\n' : ''}${raw.slice(0, MAX_CHARS)}`,
272
+ );
273
+ injectedSlugs.push(slug);
246
274
  }
247
275
  }
248
276
 
@@ -262,6 +290,13 @@ process.stdin.on('end', () => {
262
290
  return;
263
291
  }
264
292
 
293
+ // Page-usage logging (B): observability only, gated fail-closed on commit
294
+ // coverage, and fail-open so it never disturbs the injection above. Only the
295
+ // real HIT branch reaches here (miss / index-hit-but-missing returned early).
296
+ if (pageUsageLoggingAllowed(HYPO_DIR, sessionId)) {
297
+ recordLookupUsage(HYPO_DIR, { sessionId, slugs: injectedSlugs });
298
+ }
299
+
265
300
  const overflow =
266
301
  matched.length > MAX_HITS
267
302
  ? `\n(+${matched.length - MAX_HITS} more matches — search wiki index for more)`
@@ -9,13 +9,13 @@
9
9
  * - hot.md has forbidden structure
10
10
  * - lint blockers exist
11
11
  *
12
- * Bypass options (checked in order, per ADR 0022 / spec §7.5):
12
+ * Bypass options (checked in order, per spec §7.5):
13
13
  * 1. HYPO_SKIP_GATE=1 env var
14
14
  * 2. HYPO_SKIP_GATE=1 in a recent *user-role* transcript message
15
15
  * (assistant/tool output is excluded to prevent self-triggering from block reason text)
16
16
  *
17
17
  * NOTE: capacity bypass (wiki-context-critical.json ≥90%) was REMOVED
18
- * (ADR 0022 amendment 2026-05-13). Spec §7.5: even at full context, minimal
18
+ * (amendment 2026-05-13). Spec §7.5: even at full context, minimal
19
19
  * session-close is mandatory — auto-bypass on capacity caused silent state loss.
20
20
  */
21
21
 
@@ -31,6 +31,7 @@ import {
31
31
  isGateSkipped,
32
32
  isClosePattern,
33
33
  extractUserMessages,
34
+ isUnderProjectDirs,
34
35
  } from './hypo-shared.mjs';
35
36
 
36
37
  const WARNING_FILE = join(homedir(), '.claude', 'state', 'wiki-context-warning.json');
@@ -52,7 +53,7 @@ process.stdin.on('end', () => {
52
53
  /* fail-open */
53
54
  }
54
55
 
55
- // ── Capacity bypass (≥90%) REMOVED — ADR 0022 amendment 2026-05-13.
56
+ // ── Capacity bypass (≥90%) REMOVED: amendment 2026-05-13.
56
57
  // Even at full context, minimal session-close is mandatory (spec §7.5).
57
58
  // Bypass paths are now only: HYPO_SKIP_GATE env / HYPO_SKIP_GATE in transcript.
58
59
 
@@ -93,12 +94,12 @@ process.stdin.on('end', () => {
93
94
  // ── Heavy checks ──
94
95
  const today = new Date().toISOString().slice(0, 10);
95
96
 
96
- // The full PreCompact gate decision, single-sourced (ADR 0046). The SAME
97
+ // The full PreCompact gate decision, single-sourced. The SAME
97
98
  // function backs `crystallize --check-session-close`, so a green self-check
98
99
  // there means this hook will not block. precompactGateStatus runs git-clean +
99
- // hot.md structure + session-close files (ADR 0043 global invariant) + scoped
100
+ // hot.md structure + session-close files (global invariant) + scoped
100
101
  // lint + W8 design-history + feedback projection. The transcript widens the
101
- // lint scope to this session's edited files (ADR 0041); without one the scope
102
+ // lint scope to this session's edited files; without one the scope
102
103
  // is the mandatory close files. Read-only: pure feedback drift comes back as
103
104
  // gate.driftTargets, a self-heal effect requirement we run as --write below.
104
105
  let gate;
@@ -116,7 +117,7 @@ process.stdin.on('end', () => {
116
117
  return;
117
118
  }
118
119
 
119
- // Self-heal pure feedback projection drift (ADR 0045): the one mutation the
120
+ // Self-heal pure feedback projection drift: the one mutation the
120
121
  // read-only gate leaves to the caller. Fails CLOSED — if the --write errors we
121
122
  // turn the (otherwise non-blocking) drift into a blocker, since real drift is
122
123
  // confirmed and silently passing it would defeat the gate. --write only applies
@@ -152,20 +153,34 @@ process.stdin.on('end', () => {
152
153
  }
153
154
 
154
155
  // Non-blocking heads-up about pre-existing lint / out-of-scope design-history
155
- // debt in untouched files (other projects / shared pages). Surfaced so it is
156
- // visible but never blocks compact. (Mirrors the pre-ADR-0046 inline behavior,
157
- // which lumped out-of-scope W8 into the same lint-notice list.)
156
+ // debt in untouched files. Surfaced so it is visible but never blocks compact.
157
+ // Scoped to the close-target (today-active) projects: debt under one of their
158
+ // dirs stays listed by filename; debt elsewhere (other projects, shared pages,
159
+ // root files) folds into a count so the same untouched-file debt does not
160
+ // re-list its filenames on every compact. Non-file diagnostics (a fail-open
161
+ // "lint skipped" notice carries no path) are preserved verbatim, never folded.
158
162
  const debtNotices = gate.notices.filter((n) => n.type === 'lint' || n.type === 'design-history');
159
- let noticeText =
160
- debtNotices.length > 0
161
- ? `[WIKI CHECK] ${debtNotices.length} pre-existing lint issue(s) in files this session did not touch (not blocking): ${[
162
- ...new Set(debtNotices.map((n) => n.reason.replace(/ \([^)]*\)$/, ''))),
163
- ]
164
- .slice(0, 5)
165
- .join(', ')}${debtNotices.length > 5 ? ', …' : ''} — clean up when convenient.`
166
- : '';
163
+ const activeSlugs = (gate.close?.projects || []).map((p) => p.project).filter(Boolean);
164
+ const nonFileNotices = debtNotices.filter((n) => !n.file);
165
+ const fileNotices = debtNotices.filter((n) => n.file);
166
+ const inScopeNotices = fileNotices.filter((n) => isUnderProjectDirs(n.file, activeSlugs));
167
+ const otherDebtCount = fileNotices.length - inScopeNotices.length;
168
+ const listed = [
169
+ ...nonFileNotices.map((n) => n.reason),
170
+ ...new Set(inScopeNotices.map((n) => n.reason.replace(/ \([^)]*\)$/, ''))),
171
+ ];
172
+ let noticeText = '';
173
+ if (listed.length > 0) {
174
+ noticeText = `[WIKI CHECK] ${listed.length} pre-existing lint issue(s) in files this session did not touch (not blocking): ${listed
175
+ .slice(0, 5)
176
+ .join(', ')}${listed.length > 5 ? ', …' : ''} — clean up when convenient.`;
177
+ }
178
+ if (otherDebtCount > 0) {
179
+ const fold = `+${otherDebtCount} pre-existing lint issue(s) elsewhere in the vault (other projects / shared pages, not blocking) — run \`/hypo:lint\` for the full list.`;
180
+ noticeText = noticeText ? `${noticeText}\n${fold}` : `[WIKI CHECK] ${fold}`;
181
+ }
167
182
  // Surface the self-heal so a re-synced projection is not a silent mutation of
168
- // the user's MEMORY.md / CLAUDE.md (ADR 0045 transparency).
183
+ // the user's MEMORY.md / CLAUDE.md (transparency).
169
184
  if (feedbackHealed) noticeText = noticeText ? `${noticeText}\n${feedbackHealed}` : feedbackHealed;
170
185
 
171
186
  if (gate.ok) {
@@ -200,7 +215,7 @@ process.stdin.on('end', () => {
200
215
  // ── Block ──
201
216
  // gate.blockers already carry per-type reasons in the canonical order
202
217
  // (git, hot, close, lint, design-history, feedback) — same strings as before
203
- // ADR 0046, now sourced from the shared gate instead of inline checks.
218
+ // Now sourced from the shared gate instead of inline checks.
204
219
  const reasons = [
205
220
  ...gate.blockers.map((b) => b.reason),
206
221
  gate.skipped.lint ? 'lint skipped (run `hypomnema init` to enable lint gate)' : '',
@@ -224,10 +239,10 @@ process.stdin.on('end', () => {
224
239
  ` [ ] 9. hot.md — update projects/<name>/hot.md (no exceptions)`,
225
240
  ` [ ] 10. root hot.md — update ~/hypomnema/hot.md active project table`,
226
241
  ` [ ] 11. updated: field — verify today's date on all touched .md files`,
227
- ` [ ] 12. lint — run scripts/lint.mjs; fix errors in files YOU touched`,
242
+ ` [ ] 12. lint — run /hypo:lint; fix errors in files YOU touched`,
228
243
  ` (other projects' / shared-page debt is reported as non-blocking notice)`,
229
244
  ` [ ] 13. git commit & push`,
230
- ` [ ] 14. verify — run \`crystallize.mjs --check-session-close\`; only declare`,
245
+ ` [ ] 14. verify — run /hypo:crystallize (--check-session-close mode); only declare`,
231
246
  ` the session closed once it prints "Compact-ready" (= this gate passes).`,
232
247
  ].join('\n');
233
248
 
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * hypo-session-end.mjs — SessionEnd hook (ADR 0022 Layer 2)
3
+ * hypo-session-end.mjs — SessionEnd hook (Layer 2)
4
4
  *
5
5
  * `/clear` cannot be blocked: it never fires UserPromptSubmit (Stage 0 PoC,
6
6
  * 2026-05-14). The only intervention point is the SessionEnd(reason='clear')
@@ -5,7 +5,7 @@
5
5
  * Appends an entry to ~/hypomnema/.cache/sessions/index.jsonl for each
6
6
  * completed session. The index.jsonl is the **primary** source for
7
7
  * scripts/session-audit.mjs (which falls back to ~/.claude/projects/<encoded>/
8
- * if the index is empty or missing — see ADR 0019).
8
+ * if the index is empty or missing).
9
9
  *
10
10
  * Silent: never blocks, never emits user-visible output.
11
11
  */
@@ -51,7 +51,7 @@ process.stdin.on('end', () => {
51
51
  transcript_path: transcriptPath,
52
52
  recorded_at: new Date().toISOString(),
53
53
  cwd: payload.cwd || process.cwd(),
54
- // PRAC-17: machine identity for multi-machine audit. index.jsonl lives
54
+ // machine identity for multi-machine audit. index.jsonl lives
55
55
  // under .cache/ (gitignored in a normal vault), so this is a LOCAL-only
56
56
  // per-session record — accurate for every session, no sync/privacy cost.
57
57
  device: hostname() || 'unknown',