hypomnema 1.3.0 → 1.3.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.
@@ -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.3.0",
14
+ "version": "1.3.1",
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.3.0",
3
+ "version": "1.3.1",
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,13 +23,15 @@ _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.2.0(현재)은 트리거 모델을 솔직하게 정리합니다. 위키 작업(자료 정리·검색·세션 마무리)은 여전히 사용자가 `/hypo:*` 명령어를 직접 입력해 시작합니다. 다만 **v1.1.0**부터 위키가 한 세션에서 얼마나 활용됐는지를 측정하는 *관측성 지표(observability score)* 가 들어갔고, **v1.2.0**은 그 위에 사용자가 시키지 않아도 자동으로 동작하는 영역 4개를 추가했습니다:
26
+ > **현재 자동화 범위와 다음 목표.** v1.3.0(현재)은 트리거 모델을 솔직하게 정리합니다. 위키 작업(자료 정리·검색·세션 마무리)은 여전히 사용자가 `/hypo:*` 명령어를 직접 입력해 시작합니다. 다만 **v1.1.0**부터 위키가 한 세션에서 얼마나 활용됐는지를 측정하는 *관측성 지표(observability score)* 가 들어갔고, **v1.2.0**은 그 위에 사용자가 시키지 않아도 자동으로 동작하는 영역 4개를 추가했습니다:
27
27
  > - **`feedback` 페이지를 단일 원천(source of truth)으로**(ADR 0031) — `pages/feedback/`에 한 번만 적으면, 위키가 `MEMORY.md`와 `~/.claude/CLAUDE.md`의 `<learned_behaviors>` 블록을 자동으로 갱신합니다.
28
28
  > - **확장 파일 동봉 동기화**(ADR 0024) — 위키 안의 `~/hypomnema/extensions/{agents,commands,hooks,skills}/`에 둔 파일을 자동으로 `~/.claude/`에 반영합니다. `--codex` 옵션을 추가하면 `hooks`·`commands`는 `~/.codex/`에도 반영되지만, `agents`와 `skills`는 Claude 전용이라 의도적으로 건너뜁니다.
29
29
  > - **프로젝트 자동 생성**(ADR 0023) — 작업 디렉터리를 git 저장소(`package.json`·`Cargo.toml` 등의 프로젝트 표식이 있는 곳)로 옮겼을 때 대응하는 위키 프로젝트가 없으면, 새로 만들지 물어봅니다.
30
30
  > - **세션 종료 자동 정리와 `/clear` 복구**(ADR 0022) — 의미 있는 세션이 끝날 때 "마무리 메모를 짧게 남길까요?"가 자동으로 뜨고, 마무리하지 않은 채 `/clear`를 입력해도 다음 세션 시작 시 이어서 정리할 수 있습니다.
31
31
  >
32
32
  > 스키마(`SCHEMA.md`)는 2.0으로 올라갑니다. `feedback` 페이지 타입에 9개의 필수 항목이 추가되며, `hypomnema upgrade --apply`를 실행하면 위키 루트에 `MIGRATION-v2.0.md`가 생성되어 단계별 보강 체크리스트를 제공합니다. 사용자가 직접 편집한 `SCHEMA.md`는 upgrade가 **덮어쓰지 않습니다** — 안내만 표시하고, 실제 반영은 사용자가 수동으로 결정합니다(이 정책을 코드에서는 *Option C*로 부릅니다).
33
+ >
34
+ > **v1.3.0**은 자율성을 넓히기보다 이 레이어를 다듬습니다. 세션 마무리 흐름에 *권고형* 성찰 4가지(자동 실행 없이 제안만 — ADR 0029)가 들어가고, `hypomnema lint --strict`가 선택된 경고를 에러로 승격해 릴리스 게이트로 쓸 수 있으며, 설치가 **stale-sibling 감지**로 단단해집니다 — `$PATH`에 남은 더 오래된 `hypomnema`가 더는 활성 훅을 조용히 다운그레이드하지 못합니다(ADR 0038).
33
35
 
34
36
  ---
35
37
 
@@ -247,7 +249,7 @@ v1.0에서는 `personal / shared / public` 3-mode를 만들었습니다. 현실
247
249
 
248
250
  이와 별도로 `SessionStart` 훅은 npm 레지스트리와 Claude Code 플러그인 마켓플레이스를 백그라운드에서 확인합니다(세션 시작을 막지 않습니다). 새 버전이 게시되어 있으면 다음 세션 시작 시 "Update available!" 안내가 한 줄 표시됩니다. `HYPO_NO_UPDATE_CHECK=1`, `NO_UPDATE_NOTIFIER=1`을 지정하거나 `CI=true` 환경에서 실행하면 점검을 건너뜁니다.
249
251
 
250
- 위 네 가지 레인 외의 v1.2 세부 수정 — 오래된 `design-history.md`를 잡는 `W8` lint, 프로젝트 누출을 막는 `project:*` 정확 일치 필터(PR #59), 코드 주석 정리 1단계(PR #58) — [`CHANGELOG.md`](CHANGELOG.md)를 참고하세요.
252
+ 위 네 가지 레인 외의 v1.3 세부 수정 — 세션 마무리 lint를 건드린 파일로 스코프해 무관한 debt로 `/compact`가 막히지 않게 한 변경(ADR 0037), `feedback` scope 검증기가 cwd 유래 project id를 수용하게 한 수정(OQ-34), `--strict`가 에러로 승격하는 안정적 lint 경고 ID `W1`/`W2`/`W4`(`--fix`로 자동복구되는 `W3`는 경고로 유지) — [`CHANGELOG.md`](CHANGELOG.md)를 참고하세요.
251
253
 
252
254
  ### 셋업 & 유지보수
253
255
 
package/README.md CHANGED
@@ -23,13 +23,15 @@ _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.2.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.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:
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 (ADR 0031 — full design rationale lives in `projects/hypomnema/decisions/0031-*.md` inside your wiki).
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) (ADR 0024).
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 (ADR 0023).
30
30
  > - **Stop-chain auto-minimal-crystallize + `/clear` recovery** — non-trivial sessions get an automatic "save a minimal session-close note?" prompt; `/clear` after a forgotten close is detected and recovered cleanly (ADR 0022).
31
31
  >
32
32
  > The schema (`SCHEMA.md`) bumps to 2.0 — the `feedback` page type now requires 9 mandatory frontmatter fields. `hypomnema upgrade --apply` writes `MIGRATION-v2.0.md` into the wiki root with a step-by-step backfill checklist. Your own `SCHEMA.md` is **never overwritten** by upgrade — we call this policy *Option C*: the upgrade only tells you what changed, and you apply the diff yourself.
33
+ >
34
+ > **v1.3.0** refines this layer rather than expanding autonomy: the session-close flow gains four *advisory* reflections (they suggest, never auto-act — ADR 0029), `hypomnema lint --strict` promotes selected warnings to errors for release gates, and install hardens with **stale-sibling detection** — an older `hypomnema` on `$PATH` can no longer silently downgrade your active hooks (ADR 0038).
33
35
 
34
36
  ---
35
37
 
@@ -247,7 +249,7 @@ All hooks resolve the wiki root via `HYPO_DIR` env → `hypo-config.md` scan →
247
249
 
