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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.ko.md +4 -2
- package/README.md +4 -2
- package/commands/upgrade.md +2 -0
- package/hooks/hypo-session-start.mjs +22 -15
- package/hooks/hypo-shared.mjs +69 -4
- package/package.json +1 -1
- package/scripts/crystallize.mjs +6 -1
- package/scripts/lib/plugin-detect.mjs +51 -0
- package/scripts/resume.mjs +61 -3
- package/scripts/smoke-pack.mjs +23 -2
- package/scripts/upgrade.mjs +254 -36
- package/templates/hypo-config.md +1 -1
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
package/commands/upgrade.md
CHANGED
|
@@ -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
|
-
//
|
|
343
|
-
//
|
|
344
|
-
//
|
|
345
|
-
//
|
|
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
|
-
|
|
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,
|
|
435
|
+
console.log(JSON.stringify(buildOutput(notice, outExtra)));
|
|
429
436
|
} else {
|
|
430
|
-
console.log(JSON.stringify(
|
|
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,
|
|
449
|
+
console.log(JSON.stringify(buildOutput(notice, outExtra)));
|
|
443
450
|
} else {
|
|
444
|
-
console.log(JSON.stringify(
|
|
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
|
-
|
|
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(
|
|
465
|
+
console.log(JSON.stringify(outExtra));
|
|
459
466
|
}
|
|
460
467
|
});
|
package/hooks/hypo-shared.mjs
CHANGED
|
@@ -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
|
-
*
|
|
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
package/scripts/crystallize.mjs
CHANGED
|
@@ -613,7 +613,12 @@ function applySessionClose(args) {
|
|
|
613
613
|
(wrote ? applied : skipped).push('log (log.md)');
|
|
614
614
|
}
|
|
615
615
|
|
|
616
|
-
|
|
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
|
+
}
|
package/scripts/resume.mjs
CHANGED
|
@@ -9,13 +9,22 @@
|
|
|
9
9
|
* node scripts/resume.mjs [options]
|
|
10
10
|
*
|
|
11
11
|
* Options:
|
|
12
|
-
* --hypo-dir=<path> Hypomnema root
|
|
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
|
-
|
|
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.');
|
package/scripts/smoke-pack.mjs
CHANGED
|
@@ -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(
|
|
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));
|
package/scripts/upgrade.mjs
CHANGED
|
@@ -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\`.
|
|
548
|
-
|
|
549
|
-
|
|
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
|
-
|
|
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
|
-
|
|
875
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 === '
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
1274
|
-
missingSettings.length +
|
|
1275
|
-
(schemaDrift ? 1 : 0) +
|
|
1276
|
-
(invalidSettings ? 1 : 0) +
|
|
1498
|
+
claudeCoreCount +
|
|
1277
1499
|
(pkgJsonDrift ? 1 : 0) +
|
|
1278
|
-
|
|
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',
|