hypomnema 1.8.0 → 1.8.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": "hypo",
12
12
  "source": "./",
13
13
  "description": "LLM-native personal wiki — session-aware knowledge base for Claude Code",
14
- "version": "1.8.0",
14
+ "version": "1.8.1",
15
15
  "homepage": "https://github.com/sk-lim19f/Hypomnema"
16
16
  }
17
17
  ]
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hypo",
3
- "version": "1.8.0",
3
+ "version": "1.8.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/CHANGELOG.md CHANGED
@@ -5,6 +5,22 @@ All notable changes to Hypomnema are documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.8.1] - 2026-09-07
9
+
10
+ ### Bug Fixes
11
+
12
+ #### English
13
+
14
+ - Six slash names (`crystallize`, `graph`, `ingest`, `lint`, `query`, `verify`) shipped as both a `commands/*.md` file and a `skills/*/SKILL.md` directory, so each registered twice as a component and the command palette repeated them. Only the `commands/` half ever loaded, so the duplicates are removed with no behavior change, and `smoke:plugin` now fails on a name present in both directories (decisions/0100). ([#282](https://github.com/sk-lim19f/Hypomnema/pull/282))
15
+ - A session close now checks that the root files it writes still carry a usable frontmatter block, and refuses a close that would leave invalid YAML in one of them. ([#281](https://github.com/sk-lim19f/Hypomnema/pull/281))
16
+ - `/hypo:resume` and SessionStart now surface uncommitted vault work that belongs to another project, so a session does not silently adopt it. ([#280](https://github.com/sk-lim19f/Hypomnema/pull/280))
17
+
18
+ #### 한국어
19
+
20
+ - 여섯 슬래시 이름(`crystallize`, `graph`, `ingest`, `lint`, `query`, `verify`)이 `commands/*.md` 파일과 `skills/*/SKILL.md` 디렉터리로 둘 다 출하되어 component 가 두 번씩 등록되고 명령 팔레트가 그만큼 반복됐습니다. 실제로 로드된 것은 `commands/` 쪽뿐이라 중복을 동작 변화 없이 지웠고, 이제 한 이름이 두 디렉터리에 다 있으면 `smoke:plugin` 이 실패합니다 (decisions/0100). ([#282](https://github.com/sk-lim19f/Hypomnema/pull/282))
21
+ - 세션 마무리가 자기가 쓰는 루트 파일에 쓸 만한 프론트매터 블록이 남는지 검사하고, 그중 하나에 깨진 YAML 을 남길 마무리를 거절합니다. ([#281](https://github.com/sk-lim19f/Hypomnema/pull/281))
22
+ - `/hypo:resume` 과 SessionStart 가 다른 프로젝트 소관의 미커밋 변경을 한 줄로 알려, 세션이 그것을 조용히 자기 일로 삼지 않게 합니다. ([#280](https://github.com/sk-lim19f/Hypomnema/pull/280))
23
+
8
24
  ## [1.8.0] - 2026-09-03
9
25
 
10
26
  ### New Features
package/README.ko.md CHANGED
@@ -289,7 +289,9 @@ PostToolUse 훅 둘은 matcher 없이 등록되고 각자 tool_name으로 거릅
289
289
 
290
290
  ### Claude Agent Skills
291
291
 
292
- 합성이 핵심인 명령어 6개(`ingest`, `query`, `crystallize`, `lint`, `verify`, `graph`)는 `skills/<name>/SKILL.md`로도 등록돼 있습니다. 대화 내용이 해당 스킬의 `description`과 맞으면 Claude Agent Skills 메커니즘이 슬래시 명령 없이도 자동으로 호출합니다. 일곱 번째인 `debate`는 스킬로만 있습니다. `commands/` 아래에 파일이 없지만 플러그인이 `skills/`도 함께 싣기 때문에 `/hypo:debate`는 나머지와 똑같이 동작합니다. 세 단계(심문, 검증, 종합)로 구조화된 검토를 실행해 위키 주장을 재검증하거나 되돌리기 어려운 결정을 ADR로 굳힙니다. 위 명령어 표에 없는 것은 그 표가 `commands/` 아래 파일을 세기 때문이지 칠 수 있는 것을 세기 때문이 아닙니다. 명령어를 정확히 몰라도 하려는 일을 말로 적으면 됩니다.
292
+ `commands/` 아래의 모든 파일이 그 자체로 Claude Agent Skill입니다. 대화 내용이 그 `description`과 맞으면 슬래시 명령 없이도 자동으로 호출됩니다. (플러그인 설치에서 실측한 것입니다. npm 경로가 설치하는 `~/.claude/commands/hypo/` 사본도 같은 방식으로 자동 호출되는지는 재 보지 않았습니다.) 합성이 핵심인 여섯(`ingest`, `query`, `crystallize`, `lint`, `verify`, `graph`)에서 이 동작이 가장 잘 드러납니다. `debate`만 반대쪽에 있습니다. `commands/` 아래에 파일이 없고 `skills/debate/SKILL.md`에만 있는데, 플러그인이 `skills/`도 함께 싣기 때문에 `/hypo:debate`는 나머지와 똑같이 동작합니다. 세 단계(심문, 검증, 종합)로 구조화된 검토를 실행해 위키 주장을 재검증하거나 되돌리기 어려운 결정을 ADR로 굳힙니다. 위 명령어 표에 없는 것은 그 표가 `commands/` 아래 파일을 세기 때문이지 칠 수 있는 것을 세기 때문이 아닙니다. 명령어를 정확히 몰라도 하려는 일을 말로 적으면 됩니다.
293
+
294
+ 이름 하나는 두 디렉터리 중 한 곳에서만 출하됩니다. flat `commands/<name>.md`와 디렉터리 `skills/<name>/SKILL.md`는 둘 다 `/hypo:<name>`을 차지하고, 둘을 함께 실어도 사람용 표면과 모델용 표면으로 갈리지 않습니다. 한쪽만 이기고 나머지는 죽은 무게가 됩니다. 충돌이 있으면 `npm run smoke:plugin`이 실패합니다. 이 검사는 이 패키지의 `commands/`와 `skills/`만 봅니다. `~/hypomnema/extensions/`에 둔 직접 만든 확장은 검사하지 않으므로, 스스로 캡처한 `commands/mine.md`와 `skills/mine/`은 여전히 함께 동기화되어 같은 방식으로 충돌할 수 있습니다.
293
295
 
294
296
  | 이렇게 말하면 | 트리거되는 스킬 |
295
297
  |---|---|
package/README.md CHANGED
@@ -287,7 +287,9 @@ Every maintenance command also runs from a plain shell, which is what you need w
287
287
 
288
288
  ### Claude Agent Skills
289
289
 
290
- The six synthesis-heavy commands (`ingest`, `query`, `crystallize`, `lint`, `verify`, `graph`) are also exposed as Claude Agent Skills in `skills/<name>/SKILL.md`, so they auto-trigger when the conversation matches their description, with no slash command required. A seventh, `debate`, is a skill only: it has no file under `commands/`, but the plugin loads `skills/` too, so `/hypo:debate` works like the rest. It runs a structured three-phase review (interrogate, verify, synthesize) to re-verify a wiki claim or harden a hard-to-reverse decision into an ADR. It is absent from the command tables above because those count the files under `commands/`, not what you can type.
290
+ Every file under `commands/` is itself a Claude Agent Skill, so any of them auto-triggers when the conversation matches its description, with no slash command required. (Measured on a plugin install; whether the npm path's `~/.claude/commands/hypo/` copies auto-trigger the same way has not been measured.) You will notice this most on the synthesis-heavy ones (`ingest`, `query`, `crystallize`, `lint`, `verify`, `graph`). `debate` is the one that lives the other way around, in `skills/debate/SKILL.md` with no file under `commands/`; the plugin loads `skills/` too, so `/hypo:debate` works like the rest. It runs a structured three-phase review (interrogate, verify, synthesize) to re-verify a wiki claim or harden a hard-to-reverse decision into an ADR. It is absent from the command tables above because those count the files under `commands/`, not what you can type.
291
+
292
+ Each name ships from exactly one of the two directories. A flat `commands/<name>.md` and a directory `skills/<name>/SKILL.md` both claim `/hypo:<name>`, and shipping the pair does not give you one surface for people and another for the model: only one wins and the other is dead weight. `npm run smoke:plugin` fails on a collision. That gate covers this package's own `commands/` and `skills/`; your own extensions under `~/hypomnema/extensions/` are not checked, so a `commands/mine.md` and a `skills/mine/` you captured yourself can still both sync and collide the same way.
291
293
 
292
294
  | Say this | Skill it triggers |
293
295
  |---|---|
@@ -29,6 +29,7 @@ When `--project` is omitted, the script prefers the project whose `working_dir`
29
29
  ## Step 2 — Present session state
30
30
 
31
31
  Show the output from the script:
32
+ - **`[WIKI: ...]` notice**, if present, first and verbatim. It names uncommitted changes elsewhere in the vault (another project, or unattributable to any project) that are not this session's scope. Do not fold this line into the project summary or fetch/edit the paths it names without the user's explicit instruction.
32
33
  - **Project** name
33
34
  - **Next tasks** from `session-state.md`
34
35
  - **Background** from `hot.md` (what was done last session, condensed)
@@ -47,7 +47,7 @@ hypomnema/
47
47
  │ ├── frontmatter.mjs
48
48
  │ ├── hypo-root.mjs
49
49
  │ └── hypo-ignore.mjs
50
- ├── skills/ ← Agent Skills — skills/<name>/SKILL.md
50
+ ├── skills/ ← directory-form Agent Skills (skills/<name>/SKILL.md)
51
51
  ├── templates/ ← baseline files copied on init
52
52
  │ ├── hypo-config.md, index.md, hot.md, log.md, SCHEMA.md, hypo-guide.md
53
53
  │ ├── Home.md, Overview.md, hypo-automation.md, hypo-help.md
@@ -79,15 +79,29 @@ Each user-facing operation is a pair: an LLM-facing prompt + a Node.js script.
79
79
 
80
80
  **Commands shipped in v1.1:** `init`, `doctor`, `upgrade`, `uninstall`, `ingest`, `query`, `crystallize`, `resume`, `feedback`, `verify`, `lint`, `stats`, `graph`.
81
81
 
82
- The synthesis-heavy commands (`ingest`, `query`, `crystallize`, `lint`, `verify`, `graph`) are also exposed as Agent Skills.
82
+ Every one of those files is itself an Agent Skill; see below.
83
83
 
84
84
  ---
85
85
 
86
86
  ## Agent Skills
87
87
 
88
- `skills/<name>/SKILL.md` follows the Claude Agent Skills convention. When a conversation matches the skill's description, Claude auto-loads it without needing the slash command.
88
+ A flat `commands/<name>.md` and a directory `skills/<name>/SKILL.md` are the same kind of
89
+ component to the plugin loader: both are Agent Skills, both claim `/hypo:<name>`, and both
90
+ auto-load when a conversation matches their `description`. (That is measured on a plugin
91
+ install. `init`/`upgrade` do not deploy the plugin's own `skills/` at all, so on the npm path
92
+ only `commands/` is installed — into `~/.claude/commands/hypo/` — and whether those copies
93
+ auto-trigger the same way has not been measured.) The directory form is what a skill needs when
94
+ it carries more than one file (`skills/debate/references/`); the flat form is everything else.
89
95
 
90
- > v1.0 originally planned flat `skills/*.md` files; v1.1 switched to `<name>/SKILL.md` for compatibility with the official Agent Skills loader.
96
+ **One name ships from one directory, never both** (in this package; the extensions channel
97
+ under `~/hypomnema/extensions/` is not covered by the gate and can still sync a co-named pair)**.** Shipping a pair does not give you a
98
+ human-facing surface and a model-facing surface: nothing in the frontmatter separates them, only
99
+ one wins, and the loser is dead weight that still costs always-on tokens and still appears in
100
+ `claude plugin details`. Six pairs shipped that way from v1.0.0 until 2026-09-04, and `commands/`
101
+ won every one of them. `scripts/smoke-plugin.mjs` now fails the build on a collision.
102
+
103
+ > v1.0 originally planned flat `skills/*.md` files and shipped `<name>/SKILL.md` instead; both
104
+ > forms have loaded since v1.0.0. The 2026-09-04 correction is that they are not two surfaces.
91
105
 
92
106
  ---
93
107
 
@@ -385,7 +399,7 @@ If `git status` shows no `.md` changes, the diff step is skipped — Stop hook f
385
399
 
386
400
  ### Citation convention
387
401
 
388
- The six writer-side skills (`crystallize`, `query`, `ingest`, `verify`, `graph`, `lint`) carry an identical footer instructing Claude to cite wiki pages inline as `[[page-slug]]`, which keeps them connected in the graph. The observability audit does not scan for these inline citations: it scores sessions on tool and command usage recorded by `hypo-session-record` (search / ingest / feedback counts). Counting citations as a signal is a possible future iteration, not current behavior.
402
+ **The convention is current.** It is stated in `commands/query.md`, in `commands/crystallize.md`, and in `templates/hypo-guide.md` §8, which `init` writes into a vault that does not already have it (an existing copy is left alone). What went away on 2026-09-04 is one particular copy of it: an identical footer written into six `skills/*/SKILL.md` files, which were the losing half of the name collision above and never reached anyone. Whether to repeat that footer in the remaining `commands/*.md` is open. The observability audit never depended on it either way: it scores sessions on tool and command usage recorded by `hypo-session-record` (search / ingest / feedback counts), not on inline citations.
389
403
 
390
404
  ---
391
405
 
@@ -45,12 +45,12 @@ See [ARCHITECTURE.md](ARCHITECTURE.md) for the full breakdown. Short version:
45
45
 
46
46
  | Path | What lives there |
47
47
  |---|---|
48
- | `commands/` | LLM-facing prompts for `/hypo:*` slash commands |
48
+ | `commands/` | LLM-facing prompts for `/hypo:*` slash commands — each is also an Agent Skill (auto-triggers on its `description`) |
49
49
  | `scripts/` | Node.js implementations called by the commands |
50
50
  | `scripts/lib/` | Shared helpers for scripts (`frontmatter`, `hypo-root`, `hypo-ignore`) |
51
51
  | `hooks/` | Lifecycle hooks + `hooks.json` registry |
52
52
  | `hooks/hypo-shared.mjs` | Shared hook utilities — read the deployment constraint below |
53
- | `skills/<name>/SKILL.md` | Agent Skills (auto-trigger via description match) |
53
+ | `skills/<name>/SKILL.md` | Directory-form Agent Skills — only for a skill needing more than one file (`skills/debate/references/`). The name must not collide with `commands/` |
54
54
  | `templates/` | Files copied into new wiki vaults on init |
55
55
  | `tests/` | Test suite (no external deps): `harness.mjs`, `helpers.mjs`, `runner.mjs` (entry), and one `<area>.test.mjs` per production area |
56
56
  | `docs/` | ARCHITECTURE, CONTRIBUTING |
@@ -77,7 +77,7 @@ Scripts under `scripts/` are not deployed and may freely import from `scripts/li
77
77
 
78
78
  1. Edit `commands/<name>.md` — the LLM-facing prompt.
79
79
  2. Edit `scripts/<name>.mjs` — the Node.js logic.
80
- 3. If the command is new and synthesis-heavy, add `skills/<name>/SKILL.md`.
80
+ 3. Do **not** add `skills/<name>/SKILL.md` for it. A `commands/*.md` file is already an Agent Skill and auto-triggers on its `description`; a same-named directory skill claims the same `/hypo:<name>`, and `npm run smoke:plugin` fails on the collision. To change when it auto-triggers, edit that `description`.
81
81
  4. Update the command table in `README.md` and `README.ko.md`.
82
82
  5. Add coverage to the matching `tests/<area>.test.mjs` (a new command usually means a new area file).
83
83
 
@@ -109,10 +109,17 @@ If you need to share new logic, prefer extending an existing helper over adding
109
109
 
110
110
  ### Adding an Agent Skill
111
111
 
112
- 1. Create `skills/<name>/SKILL.md`.
113
- 2. The frontmatter must include `name`, `description`, and the trigger criteria.
112
+ Use the directory form only when the skill needs more than one file (`skills/debate/references/`).
113
+ Everything else belongs in `commands/`, which loads the same way.
114
+
115
+ 1. Create `skills/<name>/SKILL.md`. **The name must not collide with any `commands/<name>.md`** —
116
+ both claim `/hypo:<name>`, only one wins, and `npm run smoke:plugin` fails on the pair.
117
+ 2. The frontmatter must include a `description` carrying the trigger criteria. `name` is
118
+ optional — omit it and the directory name is the invocation name (`skills/debate/SKILL.md`
119
+ does this). If you do set it, it must equal the directory name; `smoke:plugin` fails otherwise.
114
120
  3. The skill body is the LLM prompt — keep it focused on the synthesis task.
115
- 4. Add to the skills inventory in `README.md`.
121
+ 4. Describe it in the **Claude Agent Skills** section of `README.md` and `README.ko.md`, the way
122
+ `debate` is described there. (There is no separate skills inventory table.)
116
123
 
117
124
  ---
118
125
 
@@ -484,6 +484,170 @@ function pendingProposalNotice() {
484
484
  return '';
485
485
  }
486
486
  }
487
+ // ── foreign-project uncommitted notice ──────────────────────────────────────
488
+ // Same signal precompactGateStatus already computes for its own gate
489
+ // (closeAccountableScope / sessionTouchTrusted, hypo-shared.mjs), surfaced
490
+ // here instead for the AGENT reading additionalContext: a project this
491
+ // session isn't scoped to may still have uncommitted changes sitting in the
492
+ // shared vault, and without this line the agent has no way to tell those
493
+ // apart from its own unfinished work. Cannot import hypo-shared.mjs's
494
+ // `projectOfPath` / `gitDirtyFiles` here, not a technical constraint (both
495
+ // are simply not exported), but an ownership decision: hypo-shared.mjs
496
+ // belongs to a different lane, so this hook keeps a local, self-contained
497
+ // duplicate rather than adding an export for it. The cost is real: a future
498
+ // fix to gitDirtyFiles (e.g. its rename re-attribution) does not propagate
499
+ // here automatically.
500
+
501
+ const FOREIGN_GIT_TIMEOUT_MS = 3000;
502
+ // Cap on how many foreign project names the notice spells out. Past this the
503
+ // rest collapse into a count, so one session cannot grow the prompt by however
504
+ // many projects are dirty.
505
+ const FOREIGN_NAME_CAP = 5;
506
+
507
+ /** `projects/<slug>/...` → `<slug>`; everything else → null. `null` here does
508
+ * not mean "not one project's work": the caller below folds every non-null
509
+ * hit into a per-name foreign count and every null hit into a nameless
510
+ * "unattributed" count, and both feed the same notice. Not the same
511
+ * classification hypo-auto-commit.mjs's commit message uses for its own
512
+ * "(N paths across M projects)" count (hypo-shared.mjs's private
513
+ * `projectOfPath`): that one folds a non-`projects/` path to its first path
514
+ * segment (`extensions`, `hot.md`, ...) for a tally; this one folds it to
515
+ * `null` because attribution, not tallying, is the job here. A top-level
516
+ * segment is not a project name, and this notice must not present it as
517
+ * one.
518
+ */
519
+ function projectOfPath(relPath) {
520
+ const parts = relPath.split('/');
521
+ return parts[0] === 'projects' && parts.length > 1 && parts[1] ? parts[1] : null;
522
+ }
523
+
524
+ /** Vault-relative dirty paths (tracked + untracked), normalized to be
525
+ * relative to `hypoDir` itself via `git rev-parse --show-prefix` (empty when
526
+ * `hypoDir` IS the repo top level), the same normalization
527
+ * hypo-shared.mjs's `gitDirtyFiles` applies for staging correctness: without
528
+ * it, a vault nested under a larger host repo reports paths relative to that
529
+ * repo's top level, and every one of them would fail to classify as this
530
+ * vault's own. NUL-separated porcelain so Korean project/page names survive
531
+ * intact. This also re-attributes a rename/copy's `from` path (codex
532
+ * 3rd-round review follow-up), the same as gitDirtyFiles does for
533
+ * staging correctness, so a rename OUT of a foreign project is not silently
534
+ * lost just because its destination happens to land under `ownProject`.
535
+ *
536
+ * Returns `null`, not `[]`, on any git failure (repo missing, `rev-parse` or
537
+ * `status` non-zero, or a timeout): folding "cannot enumerate" into the same
538
+ * empty array a truly clean repo returns would render the two identically,
539
+ * which is the silent failure this notice exists to catch (mirrors
540
+ * `gitDirtyFiles`'s own contract: "an empty return here just means 'cannot
541
+ * attribute', not 'clean'"). A clean repo returns `[]`.
542
+ */
543
+ function listDirtyPaths(hypoDir) {
544
+ const prefixRes = spawnSync('git', ['-C', hypoDir, 'rev-parse', '--show-prefix'], {
545
+ encoding: 'utf-8',
546
+ timeout: FOREIGN_GIT_TIMEOUT_MS,
547
+ });
548
+ if (prefixRes.status !== 0) return null;
549
+ // trimEnd(), not trim(): the prefix is a real path segment, and a leading
550
+ // space or control char in a directory name is valid there. trim() would
551
+ // strip it off the front, so the stripped prefix no longer matches the
552
+ // (untouched) start of every path `git status` reports, and every path
553
+ // under that directory would wrongly read as "outside the vault" (the
554
+ // notice going silent for exactly the same reason a missing rename `from`
555
+ // does below). Only the trailing `\n` `--show-prefix` always appends needs
556
+ // stripping.
557
+ const prefix = (prefixRes.stdout || '').trimEnd();
558
+
559
+ const r = spawnSync('git', ['-C', hypoDir, 'status', '--porcelain', '-uall', '-z'], {
560
+ encoding: 'utf-8',
561
+ timeout: FOREIGN_GIT_TIMEOUT_MS,
562
+ });
563
+ if (r.status !== 0) return null;
564
+ const out = [];
565
+ const records = (r.stdout || '').split('\0');
566
+ const toVaultRelative = (f) => {
567
+ if (!f) return null;
568
+ if (!prefix) return f; // hypoDir IS the repo top level, nothing to strip
569
+ return f.startsWith(prefix) ? f.slice(prefix.length) : null; // outside the vault
570
+ };
571
+ for (let i = 0; i < records.length; i++) {
572
+ const rec = records[i];
573
+ if (!rec) continue;
574
+ const xy = rec.slice(0, 2);
575
+ const file = rec.slice(3); // destination path for a rename/copy
576
+ const isRenameOrCopy = xy[0] === 'R' || xy[1] === 'R' || xy[0] === 'C' || xy[1] === 'C';
577
+ // A rename/copy emits a paired `to\0from` record. Attribute BOTH: the
578
+ // origin project lost a file just as surely as the destination gained
579
+ // one, and dropping `from` (as this used to) silently loses that origin
580
+ // whenever it differs from the destination's project (a rename INTO
581
+ // ownProject from a foreign one would otherwise vanish entirely). One
582
+ // rename/copy is therefore counted as up to 2 dirty paths, inflating
583
+ // foreignCount/unattributedCount by one per cross-project rename; the
584
+ // name Set below still de-dupes, so the project NAME list does not grow.
585
+ let fromFile = null;
586
+ if (isRenameOrCopy) {
587
+ i++;
588
+ fromFile = records[i] || null;
589
+ }
590
+ const rel = toVaultRelative(file);
591
+ if (rel) out.push(rel);
592
+ const relFrom = toVaultRelative(fromFile);
593
+ if (relFrom) out.push(relFrom);
594
+ }
595
+ return out;
596
+ }
597
+
598
+ /** One-line notice covering two counts: uncommitted paths under a named
599
+ * project other than `ownProject` (`projects/<slug>/...`, or, when
600
+ * `ownProject` is null, no cwd-matched project this session, so ANY named
601
+ * project counts), and uncommitted paths this classifier cannot attribute to
602
+ * any project at all (everything else, root vault infra, `extensions/`,
603
+ * `_specs/`, ...). This is attribution, not narrowing, so the
604
+ * unattributed bucket is surfaced with a count rather than silently dropped
605
+ * just because it has no project name to show. '' only when enumeration
606
+ * succeeded and both counts are zero, so the quiet path stays quiet exactly
607
+ * there. A `null` from `listDirtyPaths` (enumeration failed) gets its own
608
+ * distinct line instead: the caller must not read "could not tell" as
609
+ * "nothing foreign".
610
+ */
611
+ function foreignUncommittedNotice(hypoDir, ownProject) {
612
+ const dirty = listDirtyPaths(hypoDir);
613
+ if (dirty === null) {
614
+ return '[WIKI: 미커밋 변경의 귀속을 확인하지 못했습니다. git 상태를 근거로 작업 범위를 정하지 마십시오.]';
615
+ }
616
+ const foreignProjects = new Set();
617
+ let foreignCount = 0;
618
+ let unattributedCount = 0;
619
+ for (const f of dirty) {
620
+ const slug = projectOfPath(f);
621
+ if (slug === ownProject) continue;
622
+ if (slug) {
623
+ foreignProjects.add(slug);
624
+ foreignCount++;
625
+ } else {
626
+ unattributedCount++;
627
+ }
628
+ }
629
+ if (foreignCount === 0 && unattributedCount === 0) return '';
630
+ const clauses = [];
631
+ if (foreignCount > 0) {
632
+ // The slug comes from a directory name in `git status` output, so it is
633
+ // untrusted text on its way into a prompt. sanitizeProjForPrompt is the same
634
+ // guard the hot-cache notices in this file already use; skipping it here would
635
+ // let a newline or a control char in a project directory name break the
636
+ // one-line notice apart and inject into the surrounding context. The name list
637
+ // is also capped, because an unbounded one grows the context by however many
638
+ // projects happen to be dirty.
639
+ const all = [...foreignProjects].sort();
640
+ const shown = all.slice(0, FOREIGN_NAME_CAP).map((s) => `projects/${sanitizeProjForPrompt(s)}`);
641
+ const names =
642
+ all.length > FOREIGN_NAME_CAP
643
+ ? `${shown.join(', ')} 외 ${all.length - FOREIGN_NAME_CAP}개`
644
+ : shown.join(', ');
645
+ clauses.push(`현재 프로젝트 외 ${names} 변경 ${foreignCount}건`);
646
+ }
647
+ if (unattributedCount > 0) clauses.push(`귀속 불명 변경 ${unattributedCount}건`);
648
+ return `[WIKI: ${clauses.join(', ')}이 있습니다. 사용자 명시 지시 없이는 이 세션 작업으로 편입하지 마십시오.]`;
649
+ }
650
+
487
651
  const GLOBAL_HOT = join(HYPO_DIR, 'hot.md');
488
652
  const HOT_CHARS = 2000;
489
653
  const STATE_CHARS = 2000;
@@ -613,6 +777,17 @@ process.stdin.on('end', () => {
613
777
  const MARKER_FILE = sessionMarkerPath(sessionId);
614
778
  const hit = findProjectFiles(cwd);
615
779
 
780
+ // ownProject = the cwd-matched project (or null on a MISS, per
781
+ // foreignUncommittedNotice's docstring). Late-appended into `notices` /
782
+ // `noticePrefix`, same pattern the MISS branch's suggestLine uses below —
783
+ // both need `hit` first, which isn't resolved until this line.
784
+ const foreignNotice = foreignUncommittedNotice(HYPO_DIR, hit ? hit.proj : null);
785
+ if (foreignNotice) {
786
+ notices.push(foreignNotice);
787
+ noticePrefix = notices.length ? `${notices.join('\n\n')}\n\n` : '';
788
+ process.stderr.write(`\n\x1b[33m${foreignNotice}\x1b[0m\n`);
789
+ }
790
+
616
791
  // Observed-base snapshot for the write=proposal gate. Deliberately AFTER gitPull: the base must
617
792
  // describe the tree this session actually starts from, remote merges
618
793
  // included, or the first close would raise a proposal against content the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hypomnema",
3
- "version": "1.8.0",
3
+ "version": "1.8.1",
4
4
  "description": "LLM-native personal wiki system for Claude Code",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/scripts/init.mjs CHANGED
@@ -173,7 +173,11 @@ Init options:
173
173
  --lint-strict Opt-in: also gate the wiki pre-commit hook on
174
174
  \`lint --strict\` (promotes STRICT_PROMOTE_IDS warnings
175
175
  to a blocking error), sequenced after the .hypoignore
176
- guard. Off by default; re-run init to add or drop it.
176
+ guard. Exception: root hot.md/log.md's own
177
+ no-frontmatter warning stays a warn even under this
178
+ flag, so a legacy vault predating the frontmatter
179
+ convention still commits. Off by default; re-run init
180
+ to add or drop it.
177
181
  --dry-run Show what would be done without making changes
178
182
  --version Print the installed package version and exit
179
183
  --help, -h Show this help message