248
250
  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`.
249
251
 
250
- For fix-level v1.2 detail beyond the lanes above — `W8` stale `design-history.md` lint, exact-match `project:*` filter for cross-project feedback projection (PR #59), and the comment-hygiene Phase 1 cleanup (PR #58) — see [`CHANGELOG.md`](CHANGELOG.md).
252
+ 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 (ADR 0037), the `feedback` scope validator accepting cwd-derived project ids (OQ-34), 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).
251
253
 
252
254
  ### Setup & maintenance
253
255
 
@@ -28,6 +28,8 @@ node <package-root>/scripts/upgrade.mjs [--hypo-dir="<path>"]
28
28
 
29
29
  Show the output verbatim.
30
30
 
31
+ > **Plugin installs**: if the output begins with `ℹ Plugin install detected`, the core hooks, slash commands, and `settings.json` wiring are managed by the Claude Code plugin loader — **not** by `/hypo:upgrade`. Do **not** run `--apply` expecting it to update them (it intentionally skips those to avoid double-registering every hook). To upgrade the plugin itself: `/plugin marketplace update hypomnema` then `/reload-plugins`. `--apply` in plugin mode applies vault-side migrations (SCHEMA, `.hypoignore`), refreshes package metadata, and still syncs any vault extensions — but does **not** install the core hooks/commands/settings (the plugin provides those).
32
+
31
33
  > **Note**: A major SCHEMA bump is only **detected** in this step. The informational `MIGRATION-vX.Y.md` file is written later by `--apply` (Step 4) and only on a major bump. `SCHEMA.md` is never auto-overwritten.
32
34
 
33
35
  ---
@@ -323,6 +323,10 @@ let raw = '';
323
323
  process.stdin.setEncoding('utf-8');
324
324
  process.stdin.on('data', (chunk) => (raw += chunk));
325
325
  process.stdin.on('end', () => {
326
+ // ISSUE-5: declared before the try so every emit branch — including the outer
327
+ // catch — carries the same `systemMessage` (the user-visible update/sibling
328
+ // banner). Reassigned once below after the notices are computed.
329
+ let outExtra = { continue: true, suppressOutput: true };
326
330
  try {
327
331
  let data = {};
328
332
  try {
@@ -339,10 +343,16 @@ process.stdin.on('end', () => {
339
343
  const clearRecoveryLine = buildClearRecoveryLine(data.source);
340
344
  const updateLine = buildUpdateNotice();
341
345
  const siblingLine = buildSiblingNotice();
342
- // Intentional dual emit: stderr (yellow/cyan) is the human-visible nudge in
343
- // the terminal; noticePrefix injects the same plain-text lines into the
344
- // LLM's additionalContext so model and user start the session looking at
345
- // the same state. ANSI escapes are kept out of additionalContext on purpose.
346
+ // ISSUE-5: the update + stale-sibling banners must reach the USER. On a
347
+ // SessionStart hook that exits 0, stderr is invisible in the normal TUI
348
+ // (only shown on exit 2 / --verbose) and additionalContext is model-only —
349
+ // `systemMessage` is the documented user-visible channel. Route those two
350
+ // banners there. They ALSO stay in noticePrefix → additionalContext below,
351
+ // so the model and the user start the session looking at the same state.
352
+ // (The other stderr notices — sync/growth/clear/suggest — are intentionally
353
+ // transcript/--verbose only and out of ISSUE-5's scope.)
354
+ const userMessage = [updateLine, siblingLine].filter(Boolean).join('\n\n');
355
+ if (userMessage) outExtra = { ...outExtra, systemMessage: userMessage };
346
356
  const notices = [syncLine, growthLine, clearRecoveryLine, updateLine, siblingLine].filter(
347
357
  Boolean,
348
358
  );
@@ -383,7 +393,7 @@ process.stdin.on('end', () => {
383
393
  JSON.stringify(
384
394
  buildOutput(
385
395
  `${noticePrefix}[WIKI HOT CACHE: project=${sanitizeProjForPrompt(hit.proj)}]\n\n${parts.join('\n\n')}`,
386
- { continue: true, suppressOutput: true },
396
+ outExtra,
387
397
  ),
388
398
  ),
389
399
  );
@@ -399,10 +409,7 @@ process.stdin.on('end', () => {
399
409
  JSON.stringify(
400
410
  buildOutput(
401
411
  `${noticePrefix}[WIKI HOT CACHE: project=${sanitizeProjForPrompt(hit.proj)}, no snapshot yet]`,
402
- {
403
- continue: true,
404
- suppressOutput: true,
405
- },
412
+ outExtra,
406
413
  ),
407
414
  ),
408
415
  );
@@ -425,9 +432,9 @@ process.stdin.on('end', () => {
425
432
  if (!existsSync(GLOBAL_HOT)) {
426
433
  const notice = notices.join('\n\n');
427
434
  if (notice) {
428
- console.log(JSON.stringify(buildOutput(notice, { continue: true, suppressOutput: true })));
435
+ console.log(JSON.stringify(buildOutput(notice, outExtra)));
429
436
  } else {
430
- console.log(JSON.stringify({ continue: true, suppressOutput: true }));
437
+ console.log(JSON.stringify(outExtra));
431
438
  }
432
439
  return;
433
440
  }
@@ -439,9 +446,9 @@ process.stdin.on('end', () => {
439
446
  // would otherwise be silently dropped here.
440
447
  const notice = notices.join('\n\n');
441
448
  if (notice) {
442
- console.log(JSON.stringify(buildOutput(notice, { continue: true, suppressOutput: true })));
449
+ console.log(JSON.stringify(buildOutput(notice, outExtra)));
443
450
  } else {
444
- console.log(JSON.stringify({ continue: true, suppressOutput: true }));
451
+ console.log(JSON.stringify(outExtra));
445
452
  }
446
453
  return;
447
454
  }
@@ -449,12 +456,12 @@ process.stdin.on('end', () => {
449
456
  JSON.stringify(
450
457
  buildOutput(
451
458
  `${noticePrefix}[WIKI HOT CACHE: global — no project matched cwd=${cwd}]\n\n${globalContent}`,
452
- { continue: true, suppressOutput: true },
459
+ outExtra,
453
460
  ),
454
461
  ),
455
462
  );
456
463
  } catch (err) {
457
464
  process.stderr.write(`[hypo-session-start] error: ${err?.message ?? String(err)}\n`);
458
- console.log(JSON.stringify({ continue: true, suppressOutput: true }));
465
+ console.log(JSON.stringify(outExtra));
459
466
  }
460
467
  });
@@ -232,13 +232,56 @@ export function freshDates() {
232
232
  return local === utc ? [local] : [local, utc];
233
233
  }
234
234
 
235
+ // Parse a single frontmatter scalar (mirrors hypo-session-start.mjs /
236
+ // hypo-cwd-change.mjs; local copy per the hook self-contained convention).
237
+ function parseFrontmatterField(content, key) {
238
+ const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
239
+ if (!m) return null;
240
+ const line = m[1].split('\n').find((l) => l.startsWith(`${key}:`));
241
+ if (!line) return null;
242
+ return line
243
+ .slice(key.length + 1)
244
+ .trim()
245
+ .replace(/^['"]|['"]$/g, '');
246
+ }
247
+
248
+ // Among `slugs`, return the one whose projects/<slug>/index.md `working_dir`
249
+ // is the LONGEST prefix of cwd (so /repo/sub wins over /repo). Returns null
250
+ // when cwd is falsy or matches none. Used only as a same-date tie-breaker.
251
+ function pickByCwd(hypoDir, slugs, cwd) {
252
+ if (!cwd) return null;
253
+ let best = null;
254
+ let bestLen = -1;
255
+ for (const slug of slugs) {
256
+ const indexPath = join(hypoDir, 'projects', slug, 'index.md');
257
+ if (!existsSync(indexPath)) continue;
258
+ const wd = parseFrontmatterField(readFileSync(indexPath, 'utf-8'), 'working_dir');
259
+ if (!wd) continue;
260
+ let resolved = wd.startsWith('~/') ? join(homedir(), wd.slice(2)) : wd;
261
+ resolved = resolved.replace(/\/+$/, ''); // trailing-slash normalize
262
+ if ((cwd === resolved || cwd.startsWith(resolved + '/')) && resolved.length > bestLen) {
263
+ bestLen = resolved.length;
264
+ best = slug;
265
+ }
266
+ }
267
+ return best;
268
+ }
269
+
235
270
  /**
236
271
  * Resolve the most-recently-active project slug from root hot.md.
237
- * Mirrors scripts/resume.mjs resolveActiveProject kept in sync by hand.
272
+ * The cwd helpers (parseFrontmatterField / pickByCwd) and the same-date
273
+ * tie-break are kept in sync with scripts/resume.mjs by hand; the surrounding
274
+ * wrapper intentionally differs (resume.mjs adds an mtime fallback, this does not).
275
+ * `cwd` is an optional same-date tie-breaker (ISSUE-1): resume passes
276
+ * process.cwd(); session-close callers (sessionCloseFileStatus /
277
+ * closeFileTargets) intentionally pass null — close has a different
278
+ * authority (payload.project / freshness), tracked separately as ISSUE-7.
279
+ * When cwd is omitted, behavior is identical to the legacy version.
238
280
  * @param {string} hypoDir
281
+ * @param {string|null} [cwd]
239
282
  * @returns {string|null}
240
283
  */
241
- export function resolveActiveProject(hypoDir) {
284
+ export function resolveActiveProject(hypoDir, cwd = null) {
242
285
  const hotPath = join(hypoDir, 'hot.md');
243
286
  if (!existsSync(hotPath)) return null;
244
287
  let content;
@@ -257,6 +300,19 @@ export function resolveActiveProject(hypoDir) {
257
300
  ].map((m) => ({ name: m[1].trim(), date: m[2] || '', slug: m[3] }));
258
301
  if (wikiRows.length > 0) {
259
302
  wikiRows.sort((a, b) => b.date.localeCompare(a.date));
303
+ // Same-date tie-break (ISSUE-1): when the top date is shared by >1 row,
304
+ // prefer the project whose working_dir contains cwd. No cwd / no match →
305
+ // keep the stable-sort winner (the legacy "first table row" behavior).
306
+ const topDate = wikiRows[0].date;
307
+ const tied = wikiRows.filter((r) => r.date === topDate);
308
+ if (cwd && tied.length > 1) {
309
+ const picked = pickByCwd(
310
+ hypoDir,
311
+ tied.map((r) => r.slug),
312
+ cwd,
313
+ );
314
+ if (picked) return picked;
315
+ }
260
316
  return wikiRows[0].slug;
261
317
  }
262
318
  // Legacy markdown-link rows: | [name](projects/name/...) | ...
@@ -277,12 +333,21 @@ export function resolveActiveProject(hypoDir) {
277
333
  * project A can't be masked by a fresh close of project B (and vice versa).
278
334
  * open-questions.md (file #5) is conditional and not gated.
279
335
  *
336
+ * `projectOverride` (ISSUE-7 Part A): when the caller already holds the
337
+ * authoritative project being closed (e.g. crystallize apply derives it from
338
+ * `payload.project`), it passes that slug so verification checks the SAME
339
+ * project it just wrote — instead of re-deriving via resolveActiveProject(),
340
+ * which on a same-date root-hot.md tie can resolve a DIFFERENT project and
341
+ * false-fail a completed close. When omitted, behavior is byte-identical to the
342
+ * legacy single-arg version (resolve from root hot.md).
343
+ *
280
344
  * @param {string} hypoDir
345
+ * @param {{projectOverride?: string|null}} [opts]
281
346
  * @returns {{ok: boolean, project: string|null, dates: string[], stale: string[], missing: string[]}}
282
347
  */
283
- export function sessionCloseFileStatus(hypoDir) {
348
+ export function sessionCloseFileStatus(hypoDir, { projectOverride = null } = {}) {
284
349
  const dates = freshDates();
285
- const project = resolveActiveProject(hypoDir);
350
+ const project = projectOverride || resolveActiveProject(hypoDir);
286
351
  if (!project) {
287
352
  return {
288
353
  ok: false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hypomnema",
3
- "version": "1.3.0",
3
+ "version": "1.3.1",
4
4
  "description": "LLM-native personal wiki system for Claude Code",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -613,7 +613,12 @@ function applySessionClose(args) {
613
613
  (wrote ? applied : skipped).push('log (log.md)');
614
614
  }
615
615
 
616
- const verification = sessionCloseFileStatus(args.hypoDir);
616
+ // ISSUE-7 Part A: verify against the SAME project this apply just wrote
617
+ // (`project` = payload.project || probe.project, resolved at the top). Without
618
+ // the override, sessionCloseFileStatus re-derives via resolveActiveProject and,
619
+ // on a same-date root-hot.md tie, can pick a different project — false-failing
620
+ // a completed close (the 2026-06-09 security-ops-kb incident).
621
+ const verification = sessionCloseFileStatus(args.hypoDir, { projectOverride: project });
617
622
 
618
623
  // Fix #40 post-apply lint: payload may have introduced a malformed body or
619
624
  // bad frontmatter. Surface as a distinct `stage` so caller can tell "lint
@@ -0,0 +1,51 @@
1
+ // Detect whether the Hypomnema Claude Code plugin is enabled in a settings.json.
2
+ //
3
+ // ISSUE-8 (dual-install guard): the manual/npm `upgrade.mjs` must know when the
4
+ // plugin is ALSO enabled, because the plugin loader already provides the core
5
+ // hooks/commands/settings — copying+registering them from a manual/npm `--apply`
6
+ // would double-register every hook.
7
+ //
8
+ // This parser is INTENTIONALLY conservative. The asymmetric cost is: a false
9
+ // positive blocks/alters a legitimate npm-only user's upgrade, which is worse
10
+ // than the rare dual-install double-register it guards against. So it fails open
11
+ // (returns false) on every uncertainty and only fires on an exact, well-formed
12
+ // `enabledPlugins` entry whose plugin name is precisely `hypomnema`.
13
+
14
+ import { readFileSync } from 'node:fs';
15
+
16
+ /**
17
+ * @param {string} settingsPath path to a Claude Code settings.json (e.g. ~/.claude/settings.json)
18
+ * @returns {boolean} true iff `enabledPlugins` contains a key shaped
19
+ * `hypomnema@<marketplace>` whose value is strictly `true`.
20
+ */
21
+ export function isHypomnemaPluginEnabled(settingsPath) {
22
+ let raw;
23
+ try {
24
+ raw = readFileSync(settingsPath, 'utf-8');
25
+ } catch {
26
+ return false; // missing / unreadable → cannot prove enabled → fail open
27
+ }
28
+ let parsed;
29
+ try {
30
+ parsed = JSON.parse(raw);
31
+ } catch {
32
+ return false; // corrupt JSON → fail open
33
+ }
34
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return false;
35
+
36
+ const enabled = parsed.enabledPlugins;
37
+ // enabledPlugins is an object map `{ "<name>@<marketplace>": true|false }`.
38
+ // Anything else (absent, array, scalar) → not enabled.
39
+ if (!enabled || typeof enabled !== 'object' || Array.isArray(enabled)) return false;
40
+
41
+ for (const [key, value] of Object.entries(enabled)) {
42
+ if (value !== true) continue; // strictly true only — no truthy coercion
43
+ // Require a real `name@marketplace` shape: an `@` that is neither the first
44
+ // nor the last char. A bare `"hypomnema": true` (no marketplace) must NOT
45
+ // trigger — that is not a valid enabledPlugins identifier.
46
+ const at = key.indexOf('@');
47
+ if (at <= 0 || at === key.length - 1) continue;
48
+ if (key.slice(0, at) === 'hypomnema') return true; // exact, case-sensitive
49
+ }
50
+ return false;
51
+ }
@@ -9,13 +9,22 @@
9
9
  * node scripts/resume.mjs [options]
10
10
  *
11
11
  * Options:
12
- * --hypo-dir=<path> Hypomnema root (default: resolved via HYPO_DIR / hypo-config.md / ~/hypomnema)
12
+ * --hypo-dir=<path> Hypomnema root. When omitted, resolveHypoRoot()
13
+ * (see lib/hypo-root.mjs) resolves it in priority order:
14
+ * 1. $HYPO_DIR if set — returned immediately; the
15
+ * hypo-config.md scan below is then skipped.
16
+ * 2. else the first of 7 fixed candidates
17
+ * (~/{hypomnema,wiki,notes,knowledge},
18
+ * ~/Documents/{hypomnema,wiki,notes}) that contains
19
+ * a hypo-config.md marker.
20
+ * 3. else the default ~/hypomnema.
13
21
  * --project=<name> Project name (default: most recently active from hot.md)
14
22
  * --json Output as JSON
15
23
  */
16
24
 
17
25
  import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
18
26
  import { join } from 'path';
27
+ import { homedir } from 'os';
19
28
  import { resolveHypoRoot, expandHome } from './lib/hypo-root.mjs';
20
29
 
21
30
  // ── arg parsing ──────────────────────────────────────────────────────────────
@@ -33,7 +42,43 @@ function parseArgs(argv) {
33
42
 
34
43
  // ── active project from hot.md ───────────────────────────────────────────────
35
44
 
36
- function resolveActiveProject(hypoDir) {
45
+ // Parse a single frontmatter scalar (mirrors the hook helpers in
46
+ // hypo-session-start.mjs / hypo-cwd-change.mjs — kept local per the hook
47
+ // self-contained convention rather than shared, to avoid script↔hook coupling).
48
+ function parseFrontmatterField(content, key) {
49
+ const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
50
+ if (!m) return null;
51
+ const line = m[1].split('\n').find((l) => l.startsWith(`${key}:`));
52
+ if (!line) return null;
53
+ return line
54
+ .slice(key.length + 1)
55
+ .trim()
56
+ .replace(/^['"]|['"]$/g, '');
57
+ }
58
+
59
+ // Among `slugs`, return the one whose projects/<slug>/index.md `working_dir`
60
+ // is the LONGEST prefix of cwd (so /repo/sub wins over /repo). Returns null
61
+ // when cwd is falsy or matches none. Used only as a same-date tie-breaker.
62
+ function pickByCwd(hypoDir, slugs, cwd) {
63
+ if (!cwd) return null;
64
+ let best = null;
65
+ let bestLen = -1;
66
+ for (const slug of slugs) {
67
+ const indexPath = join(hypoDir, 'projects', slug, 'index.md');
68
+ if (!existsSync(indexPath)) continue;
69
+ const wd = parseFrontmatterField(readFileSync(indexPath, 'utf-8'), 'working_dir');
70
+ if (!wd) continue;
71
+ let resolved = wd.startsWith('~/') ? join(homedir(), wd.slice(2)) : wd;
72
+ resolved = resolved.replace(/\/+$/, ''); // trailing-slash normalize
73
+ if ((cwd === resolved || cwd.startsWith(resolved + '/')) && resolved.length > bestLen) {
74
+ bestLen = resolved.length;
75
+ best = slug;
76
+ }
77
+ }
78
+ return best;
79
+ }
80
+
81
+ function resolveActiveProject(hypoDir, cwd = null) {
37
82
  const hotPath = join(hypoDir, 'hot.md');
38
83
  if (!existsSync(hotPath)) return null;
39
84
 
@@ -49,6 +94,19 @@ function resolveActiveProject(hypoDir) {
49
94
  ].map((m) => ({ name: m[1].trim(), date: m[2] || '', slug: m[3] }));
50
95
  if (wikiRows.length > 0) {
51
96
  wikiRows.sort((a, b) => b.date.localeCompare(a.date));
97
+ // Same-date tie-break (ISSUE-1): when the top date is shared by >1 row,
98
+ // prefer the project whose working_dir contains cwd. No cwd / no match →
99
+ // keep the stable-sort winner (the legacy "first table row" behavior).
100
+ const topDate = wikiRows[0].date;
101
+ const tied = wikiRows.filter((r) => r.date === topDate);
102
+ if (cwd && tied.length > 1) {
103
+ const picked = pickByCwd(
104
+ hypoDir,
105
+ tied.map((r) => r.slug),
106
+ cwd,
107
+ );
108
+ if (picked) return picked;
109
+ }
52
110
  return wikiRows[0].slug;
53
111
  }
54
112
  // Legacy markdown-link rows: | [name](projects/name/...) | ...
@@ -93,7 +151,7 @@ function readHot(hypoDir, project) {
93
151
 
94
152
  const args = parseArgs(process.argv);
95
153
 
96
- const project = args.project || resolveActiveProject(args.hypoDir);
154
+ const project = args.project || resolveActiveProject(args.hypoDir, process.cwd());
97
155
 
98
156
  if (!project) {
99
157
  console.error('Error: no active project found. Use --project=<name> or create a hot.md entry.');
@@ -32,6 +32,24 @@ import { fileURLToPath } from 'node:url';
32
32
  const REPO = join(fileURLToPath(new URL('.', import.meta.url)), '..');
33
33
  const KEEP = process.argv.includes('--keep');
34
34
 
35
+ // When this script runs inside `npm publish --dry-run` (the release workflow's
36
+ // publish-credential pre-check), npm exports `npm_config_dry_run=true` into the
37
+ // lifecycle environment. spawnSync inherits process.env, so the nested
38
+ // `npm pack` below would ALSO run in dry-run mode — reporting a tarball
39
+ // filename/size it never actually writes to disk — and the subsequent
40
+ // `npm install <tarball>` then fails with ENOENT (npm maps errno -2 → exit 254).
41
+ // Strip the flag so the nested npm commands always perform real writes,
42
+ // matching the real tag-push publish job (which has no outer `--dry-run`). The
43
+ // outer workflow stays dry-run and still skips the registry PUT.
44
+ // (This was the precheck-254 root cause; the earlier "missing NODE_AUTH_TOKEN"
45
+ // theory was wrong — it is file absence, not auth.)
46
+ const npmEnv = { ...process.env };
47
+ for (const key of Object.keys(npmEnv)) {
48
+ if (key.toLowerCase().replaceAll('-', '_') === 'npm_config_dry_run') {
49
+ delete npmEnv[key];
50
+ }
51
+ }
52
+
35
53
  const PKG = JSON.parse(readFileSync(join(REPO, 'package.json'), 'utf-8'));
36
54
  const PKG_NAME = PKG.name;
37
55
 
@@ -64,7 +82,7 @@ try {
64
82
  const preCommitBefore = existsSync(preCommitPath) ? readFileSync(preCommitPath, 'utf-8') : null;
65
83
 
66
84
  step('npm pack');
67
- const pack = run('npm', ['pack', '--json'], { cwd: REPO });
85
+ const pack = run('npm', ['pack', '--json'], { cwd: REPO, env: npmEnv });
68
86
  const meta = JSON.parse(pack.stdout)[0];
69
87
  const tarball = join(REPO, meta.filename);
70
88
  console.log(` → ${meta.filename} (${meta.size} bytes, ${meta.entryCount} entries)`);
@@ -79,7 +97,10 @@ try {
79
97
  private: true,
80
98
  }) + '\n',
81
99
  );
82
- run('npm', ['install', '--no-audit', '--no-fund', '--silent', tarball], { cwd: installRoot });
100
+ // No `--silent`: run() only prints npm's stdout/stderr on a non-zero exit, so
101
+ // a real nested-install failure must not be muted (the precheck-254 error was
102
+ // masked by `--silent` for two release cycles).
103
+ run('npm', ['install', '--no-audit', '--no-fund', tarball], { cwd: installRoot, env: npmEnv });
83
104
 
84
105
  // Move tarball into the work dir so it's not left in the repo.
85
106
  renameSync(tarball, join(work, meta.filename));
@@ -21,6 +21,9 @@
21
21
  * --json Output results as JSON
22
22
  * --allow-downgrade Override the guard that refuses to overwrite a NEWER
23
23
  * active install with an older package (ADR 0038)
24
+ * --allow-dual-install Override the ISSUE-8 guard: register the Claude core
25
+ * surface even though the Hypomnema plugin is also enabled
26
+ * (knowingly accept the double-registration risk)
24
27
  */
25
28
 
26
29
  import {
@@ -47,6 +50,7 @@ import {
47
50
  readFileIfRegular,
48
51
  } from './lib/pkg-json.mjs';
49
52
  import { syncExtensions } from './lib/extensions.mjs';
53
+ import { isHypomnemaPluginEnabled } from './lib/plugin-detect.mjs';
50
54
  import { classifyInstall, downgradeGuardMessage } from '../hooks/version-check.mjs';
51
55
 
52
56
  const HOME = homedir();
@@ -80,6 +84,7 @@ function parseArgs(argv) {
80
84
  forceExtensions: false,
81
85
  codex: false,
82
86
  allowDowngrade: false,
87
+ allowDualInstall: false,
83
88
  };
84
89
  for (const arg of argv.slice(2)) {
85
90
  if (arg.startsWith('--hypo-dir=')) args.hypoDir = expandHome(arg.slice(11));
@@ -89,6 +94,7 @@ function parseArgs(argv) {
89
94
  else if (arg === '--codex') args.codex = true;
90
95
  else if (arg === '--json') args.json = true;
91
96
  else if (arg === '--allow-downgrade') args.allowDowngrade = true;
97
+ else if (arg === '--allow-dual-install') args.allowDualInstall = true;
92
98
  }
93
99
  if (!args.hypoDir) args.hypoDir = resolveHypoRoot();
94
100
  return args;
@@ -458,7 +464,7 @@ function applyHypoignoreMigration(result) {
458
464
  return appended;
459
465
  }
460
466
 
461
- function writeMigrationReport(hypoDir, fromVersion, toVersion) {
467
+ function writeMigrationReport(hypoDir, fromVersion, toVersion, { pluginMode = false } = {}) {
462
468
  const today = new Date().toISOString().slice(0, 10);
463
469
  const filename = `MIGRATION-v${toVersion}.md`;
464
470
  const dest = join(hypoDir, filename);
@@ -544,9 +550,15 @@ Review the SCHEMA diff and update your wiki pages accordingly.
544
550
 
545
551
  ## Action items
546
552
 
547
- This report was generated during \`/hypo:upgrade --apply\`. Hook files and settings.json
548
- entries were applied by that run (or skipped with a warning if the target was malformed —
549
- see the upgrade output). \`SCHEMA.md\` is intentionally **not** overwritten by upgrade the
553
+ This report was generated during \`/hypo:upgrade --apply\`. ${
554
+ pluginMode
555
+ ? 'You are on a **plugin install**, so the core hook files and settings.json hook ' +
556
+ 'registrations were NOT touched — the Claude Code plugin loader owns them (upgrade the ' +
557
+ 'plugin via `/plugin marketplace update hypomnema` then `/reload-plugins`). Vault ' +
558
+ 'extensions, if any, were still synced.'
559
+ : 'Hook files and settings.json entries were applied by that run (or skipped with a ' +
560
+ 'warning if the target was malformed — see the upgrade output).'
561
+ } \`SCHEMA.md\` is intentionally **not** overwritten by upgrade — the
550
562
  remaining steps are manual:
551
563
 
552
564
  - [ ] Compare your \`SCHEMA.md\` (v${fromVersion}) with the package template (v${toVersion}) and merge changes manually
@@ -749,6 +761,31 @@ function applyCommands(commandResults, force) {
749
761
  return applied;
750
762
  }
751
763
 
764
+ // ISSUE-6: in plugin mode `applyCommands` is skipped (no command copy), but the
765
+ // runtime still needs hypo-pkg.json to resolve PKG_ROOT for lint/feedback scripts
766
+ // (hooks/hypo-shared.mjs → hypo-personal-check). Write minimal metadata pointing
767
+ // at the plugin's package root, preserving any existing fields (e.g. `extensions`)
768
+ // but DROPPING any prior `commands` map (no commands were copied, so a stale map
769
+ // would falsely assert ownership of ~/.claude/commands/hypo).
770
+ function writePluginModeMetadata() {
771
+ const path = pkgJsonPath();
772
+ // Drop any prior top-level `commands` SHA map: no commands were copied in plugin
773
+ // mode, so keeping a manual install's map would falsely assert ownership of
774
+ // ~/.claude/commands/hypo. Preserve every other field (e.g. `extensions`).
775
+ const { commands: _droppedCommands, ...existing } = readPkgJsonSafe(path) || {};
776
+ let pkgVersion = null;
777
+ try {
778
+ pkgVersion = JSON.parse(readFileSync(join(PKG_ROOT, 'package.json'), 'utf-8')).version;
779
+ } catch {}
780
+ writePkgJsonAtomic(path, {
781
+ ...existing,
782
+ pkgRoot: PKG_ROOT,
783
+ pkgVersion,
784
+ schemaVersion: '2.0',
785
+ });
786
+ return true;
787
+ }
788
+
752
789
  // ── main ─────────────────────────────────────────────────────────────────────
753
790
 
754
791
  const args = parseArgs(process.argv);
@@ -760,6 +797,43 @@ const claudeSettingsPath = join(HOME, '.claude', 'settings.json');
760
797
  const codexHooksDir = join(HOME, '.codex', 'hooks');
761
798
  const codexSettingsPath = join(HOME, '.codex', 'settings.json');
762
799
 
800
+ // ISSUE-6: when `/hypo:upgrade` runs as the Claude Code PLUGIN, the 15 core hooks
801
+ // and 14 slash commands are provided by the plugin's hooks.json + commands/
802
+ // (auto-wired by Claude Code), NOT copied into ~/.claude/. The manual/npm health
803
+ // check below would then report all of them "missing" and recommend `--apply`,
804
+ // which copies the hooks into ~/.claude/hooks/ and registers 14 settings.json
805
+ // events → Claude Code runs BOTH the plugin hooks.json AND user settings.json, so
806
+ // every hook fires TWICE. The decisive signal: the plugin command runs the
807
+ // PLUGIN's upgrade.mjs, so PKG_ROOT lives under ~/.claude/plugins/. (A manual/npm
808
+ // upgrade.mjs run while the plugin is ALSO enabled is a different failure mode —
809
+ // dual install — tracked as ISSUE-8.)
810
+ // Match the Claude plugin cache shape specifically (`~/.claude/plugins/…`), NOT a
811
+ // generic `/plugins/` substring — this flag now GATES install behavior, so a
812
+ // legitimate npm/dev checkout under some unrelated `…/plugins/…` path must not be
813
+ // misclassified and silently stop managing its hooks. (detectChannel's broad
814
+ // `/plugins/` test is fine for the notifier's display-only use, but too loose here.)
815
+ const pluginMode = PKG_ROOT.replace(/\\/g, '/').includes('/.claude/plugins/');
816
+ // ISSUE-8 (dual install): the OTHER way the same double-registration can happen.
817
+ // Here the MANUAL/npm upgrade.mjs is running (pluginMode=false), but the Hypomnema
818
+ // plugin is ALSO enabled in ~/.claude/settings.json — so the plugin loader already
819
+ // provides the core hooks/commands/settings. A manual/npm `--apply` would copy and
820
+ // register them on top, and every core hook fires twice. The detector is fail-open
821
+ // (see lib/plugin-detect.mjs): a false positive would wrongly alter a legitimate
822
+ // npm-only user's upgrade, so it only fires on an exact `hypomnema@<mp>: true`.
823
+ const hypomnemaPluginEnabled = !pluginMode && isHypomnemaPluginEnabled(claudeSettingsPath);
824
+ const dualInstallCoreConflict = hypomnemaPluginEnabled;
825
+ // Surface policy: the Claude core surface (hooks/settings/commands/hook-name
826
+ // migration) is skipped when EITHER the plugin runs this script (pluginMode) OR a
827
+ // manual/npm run detects the plugin is enabled (dualInstallCoreConflict) — unless
828
+ // the user knowingly overrides with --allow-dual-install. The codex core surface
829
+ // (--codex) and vault-defined extensions are NOT plugin-provided, so they stay
830
+ // managed in every case.
831
+ const managesClaudeCore = !pluginMode && (!dualInstallCoreConflict || args.allowDualInstall);
832
+ // dualSkip = core was skipped specifically because of the dual install (not a true
833
+ // plugin-mode run, and not overridden). Drives the warning banner and the metadata
834
+ // preservation below.
835
+ const dualSkip = dualInstallCoreConflict && !args.allowDualInstall;
836
+
763
837
  const schema = checkSchemaVersion(args.hypoDir);
764
838
  const hooks = checkHookFiles(claudeHooksDir);
765
839
  const settings = checkSettingsJson(claudeSettingsPath, claudeHooksDir);
@@ -823,7 +897,14 @@ const invalidSettingsCodex = settingsCodex
823
897
  ? settingsCodex.some((s) => s.status === 'invalid-json')
824
898
  : false;
825
899
  const schemaDrift = schema.bump !== 'none' && schema.bump !== 'unknown' && schema.bump !== 'ahead';
826
- const pkgJsonDrift = pkgJson.status !== 'up-to-date';
900
+ // ISSUE-8 dual-install: when core is skipped, hypo-pkg.json is deliberately left
901
+ // pointing at the PLUGIN's package root (preserved identity), so checkPkgJson()
902
+ // reports it 'stale' relative to this npm/manual PKG_ROOT. That mismatch is
903
+ // INTENTIONAL — `--apply` will not (and must not) rewrite it — so it must not
904
+ // count as actionable drift, or the user would be nagged to run --apply forever.
905
+ // A genuinely missing/corrupt file (status 'missing') is still surfaced (warning
906
+ // below), because the runtime then cannot resolve its package root at all.
907
+ const pkgJsonDrift = pkgJson.status !== 'up-to-date' && !(dualSkip && pkgJson.status === 'stale');
827
908
  const staleCommands = commands.filter((c) => c.status === 'stale' || c.status === 'missing');
828
909
  const userModifiedCommands = commands.filter((c) => c.status === 'user-modified');
829
910
  const orphanedCommands = commands.filter((c) => c.status === 'orphaned');
@@ -871,21 +952,59 @@ if (args.apply) {
871
952
  process.exit(2);
872
953
  }
873
954
  }
874
- if (oldHookRefs.length > 0) {
875
- appliedHookNameRenames = applyHookNameMigration(
876
- oldHookRefs,
877
- claudeSettingsPath,
878
- claudeHooksDir,
879
- );
880
- }
955
+ // Migration report is vault-side (writes into the Hypomnema root) and applies
956
+ // in both install models.
881
957
  if (schema.bump === 'major' && schema.installed && schema.current && existsSync(args.hypoDir)) {
882
- migrationPath = writeMigrationReport(args.hypoDir, schema.installed, schema.current);
958
+ migrationPath = writeMigrationReport(args.hypoDir, schema.installed, schema.current, {
959
+ // Use the core-skipped predicate, not raw pluginMode: in a dual-install skip
960
+ // the core surface is plugin-owned too, so the report must not claim the
961
+ // core hooks/settings were applied (ISSUE-8, W2 review note).
962
+ pluginMode: !managesClaudeCore,
963
+ });
964
+ }
965
+ if (managesClaudeCore) {
966
+ if (oldHookRefs.length > 0) {
967
+ appliedHookNameRenames = applyHookNameMigration(
968
+ oldHookRefs,
969
+ claudeSettingsPath,
970
+ claudeHooksDir,
971
+ );
972
+ }
973
+ appliedHooks = applyHookFiles(hooks, claudeHooksDir);
974
+ appliedSettings = applySettingsJson(settings, claudeSettingsPath);
975
+ // applyCommands handles the single atomic hypo-pkg.json write (pkgRoot, version, schema, commands map)
976
+ appliedCommands = applyCommands(commands, args.forceCommands);
977
+ appliedPkgJson = true;
978
+ } else if (pluginMode) {
979
+ // ISSUE-6 plugin mode: the plugin loader owns the core hooks/commands and
980
+ // settings.json wiring — copying them here would double-register. Skip those,
981
+ // but STILL write minimal package metadata so the runtime can resolve PKG_ROOT
982
+ // for lint/feedback scripts (hooks/hypo-shared.mjs → hypo-personal-check). The
983
+ // commands SHA map is intentionally omitted (no command copy happened). PKG_ROOT
984
+ // is the plugin's own path here, so this metadata is authoritative.
985
+ appliedPkgJson = writePluginModeMetadata();
986
+ } else {
987
+ // ISSUE-8 dual-install skip: a manual/npm run while the plugin is enabled. We
988
+ // skip the core surface (the plugin owns it), but — unlike true plugin mode —
989
+ // PKG_ROOT here is the npm/manual path while the ACTIVE runtime hooks are the
990
+ // PLUGIN's. Rewriting a VALID hypo-pkg.json.pkgRoot to this npm path would
991
+ // mis-point the plugin runtime's lint/feedback resolution, so we PRESERVE an
992
+ // existing plugin-written identity (pkgJson.status 'stale'/'up-to-date' both
993
+ // mean a usable pkgRoot is already on disk) and do not touch it.
994
+ //
995
+ // If the metadata is MISSING or corrupt (status 'missing'; corrupt files are
996
+ // renamed to *.corrupt-*.json by readPkgJson and then read as absent), there is
997
+ // no plugin identity to preserve. Write minimal fallback metadata pointing at
998
+ // this (same-version) npm copy so the plugin runtime can resolve a package root
999
+ // at all — strictly better than the pkgRoot-less file extension sync would
1000
+ // otherwise create, or no file at all. The dual-install banner still tells the
1001
+ // user to resolve the dual install.
1002
+ if (pkgJson.status === 'missing') {
1003
+ appliedPkgJson = writePluginModeMetadata();
1004
+ } else {
1005
+ appliedPkgJson = false;
1006
+ }
883
1007
  }
884
- appliedHooks = applyHookFiles(hooks, claudeHooksDir);
885
- appliedSettings = applySettingsJson(settings, claudeSettingsPath);
886
- // applyCommands handles the single atomic hypo-pkg.json write (pkgRoot, version, schema, commands map)
887
- appliedCommands = applyCommands(commands, args.forceCommands);
888
- appliedPkgJson = true;
889
1008
  appliedHypoignore = applyHypoignoreMigration(hypoignore);
890
1009
  // codex core hooks + settings + wiki-*→hypo-* rename mirror. Same order
891
1010
  // as the claude side (rename first so subsequent hook copy can find renamed targets).
@@ -939,17 +1058,28 @@ const codexCoreDrift =
939
1058
  invalidSettingsCodex ||
940
1059
  (oldHookRefsCodex?.length ?? 0) > 0);
941
1060
 
942
- const hasDrift =
1061
+ // Claude core-surface drift (hooks/settings/commands/rename/metadata). In plugin
1062
+ // mode these are plugin-managed, so they must NOT count as drift — otherwise the
1063
+ // report nags "N items need updating" and recommends a double-registering --apply.
1064
+ // Plugin-provided surface (hooks/settings/commands/rename) — excluded from drift
1065
+ // in plugin mode. pkgJsonDrift is intentionally NOT here: hypo-pkg.json is written
1066
+ // in BOTH install models (plugin mode writes minimal metadata so the runtime can
1067
+ // resolve PKG_ROOT for lint/feedback), so a missing/stale metadata file should
1068
+ // still prompt a (safe, metadata-only) --apply.
1069
+ const claudeCoreDrift =
943
1070
  staleHooks.length > 0 ||
944
1071
  missingSettings.length > 0 ||
945
- schemaDrift ||
946
1072
  invalidSettings ||
947
- pkgJsonDrift ||
948
1073
  oldHookRefs.length > 0 ||
949
1074
  staleCommands.length > 0 ||
950
1075
  userModifiedCommands.length > 0 ||
951
1076
  orphanedCommands.length > 0 ||
952
- nonRegularCommands.length > 0 ||
1077
+ nonRegularCommands.length > 0;
1078
+
1079
+ const hasDrift =
1080
+ (managesClaudeCore && claudeCoreDrift) ||
1081
+ pkgJsonDrift ||
1082
+ schemaDrift ||
953
1083
  hypoignore.status === 'needs-migration' ||
954
1084
  extDrift ||
955
1085
  codexCoreDrift;
@@ -958,6 +1088,12 @@ if (args.json) {
958
1088
  console.log(
959
1089
  JSON.stringify(
960
1090
  {
1091
+ pluginMode,
1092
+ // ISSUE-8 dual-install signals.
1093
+ hypomnemaPluginEnabled,
1094
+ dualInstallCoreConflict,
1095
+ coreManagedBy: managesClaudeCore ? 'self' : pluginMode ? 'plugin' : 'plugin-enabled',
1096
+ dualInstallOverride: args.allowDualInstall,
961
1097
  schema,
962
1098
  hooks,
963
1099
  settings,
@@ -1002,6 +1138,49 @@ if (args.json) {
1002
1138
  // Human-readable report
1003
1139
  const lines = [];
1004
1140
 
1141
+ // ISSUE-6: lead with the plugin-mode banner so the user understands why the core
1142
+ // hook/command/settings sections read "managed by plugin" and that `--apply` will
1143
+ // NOT touch them (only vault-side migrations + package metadata).
1144
+ if (pluginMode) {
1145
+ lines.push(
1146
+ 'ℹ Plugin install detected — Hypomnema is loaded via the Claude Code plugin.',
1147
+ ' Core hooks, slash commands, and settings.json wiring are provided by the',
1148
+ ' plugin loader, so `/hypo:upgrade` does NOT manage them (and `--apply` will',
1149
+ ' not copy/register them — that would double-register every hook).',
1150
+ ' → To upgrade the plugin: `/plugin marketplace update hypomnema` then `/reload-plugins`.',
1151
+ ' → `/hypo:upgrade --apply` here applies vault-side migrations (SCHEMA,',
1152
+ ' .hypoignore), refreshes package metadata, and still syncs any vault',
1153
+ ' extensions — but does NOT install the core hooks/commands/settings.',
1154
+ '',
1155
+ );
1156
+ }
1157
+
1158
+ // ISSUE-8: a manual/npm upgrade.mjs is running while the Hypomnema plugin is ALSO
1159
+ // enabled — a dual install. Lead with a loud banner: the core surface is owned by
1160
+ // the plugin and is intentionally skipped, so `--apply` will not double-register.
1161
+ if (dualSkip) {
1162
+ lines.push(
1163
+ '⚠ Dual install detected — you are running the MANUAL/npm `upgrade.mjs`, but the',
1164
+ ' Hypomnema plugin is ALSO enabled in ~/.claude/settings.json. The plugin loader',
1165
+ ' already provides the core hooks, slash commands, and settings.json wiring, so',
1166
+ ' this run does NOT copy/register them (doing so would double-register every hook).',
1167
+ ' → Recommended: pick ONE install. To keep the plugin, remove the npm/manual copy',
1168
+ ' (`npm uninstall -g hypomnema`) and upgrade via `/plugin marketplace update',
1169
+ ' hypomnema` + `/reload-plugins`. Vault extensions + codex (if any) are still synced.',
1170
+ ' → To register the core surface here anyway (knowingly accept the double-register',
1171
+ ' risk), re-run with `--allow-dual-install`.',
1172
+ '',
1173
+ );
1174
+ } else if (dualInstallCoreConflict && args.allowDualInstall) {
1175
+ // Override path: the user forced core registration despite the enabled plugin.
1176
+ lines.push(
1177
+ '⚠ Dual install — `--allow-dual-install` set: registering the Claude core surface',
1178
+ ' even though the Hypomnema plugin is enabled. Every core hook may now fire TWICE',
1179
+ ' (plugin loader + ~/.claude registration) until one install is removed.',
1180
+ '',
1181
+ );
1182
+ }
1183
+
1005
1184
  // Schema version
1006
1185
  if (schema.bump === 'none') {
1007
1186
  lines.push(`✓ SCHEMA version ${schema.installed} (up to date)`);
@@ -1047,7 +1226,13 @@ function pushHookSummary(hookList, label, targetPath) {
1047
1226
  }
1048
1227
  }
1049
1228
  }
1050
- pushHookSummary(hooks, '', '~/.claude/hooks/');
1229
+ if (managesClaudeCore) {
1230
+ pushHookSummary(hooks, '', '~/.claude/hooks/');
1231
+ } else {
1232
+ lines.push(
1233
+ '✓ Hook files provided by the plugin loader (not managed in ~/.claude/hooks/)',
1234
+ );
1235
+ }
1051
1236
  if (hooksCodex) pushHookSummary(hooksCodex, ' (codex)', '~/.codex/hooks/');
1052
1237
 
1053
1238
  // settings.json registrations (target-aware mirror; fix #48).
@@ -1066,11 +1251,33 @@ function pushSettingsSummary(sList, label, invalidFlag) {
1066
1251
  }
1067
1252
  }
1068
1253
  }
1069
- pushSettingsSummary(settings, '', invalidSettings);
1254
+ if (managesClaudeCore) {
1255
+ pushSettingsSummary(settings, '', invalidSettings);
1256
+ } else {
1257
+ lines.push(
1258
+ '✓ settings.json hook wiring provided by the plugin (no ~/.claude registration)',
1259
+ );
1260
+ }
1070
1261
  if (settingsCodex) pushSettingsSummary(settingsCodex, ' (codex)', invalidSettingsCodex);
1071
1262
 
1072
1263
  // Package metadata
1073
- if (pkgJson.status === 'up-to-date') {
1264
+ if (dualSkip && pkgJson.status === 'stale') {
1265
+ // ISSUE-8: the 'stale' here is the preserved plugin identity (pkgRoot points at
1266
+ // the plugin, not this npm/manual copy). That is intentional — not actionable.
1267
+ lines.push(
1268
+ `✓ Package metadata hypo-pkg.json plugin-owned (preserved — not rewritten in a dual install)`,
1269
+ );
1270
+ } else if (dualSkip && pkgJson.status === 'missing') {
1271
+ // Missing/corrupt in a dual install: there is no plugin identity to preserve, so
1272
+ // `--apply` writes minimal fallback metadata (pointing at this same-version npm
1273
+ // copy) — enough for the plugin runtime to resolve its scripts. The real fix is
1274
+ // still to resolve the dual install.
1275
+ lines.push(
1276
+ `⚠ Package metadata hypo-pkg.json missing/unreadable — \`--apply\` writes fallback metadata`,
1277
+ ` for this npm copy so the plugin runtime can resolve its scripts.`,
1278
+ ` Better: resolve the dual install (remove the npm/manual copy).`,
1279
+ );
1280
+ } else if (pkgJson.status === 'up-to-date') {
1074
1281
  lines.push(`✓ Package metadata hypo-pkg.json up to date`);
1075
1282
  } else if (pkgJson.status === 'stale') {
1076
1283
  lines.push(
@@ -1087,7 +1294,9 @@ const cmdMissCount = commands.filter((c) => c.status === 'missing').length;
1087
1294
  const cmdUserCount = userModifiedCommands.length;
1088
1295
  const cmdOrphanCount = orphanedCommands.length;
1089
1296
  const cmdNonRegCount = nonRegularCommands.length;
1090
- if (commands.length === 0) {
1297
+ if (!managesClaudeCore) {
1298
+ lines.push('✓ Slash commands provided by the plugin loader (not ~/.claude/commands/hypo/)');
1299
+ } else if (commands.length === 0) {
1091
1300
  lines.push(`⚠ Slash commands package commands/ is empty`);
1092
1301
  } else if (
1093
1302
  cmdStaleCount === 0 &&
@@ -1134,7 +1343,10 @@ function pushHookNameSummary(refs, label) {
1134
1343
  lines.push(`✓ ${colN}All hook references use current hypo-*.mjs names`);
1135
1344
  }
1136
1345
  }
1137
- pushHookNameSummary(oldHookRefs, '');
1346
+ // In plugin mode the Claude settings.json is plugin-owned and --apply skips the
1347
+ // rename migration, so do not print a "run --apply to rename" instruction it will
1348
+ // not honor. (codex hook-name migration is unaffected by pluginMode.)
1349
+ if (managesClaudeCore) pushHookNameSummary(oldHookRefs, '');
1138
1350
  if (oldHookRefsCodex) pushHookNameSummary(oldHookRefsCodex, ' (codex)');
1139
1351
 
1140
1352
  // .hypoignore migration (ensure required runtime patterns are present)
@@ -1269,17 +1481,23 @@ pushAppliedExt(appliedExtensionsCodex, ' (codex)');
1269
1481
 
1270
1482
  // Summary
1271
1483
  lines.push('');
1484
+ // Claude core-surface item count — zeroed in plugin mode (plugin-managed), so the
1485
+ // summary never reads "N items need updating" for hooks/settings/commands.
1486
+ const claudeCoreCount = managesClaudeCore
1487
+ ? staleHooks.length +
1488
+ missingSettings.length +
1489
+ (invalidSettings ? 1 : 0) +
1490
+ oldHookRefs.length +
1491
+ staleCommands.length +
1492
+ userModifiedCommands.length +
1493
+ orphanedCommands.length +
1494
+ nonRegularCommands.length
1495
+ : 0;
1496
+
1272
1497
  const totalDrift =
1273
- staleHooks.length +
1274
- missingSettings.length +
1275
- (schemaDrift ? 1 : 0) +
1276
- (invalidSettings ? 1 : 0) +
1498
+ claudeCoreCount +
1277
1499
  (pkgJsonDrift ? 1 : 0) +
1278
- oldHookRefs.length +
1279
- staleCommands.length +
1280
- userModifiedCommands.length +
1281
- orphanedCommands.length +
1282
- nonRegularCommands.length +
1500
+ (schemaDrift ? 1 : 0) +
1283
1501
  (hypoignore.status === 'needs-migration' ? hypoignore.missing.length : 0) +
1284
1502
  extCheck.actions.filter(
1285
1503
  (a) => a.action === 'create' || a.action === 'update' || a.action === 'force-update',
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  title: Hypomnema Config
3
3
  type: config
4
- version: "1.3.0"
4
+ version: "1.3.1"
5
5
  created: YYYY-MM-DD
6
6
  ---
7
7