hypomnema 1.5.1 → 1.6.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.5.1",
14
+ "version": "1.6.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.5.1",
3
+ "version": "1.6.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
@@ -25,7 +25,7 @@ Andrej Karpathy의 "LLM 네이티브 위키" 스케치에서 출발했습니다.
25
25
 
26
26
  ### 지금 어디까지 자동인가
27
27
 
28
- 현재 릴리스는 v1.5.1입니다. 위키가 자기 신선도를 알리는 방식을 다듬었습니다. `verify_by_date`가 지난 페이지는 주입되는 순간 `[STALE ...]`로 표시돼, 날짜 지난 답을 조용히 신뢰하는 대신 눈에 띄게 하고, lookup 사용을 로컬에서 추적해 링크됐지만 한 번도 주입되지 않은 페이지를 crystallize가 cold 후보로 표면화합니다. 같은 릴리스에서 표준 `session | project: title` 콜론 로그 포맷을 stale로 오진하던 세션 마무리도 견고해졌습니다. 그 앞 v1.5.0은 머신 간 안정성과 일상의 마찰에 집중했습니다. git 동기화 볼트의 `cwd`-first resume, 코드 레포 세션에 위키 위치 안내, 세션 마무리가 무관한 lint 부채를 다시 나열하지 않는 것이었습니다.
28
+ 현재 릴리스는 v1.6.1입니다. 조건부나 유예된 세션 마무리에서 미커밋이나 진행 중인 작업이 남아 문구가 모호할 때 조기 nag 대신 지금 닫을지 되묻고, `working_dir` provenance를 강화해 앵커가 없던 프로젝트도 `cwd`-first resume이 앵커하도록 고쳤습니다. 그 앞 v1.6.0은 역방향 확장 capture를 완성했습니다. `hypomnema capture`(그리고 `/hypo:capture` 슬래시 커맨드)가 `~/.claude/` 아래에 보통 방식으로 만든 command·agent·hook을 위키로 끌어와, forward-sync가 다른 머신에 원래 이름 그대로 전파합니다. 손으로 만든 확장의 "한 머신에서 등록, 다른 머신에서 동기화" 갭을 데몬 없이 메웁니다. 그 앞 v1.5.1은 위키가 자기 신선도를 알리는 방식을 다듬었습니다. `verify_by_date`가 지난 페이지는 주입되는 순간 `[STALE ...]`로 표시되고, lookup 사용을 로컬에서 추적해 링크됐지만 한 번도 주입되지 않은 페이지를 crystallize가 cold 후보로 표면화하며, 표준 `session | project: title` 콜론 로그 포맷을 stale로 오진하던 세션 마무리도 견고해졌습니다. 다시 그 앞 v1.5.0은 머신 간 안정성에 집중했습니다. git 동기화 볼트의 `cwd`-first resume 코드 레포 세션에 위키 위치를 안내하는 것이었습니다.
29
29
 
30
30
  위키 작업(자료 정리·검색·세션 마무리)은 아직 `/hypo:*` 명령이나 자연어로 시작합니다. v2의 목표는 Claude가 시키지 않아도 위키를 읽고 쓰고 합성하는 완전 자율 동작이고, 지금 그쪽으로 가는 중입니다.
31
31
 
@@ -33,6 +33,7 @@ Andrej Karpathy의 "LLM 네이티브 위키" 스케치에서 출발했습니다.
33
33
 
34
34
  - 피드백 한 곳만 고치면 나머지는 따라옵니다. `pages/feedback/`에 한 번 적으면 위키가 `MEMORY.md`와 `~/.claude/CLAUDE.md`의 `<learned_behaviors>` 블록을 자동으로 갱신합니다(단방향 projection).
35
35
  - 확장 파일도 함께 동기화. 위키 안 `~/hypomnema/extensions/{agents,commands,hooks,skills}/`에 둔 파일을 `~/.claude/`에 자동 반영합니다. `--codex`를 붙이면 `hooks`·`commands`는 `~/.codex/`에도 반영하지만, `agents`와 `skills`는 Claude 전용이라 건너뜁니다.
36
+ - 역방향 capture는 반대로 갑니다. `hypomnema capture`(또는 `/hypo:capture`)는 `~/.claude/{commands,agents}/`에 보통 방식으로 만든 command·agent나 `~/.claude/settings.json`에 등록한 정규형 hook을 위키로 끌어와, 이후 다른 머신에 원래 이름 그대로 동기화되게 합니다. hook은 정규형으로 무손실 라운드트립되는 것만 capture하고, 나머지는 이유와 함께 건너뜁니다. capture는 명시적이며 내용이 다른 위키 파일은 덮어쓰지 않습니다. skill은 아직 capture 대상이 아닙니다.
36
37
  - 프로젝트 자동 생성. 프로젝트 표식(`package.json`·`Cargo.toml` 등)이 있는 git 저장소로 `cd` 했는데 대응하는 위키 프로젝트가 없으면 만들지 물어봅니다.
37
38
  - 세션 종료 자동 정리와 `/clear` 복구. 의미 있는 세션이 끝나면 "마무리 메모를 짧게 남길까요?"가 자동으로 뜹니다. 마무리하지 않은 채 `/clear`를 입력해도 다음 세션 시작 때 이어서 정리할 수 있습니다.
38
39
 
package/README.md CHANGED
@@ -25,7 +25,7 @@ New to the terms below? Keep the [Term decoder](#term-decoder) open in another t
25
25
 
26
26
  ### Where automation stands today
27
27
 
28
- The current release is v1.5.1. It sharpens how the wiki signals its own freshness: a page whose `verify_by_date` has passed is flagged `[STALE ...]` at the moment it is injected, so a dated answer is visible instead of trusted silently, and lookup usage is tracked locally so crystallize can surface a linked-but-never-injected page as a cold candidate. The same release makes session close robust to the standard `session | project: title` colon log format that had been misread as stale. The v1.5.0 line turned to cross-machine reliability and everyday friction before it: `cwd`-first resume on a git-synced vault, a code-repo session told where the wiki lives, and session close no longer re-listing unrelated lint debt.
28
+ The current release is v1.6.1. It fixes session close so a conditional or deferred wrap-up ("once X is done, wrap up") that still has uncommitted or in-flight work asks you whether to close now instead of nagging prematurely, and it hardens `working_dir` provenance so `cwd`-first resume can anchor a project that was missing its anchor. The v1.6.0 release before it completed reverse extension capture: `hypomnema capture` (and the `/hypo:capture` slash command) pulls a command, agent, or hook you created the normal way under `~/.claude/` into the wiki, so forward-sync propagates it to your other machines under its original name. That closes the "register on one machine, sync on another" gap for extensions you author by hand, without a daemon. The v1.5.1 release before it sharpened how the wiki signals its own freshness: a page whose `verify_by_date` has passed is flagged `[STALE ...]` the moment it is injected, lookup usage is tracked locally so crystallize can surface a linked-but-never-injected page as a cold candidate, and session close was made robust to the standard `session | project: title` colon log format that had been misread as stale. The v1.5.0 line before that turned to cross-machine reliability: `cwd`-first resume on a git-synced vault and a code-repo session told where the wiki lives.
29
29
 
30
30
  Wiki work (ingest, query, session-close) still starts from an explicit `/hypo:*` command or plain language. The v2 goal is full autonomy: Claude reading, writing, and synthesizing the wiki without being asked, which is the direction this is heading.
31
31
 
@@ -33,6 +33,7 @@ The lanes that already run on their own: v1.1.0 shipped the observability score
33
33
 
34
34
  - Edit feedback in one place and the rest follows. `pages/feedback/` is the single source of truth for behavior corrections, and Hypomnema derives `MEMORY.md` and the `<learned_behaviors>` block inside `~/.claude/CLAUDE.md` from it automatically.
35
35
  - Extensions sync alongside. Anything under `~/hypomnema/extensions/{agents,commands,hooks,skills}/` is mirrored into `~/.claude/` automatically. The `--codex` flag also mirrors `hooks` and `commands` into `~/.codex/`; `agents` and `skills` are Claude-only and skipped on purpose.
36
+ - Reverse capture goes the other way. `hypomnema capture` (or `/hypo:capture`) pulls a command or agent you made the normal way under `~/.claude/{commands,agents}/`, or a canonical hook you registered in `~/.claude/settings.json`, into the wiki, so it then syncs to your other machines under its original name. Hooks are captured only when they round-trip losslessly to the canonical form; others are skipped with a reason. Capture is explicit and never overwrites a differing wiki file. Skills are not captured yet.
36
37
  - Auto-project creation. 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.
37
38
  - Session-close cleanup and `/clear` recovery. After a non-trivial session, a "save a minimal session-close note?" prompt appears automatically; a `/clear` after a forgotten close is detected and recovered at the next session start.
38
39
 
@@ -0,0 +1,40 @@
1
+ ---
2
+ description: Pull extensions you made the normal way under ~/.claude/{commands,agents}, or a canonical hook registered in ~/.claude/settings.json, into the wiki so they sync to your other machines. Use when the user wants to capture, adopt, or back up a locally-authored slash command, agent, or hook into Hypomnema.
3
+ ---
4
+
5
+ You are running `/hypo:capture`. Bring a command or agent that the user created the normal way (directly under `~/.claude/commands/` or `~/.claude/agents/`), or a hook they registered in `~/.claude/settings.json`, into the wiki `extensions/` tree so the existing forward-sync propagates it to their other machines under its original name.
6
+
7
+ Scope: commands, agents, and hooks. Commands and agents are enumerated from `~/.claude/{commands,agents}/`; hooks are read from the `~/.claude/settings.json` registration. A hook is captured only when its command is the canonical `node $HOME/.claude/hooks/<name>.mjs` form and it round-trips losslessly (original event, matcher, and timeout preserved); non-canonical or lossy registrations are skipped with a reason instead of being rewritten. Skills are not captured yet.
8
+
9
+ ## Step 1: Resolve the package root
10
+
11
+ Bundled scripts here run via `${CLAUDE_PLUGIN_ROOT}/scripts/`. To resolve that package root: if `${CLAUDE_PLUGIN_ROOT}` is already an absolute path, use it; otherwise read `pkgRoot` from `~/.claude/hypo-pkg.json` (only when non-empty and the target script exists under it); otherwise use the `hypo@hypomnema` (or legacy `hypomnema@hypomnema`) installPath in `~/.claude/plugins/installed_plugins.json`; if none resolve, stop and tell the user to run `hypomnema capture` or reinstall instead of guessing the cache layout.
12
+
13
+ If the user specified a Hypomnema directory, pass it as `--hypo-dir="<path>"`. Otherwise omit the flag.
14
+
15
+ ## Step 2: List what can be captured
16
+
17
+ ```bash
18
+ node ${CLAUDE_PLUGIN_ROOT}/scripts/capture.mjs [--hypo-dir="<path>"]
19
+ ```
20
+
21
+ With no names and no `--all`, the script lists capturable candidates and stops. Candidates come from two sources: commands and agents are the unowned regular `.md` files under `~/.claude/{commands,agents}/`, and hooks are the canonical `node $HOME/.claude/hooks/<name>.mjs` entries registered in `~/.claude/settings.json`. It excludes anything already managed by the wiki, the reserved `hypo-*` namespace, symlinks and other non-regular files, core hooks, and any hook whose registration would not round-trip losslessly. Skipped hook registrations are printed with the reason; unowned commands and agents that fail these filters are simply omitted from the list, without a per-item reason.
22
+
23
+ Show the list. Note that these are unowned candidates, not a provenance check: explicit selection is the trust boundary. A third-party tool's command, agent, or hook could appear here, so let the user pick deliberately.
24
+
25
+ ## Step 3: Capture the user's selection
26
+
27
+ Ask which ones to capture, then run with the chosen names (space-separated), or `--all` if the user wants everything listed:
28
+
29
+ ```bash
30
+ node ${CLAUDE_PLUGIN_ROOT}/scripts/capture.mjs [--hypo-dir="<path>"] <name> [<name> ...]
31
+ node ${CLAUDE_PLUGIN_ROOT}/scripts/capture.mjs [--hypo-dir="<path>"] --all
32
+ ```
33
+
34
+ A name is the filename (`mycmd.md`, or `myhook.mjs` for a hook), its stem (`mycmd`), or the `type/file` form (`commands/mycmd.md`, `hooks/myhook.mjs`). Add `--dry-run` to preview without writing.
35
+
36
+ Each capture stores the file in the wiki as `extensions/<type>/hypo-ext-<name>.<ext>` (`.md` for commands and agents, `.mjs` for hooks) with a sidecar `hypo-ext-<name>.manifest.json` that records the original install name (and, for a hook, its event, matcher, and timeout), then adopts it so the currently-installed copy is left in place and tracked. A wiki file that already exists with different content (or a mismatched manifest) is refused, not overwritten.
37
+
38
+ ## Step 4: Commit the wiki, then sync elsewhere
39
+
40
+ Show the script output verbatim. When something was captured, remind the user to commit and push the wiki, then run `hypomnema upgrade --apply` on their other machine to install the captured extension under its original name.
@@ -331,6 +331,17 @@ from being dropped.
331
331
  Prerequisite: the changelog collector in step 2 reads merged-PR data through the GitHub CLI, so have `gh` installed and authenticated (`gh auth status`) before you start. It is a maintainer-only release script and is not shipped to npm.
332
332
 
333
333
  ```bash
334
+ # 0. (minor+ releases only) Ensure the wiki vault carries this release's version
335
+ # spec. Convention (version-plan lifecycle: born-at-stable-path, flip-in-place,
336
+ # recorded in the vault's decisions log): a minor-or-higher release has
337
+ # projects/hypomnema/specs/spec-v<version>.md with status: active and
338
+ # version: <version>. If it is missing, create it from the vault's minimal spec
339
+ # shape (omit patch-only fields for a minor/major). If you created or changed
340
+ # it, run the wiki lint and commit + push the vault before bumping, so the
341
+ # active pre-ship state is recorded remotely. Patch releases are exempt: the
342
+ # CHANGELOG section and PR body cover them, no spec doc required.
343
+ $EDITOR ~/hypomnema/projects/hypomnema/specs/spec-v<version>.md
344
+
334
345
  # 1. Bump the version across package.json, plugin.json, marketplace.json,
335
346
  # and templates/hypo-config.md. Takes a concrete semver (not patch/minor/major).
336
347
  node scripts/bump-version.mjs <new-semver> # e.g. 1.2.2 or 1.3.0-rc.1
@@ -397,6 +408,13 @@ npm publish --dry-run --access public # packs + prepublishOnly, NO registry PU
397
408
  # 8. Push the SPECIFIC tag alone — NOT --tags (that would push stale local tags
398
409
  # and could trigger releases for versions you did not intend).
399
410
  git push origin v<version>
411
+
412
+ # 9. (minor+ releases only) After the release workflow succeeds, close the spec
413
+ # lifecycle IN PLACE (no path move): set status: archived and
414
+ # archived_date: <release date> in the same spec, run the wiki lint, then
415
+ # commit and push the vault. Do this only after the release is green, never at
416
+ # tag time: a failed release must not leave an archived spec behind.
417
+ $EDITOR ~/hypomnema/projects/hypomnema/specs/spec-v<version>.md
400
418
  ```
401
419
 
402
420
  The `release.yml` workflow then:
@@ -32,6 +32,19 @@
32
32
  * "오늘 여기까지", "wrap up", "session close"). last_assistant_message is NOT
33
33
  * used — "커밋했습니다"/"작업 완료" type phrases produce false positives.
34
34
  *
35
+ * Reconfirm branch (conditional-close-reconfirm, added after the close-intent
36
+ * gate above was found to be unable to distinguish "close now" from "close
37
+ * once X is done" by regex alone — same sentence shape either way). When the
38
+ * close-intent gate above fires AND the session has a work-incomplete signal
39
+ * (an uncommitted wiki, or an in-flight delegated subagent per
40
+ * hasInFlightSubagent), step 6's block reason is replaced with an
41
+ * AskUserQuestion instruction instead of a crystallize command — the model
42
+ * asks the user "close now?" rather than deciding for them. A correlated
43
+ * "아직"/"나중"/"not yet"/"later" answer (isCloseReconfirmDeclined) suppresses
44
+ * the NEXT such block via `continue`, until a new user close signal re-arms
45
+ * it. This does not touch step 4's isClosePattern gate itself, and does not
46
+ * change the marker-writer's own close-file-status gate.
47
+ *
35
48
  * The hook NEVER writes the marker — even in the loop-guard branch. Writer
36
49
  * authority lives in `scripts/crystallize.mjs` (`--apply-session-close
37
50
  * --session-id=X` or standalone `--mark-session-closed --session-id=X`),
@@ -52,13 +65,47 @@ import {
52
65
  isClosePattern,
53
66
  isGateSkipped,
54
67
  precompactGateStatus,
68
+ hasInFlightSubagent,
69
+ isCloseReconfirmDeclined,
70
+ CLOSE_RECONFIRM_MARK,
55
71
  } from './hypo-shared.mjs';
56
72
 
57
73
  function emitContinue() {
58
74
  console.log(JSON.stringify({ continue: true, suppressOutput: true }));
59
75
  }
60
76
 
61
- function emitBlock(sessionId, transcriptPath, gate = null) {
77
+ function emitBlock(sessionId, transcriptPath, gate = null, opts = {}) {
78
+ // Reconfirm branch (work-incomplete + close-intent, ambiguous "now" vs
79
+ // "later"): the model must NOT decide unilaterally. Ask the user via
80
+ // AskUserQuestion instead of naming a marker/crystallize command — doing
81
+ // so here would let the model "resolve" the ambiguity by just running the
82
+ // close, which is exactly the silent-close-on-uncommitted-work failure
83
+ // mode this branch exists to stop. See spec/plan
84
+ // (specs/conditional-close-reconfirm/). The close-now option label below
85
+ // (CLOSE_RECONFIRM_MARK) must stay in sync with hypo-shared.mjs
86
+ // isCloseReconfirmDeclined's correlation check — that coupling is
87
+ // load-bearing (a differently-labeled option would leave the model's
88
+ // AskUserQuestion uncorrelated, so even an explicit user decline could not
89
+ // be recognized as such). Both sides import the same constant so a reworded
90
+ // label can't drift out of sync silently. The decline label ("아직, 계속")
91
+ // stays a literal here — it is matched by the tolerant DECLINE regex in
92
+ // isCloseReconfirmDeclined, not by an exact-mark check.
93
+ if (opts.reconfirm) {
94
+ console.log(
95
+ JSON.stringify({
96
+ decision: 'block',
97
+ reason:
98
+ `[WIKI_AUTOCLOSE] close 신호가 잡혔지만 아직 커밋되지 않은 변경(또는 진행 중인 ` +
99
+ `위임 작업)이 있어 지금 닫을지 뒤로 미룰지 모호합니다 (session_id=${sessionId}). ` +
100
+ `임의로 닫지 말고 AskUserQuestion으로 사용자에게 지금 세션을 닫을지 물어보세요. ` +
101
+ `선택지는 "${CLOSE_RECONFIRM_MARK}"와 "아직, 계속"으로 제시합니다. 사용자가 ` +
102
+ `"${CLOSE_RECONFIRM_MARK}"를 고른 뒤에만 세션 마무리를 진행하세요. 그전에는 ` +
103
+ `마커를 쓰거나 종료 명령을 실행하지 마세요.`,
104
+ stopReason: 'session-close incomplete (Layer 3, reconfirm)',
105
+ }),
106
+ );
107
+ return;
108
+ }
62
109
  // One-line recovery action. The gate-precise branches below name an EXACT
63
110
  // command so "only the marker is missing" stays a one-shot fix — but that
64
111
  // command must be a runnable `node <pkg>/scripts/crystallize.mjs` invocation,
@@ -199,7 +246,27 @@ process.stdin.on('end', () => {
199
246
  gate = null;
200
247
  }
201
248
 
202
- emitBlock(sessionId, transcriptPath, gate);
249
+ // Reconfirm decision (conditional-close-reconfirm): "close" and
250
+ // "work-incomplete" together are exactly the case where the transcript's
251
+ // NL close phrase can't tell "close now" from "close once X is done" —
252
+ // regex can't disambiguate this (see spec background). Narrowed to the
253
+ // two work-incomplete signals only: an uncommitted wiki (`git` blocker;
254
+ // real unsaved work) OR an in-flight delegated subagent. `hot` / `lint` /
255
+ // `close` / `design-history` / `feedback` blockers are real close
256
+ // blockers but not evidence of "later" intent, so they keep the existing
257
+ // wording (unchanged from before this feature).
258
+ const workIncomplete =
259
+ (gate && gate.blockers && gate.blockers.some((b) => b.type === 'git')) ||
260
+ hasInFlightSubagent(payload);
261
+ if (workIncomplete && isCloseReconfirmDeclined(transcriptPath)) {
262
+ // The user already answered "아직" to the ambiguous close signal that
263
+ // triggered this Stop turn — suppressing is a continue, not a
264
+ // (differently-worded) block, so it cannot live inside emitBlock.
265
+ emitContinue();
266
+ return;
267
+ }
268
+
269
+ emitBlock(sessionId, transcriptPath, gate, { reconfirm: workIncomplete });
203
270
  } catch (err) {
204
271
  // Fail-open on any unexpected error.
205
272
  process.stderr.write(`[hypo-auto-minimal-crystallize] error: ${err?.message ?? String(err)}\n`);
@@ -16,6 +16,9 @@ import {
16
16
  sessionMarkerPath,
17
17
  shouldSuggestProjectCreation,
18
18
  buildProjectSuggestionLine,
19
+ shouldSuggestBackfill,
20
+ buildBackfillSuggestionLine,
21
+ findBackfillCandidate,
19
22
  recordSuggestionCooldown,
20
23
  sanitizeProjForPrompt,
21
24
  pickProjectByCwd,
@@ -127,10 +130,25 @@ process.stdin.on('end', () => {
127
130
  return;
128
131
  }
129
132
 
130
- // MISS: cwd matches no project. Offer to create one
131
- // when the trigger conditions hold. Same nudge-only model as session-start.
133
+ // MISS: cwd matches no project's working_dir. Two distinct causes surface
134
+ // two distinct offers:
135
+ // - cwd's leaf directory name already IS an existing projects/<slug>/
136
+ // with session artifacts but no working_dir anchor → offer to
137
+ // BACKFILL the anchor (a duplicate new-project offer would be wrong).
138
+ // - otherwise, the usual create-a-new-project nudge, same as before.
139
+ // Same nudge-only model as session-start either way.
132
140
  let suggestPrefix = '';
133
- if (shouldSuggestProjectCreation(newCwd, HYPO_DIR)) {
141
+ const backfillCandidate = findBackfillCandidate(newCwd, HYPO_DIR);
142
+ if (backfillCandidate && shouldSuggestBackfill(newCwd, HYPO_DIR)) {
143
+ const suggestLine = buildBackfillSuggestionLine(
144
+ backfillCandidate.slug,
145
+ newCwd,
146
+ backfillCandidate.hasIndex,
147
+ );
148
+ suggestPrefix = `${suggestLine}\n\n`;
149
+ recordSuggestionCooldown(HYPO_DIR, newCwd);
150
+ process.stderr.write(`\n\x1b[33m${suggestLine}\x1b[0m\n`);
151
+ } else if (shouldSuggestProjectCreation(newCwd, HYPO_DIR)) {
134
152
  const suggestLine = buildProjectSuggestionLine(newCwd);
135
153
  suggestPrefix = `${suggestLine}\n\n`;
136
154
  recordSuggestionCooldown(HYPO_DIR, newCwd);
@@ -624,6 +624,80 @@ export function collectProjectWorkingDirs(hypoDir) {
624
624
  return out;
625
625
  }
626
626
 
627
+ // A project without a working_dir anchor (no index.md, or an index.md missing
628
+ // the field) never enters collectProjectWorkingDirs' universe, so cwd-first
629
+ // resume always MISSes for it — the SessionStart/CwdChanged MISS branch would
630
+ // otherwise only ever offer to create a brand-new (duplicate) project. Match
631
+ // the cwd's LEAF basename only (not every ancestor): the anchor being written
632
+ // is `working_dir: <cwd>` itself, so matching an ancestor directory would
633
+ // backfill the wrong (parent) path. Since projects/<slug>/ names are unique on
634
+ // disk, a leaf-basename match identifies at most one project — no separate
635
+ // uniqueness gate is needed here (unlike pickProjectByCwd's cross-machine tier
636
+ // 2, which reasons over synced *paths* that can collide).
637
+ //
638
+ // Known bound: a session whose cwd is a SUBDIRECTORY of the anchorless
639
+ // project's root (rather than the root itself) will not match — this is a
640
+ // deliberate scope limit, not a bug: guessing the project from a mid-tree cwd
641
+ // would risk writing an anchor to the wrong (non-root) path.
642
+ //
643
+ // @param {string} cwd
644
+ // @param {string} [hypoDir=HYPO_DIR]
645
+ // @returns {{slug: string, hasIndex: boolean}|null} the anchorless project
646
+ // this cwd's leaf basename names, or null when there is no match, no
647
+ // session artifacts, or the project already carries a working_dir.
648
+ export function findBackfillCandidate(cwd, hypoDir = HYPO_DIR) {
649
+ const n = normalizeWorkingDir(cwd);
650
+ if (!n) return null;
651
+ const projectsDir = join(hypoDir, 'projects');
652
+ if (!existsSync(projectsDir)) return null;
653
+ const leaf = _lastSeg(n);
654
+ if (!leaf) return null;
655
+
656
+ const ci = isCaseInsensitiveFs();
657
+ let entries;
658
+ try {
659
+ entries = readdirSync(projectsDir);
660
+ } catch {
661
+ return null;
662
+ }
663
+ const slug = entries.find((e) => e !== '_template' && _fold(e, ci) === _fold(leaf, ci));
664
+ if (!slug) return null;
665
+
666
+ const dir = join(projectsDir, slug);
667
+ try {
668
+ if (!statSync(dir).isDirectory()) return null;
669
+ } catch {
670
+ return null;
671
+ }
672
+ const hasArtifacts =
673
+ existsSync(join(dir, 'session-state.md')) ||
674
+ existsSync(join(dir, 'hot.md')) ||
675
+ existsSync(join(dir, 'session-log'));
676
+ if (!hasArtifacts) return null;
677
+
678
+ const indexPath = join(dir, 'index.md');
679
+ const hasIndex = existsSync(indexPath);
680
+ let workingDir = null;
681
+ if (hasIndex) {
682
+ try {
683
+ const fmBlock = readFileSync(indexPath, 'utf-8').match(/^---\r?\n([\s\S]*?)\r?\n---/);
684
+ const line = fmBlock && fmBlock[1].split(/\r?\n/).find((l) => /^working_dir:/.test(l));
685
+ if (line) {
686
+ workingDir = line
687
+ .slice('working_dir:'.length)
688
+ .trim()
689
+ .replace(/\s+#.*$/, '')
690
+ .replace(/^['"]|['"]$/g, '');
691
+ }
692
+ } catch {
693
+ workingDir = null;
694
+ }
695
+ }
696
+ if (hasIndex && workingDir) return null; // already anchored — not a backfill case
697
+
698
+ return { slug, hasIndex };
699
+ }
700
+
627
701
  /**
628
702
  * When the session cwd is a project working_dir distinct from the vault root,
629
703
  * the wiki/knowledge files live in the VAULT, not in this cwd. SessionStart and
@@ -1461,6 +1535,23 @@ export function cwdHasProjectMarker(cwd) {
1461
1535
  return PROJECT_MARKERS.some((m) => existsSync(join(cwd, m)));
1462
1536
  }
1463
1537
 
1538
+ // Shared cooldown/skip gate for BOTH the new-project offer and the backfill
1539
+ // offer below — same store (.cache/project-suggestions.json), same key (cwd),
1540
+ // so a user who just answered one kind of offer for a cwd doesn't immediately
1541
+ // get re-prompted with the other. A corrupt store stays silent (doctor
1542
+ // surfaces the malformation separately).
1543
+ function suggestionNotSuppressed(cwd, hypoDir, now) {
1544
+ const { skips, cooldowns, parseError } = readProjectSuggestions(hypoDir);
1545
+ if (parseError) return false;
1546
+ if (skips.some((s) => s && s.cwd === cwd)) return false;
1547
+ const ts = cooldowns[cwd];
1548
+ if (ts) {
1549
+ const t = Date.parse(ts);
1550
+ if (Number.isFinite(t) && now - t < SUGGESTION_COOLDOWN_MS) return false;
1551
+ }
1552
+ return true;
1553
+ }
1554
+
1464
1555
  /**
1465
1556
  * Decide whether SessionStart/CwdChanged should offer to create a project for
1466
1557
  * `cwd`. The caller MUST have already confirmed `cwd` matches no project's
@@ -1478,15 +1569,25 @@ export function shouldSuggestProjectCreation(cwd, hypoDir = HYPO_DIR, now = Date
1478
1569
  if (!cwd) return false;
1479
1570
  if (!existsSync(join(cwd, '.git'))) return false;
1480
1571
  if (!cwdHasProjectMarker(cwd)) return false;
1481
- const { skips, cooldowns, parseError } = readProjectSuggestions(hypoDir);
1482
- if (parseError) return false;
1483
- if (skips.some((s) => s && s.cwd === cwd)) return false;
1484
- const ts = cooldowns[cwd];
1485
- if (ts) {
1486
- const t = Date.parse(ts);
1487
- if (Number.isFinite(t) && now - t < SUGGESTION_COOLDOWN_MS) return false;
1488
- }
1489
- return true;
1572
+ return suggestionNotSuppressed(cwd, hypoDir, now);
1573
+ }
1574
+
1575
+ /**
1576
+ * Decide whether SessionStart/CwdChanged should offer to BACKFILL a
1577
+ * working_dir anchor for `cwd`. The caller MUST have already located a
1578
+ * findBackfillCandidate() match; this only applies the same cooldown/skip
1579
+ * gate shouldSuggestProjectCreation uses, so the two offers share one
1580
+ * suppression store. No git/marker gate here — the project directory
1581
+ * already existing is itself the trigger condition.
1582
+ *
1583
+ * @param {string} cwd
1584
+ * @param {string} [hypoDir]
1585
+ * @param {number} [now] epoch ms, injectable for tests
1586
+ * @returns {boolean}
1587
+ */
1588
+ export function shouldSuggestBackfill(cwd, hypoDir = HYPO_DIR, now = Date.now()) {
1589
+ if (!cwd) return false;
1590
+ return suggestionNotSuppressed(cwd, hypoDir, now);
1490
1591
  }
1491
1592
 
1492
1593
  /**
@@ -1509,6 +1610,64 @@ export function buildProjectSuggestionLine(cwd) {
1509
1610
  return `[WIKI: cwd '${safe}'에 매칭되는 프로젝트가 없습니다. 자동 생성할까요? (Y/n)]`;
1510
1611
  }
1511
1612
 
1613
+ // Neutralize a PATH value for additionalContext injection without truncating
1614
+ // it — unlike sanitizeProjForPrompt (built for a short display name), a path
1615
+ // must render in full or the backfilled working_dir would be wrong. Only
1616
+ // newline/control chars are stripped (the injection vector); everything else,
1617
+ // including shell metacharacters, passes through untouched because this value
1618
+ // is NEVER assembled into a runnable command (see buildBackfillSuggestionLine)
1619
+ // — it is purely descriptive text for the LLM/user to read.
1620
+ function stripControlCharsForPath(raw) {
1621
+ // Per-codepoint (not a regex escape range) so no control byte is written
1622
+ // literally into this source file (the source-corruption trap hit earlier
1623
+ // in this same change). Mirrors sanitizeProjForPrompt's coverage EXACTLY —
1624
+ // C0 (0x00-0x1F), DEL+C1 (0x7F-0x9F, which folds in U+0085 NEL), and the
1625
+ // U+2028/U+2029 line separators. Deliberately does NOT strip bidi format
1626
+ // controls: sanitizeProjForPrompt doesn't either, and matching its exact
1627
+ // decision (rather than inventing a wider one here) is the principled
1628
+ // choice so the two sanitizers never silently diverge in coverage.
1629
+ return Array.from(String(raw == null ? '' : raw))
1630
+ .map((ch) => {
1631
+ const code = ch.codePointAt(0);
1632
+ const isC0OrDelOrC1 = code < 0x20 || (code >= 0x7f && code <= 0x9f);
1633
+ const isLineSep = code === 0x2028 || code === 0x2029;
1634
+ return isC0OrDelOrC1 || isLineSep ? ' ' : ch;
1635
+ })
1636
+ .join('');
1637
+ }
1638
+
1639
+ /**
1640
+ * Build the working_dir-backfill offer line for a cwd that names an EXISTING
1641
+ * `projects/<slug>/` directory (findBackfillCandidate already confirmed it
1642
+ * carries session artifacts but no anchor). `slug` (short, display-only)
1643
+ * routes through sanitizeProjForPrompt; `cwd` (a real path that must not be
1644
+ * corrupted) routes through stripControlCharsForPath instead — truncating a
1645
+ * path would silently backfill the WRONG working_dir. Neither branch
1646
+ * assembles a runnable shell command from either value (codex pre-commit
1647
+ * review: embedding untrusted path/slug text into a copy-paste `node ...`
1648
+ * invocation is a shell-injection vector); both branches are purely
1649
+ * descriptive guidance for the LLM/user to act on.
1650
+ *
1651
+ * The two cases still need different WORDING: a MISSING index.md has nothing
1652
+ * to edit yet, so the offer describes creating a fresh one anchored to the
1653
+ * current cwd (the agent constructs the actual project-create.mjs invocation
1654
+ * itself, safely, outside this string). An index.md that already EXISTS but
1655
+ * simply omits working_dir needs a direct frontmatter addition instead.
1656
+ *
1657
+ * @param {string} slug the matching projects/<slug>/ directory name
1658
+ * @param {string} cwd the session cwd (the value that would become working_dir)
1659
+ * @param {boolean} hasIndex whether projects/<slug>/index.md already exists
1660
+ * @returns {string}
1661
+ */
1662
+ export function buildBackfillSuggestionLine(slug, cwd, hasIndex) {
1663
+ const safeSlug = sanitizeProjForPrompt(slug);
1664
+ const safeCwd = stripControlCharsForPath(cwd);
1665
+ const action = hasIndex
1666
+ ? `projects/${safeSlug}/index.md의 frontmatter에 working_dir: ${safeCwd}를 추가할까요?`
1667
+ : `projects/${safeSlug}/index.md가 없습니다. 현재 세션의 cwd(${safeCwd})를 working_dir로 삼아 index.md를 새로 만들까요?`;
1668
+ return `[WIKI: cwd가 기존 프로젝트 'projects/${safeSlug}/'와 이름이 일치하지만 working_dir 앵커가 없습니다. ${action} (Y/n)]`;
1669
+ }
1670
+
1512
1671
  // ── clear-marker (amendment 2026-05-14) ──────────────────────
1513
1672
  // `/clear` cannot be blocked (no UserPromptSubmit fire). The only intervention
1514
1673
  // point is the SessionEnd(reason='clear') → SessionStart(source='clear') pair:
@@ -2598,6 +2757,141 @@ export function hasUserCloseSignal(transcriptPath) {
2598
2757
  return false;
2599
2758
  }
2600
2759
 
2760
+ /**
2761
+ * True iff the Stop payload's `background_tasks` names an in-flight (not yet
2762
+ * terminal) delegated subagent. Read-only: used to widen the autoclose
2763
+ * reconfirm trigger (see hypo-auto-minimal-crystallize.mjs) to "work is
2764
+ * demonstrably still running", not just "the wiki has uncommitted changes".
2765
+ *
2766
+ * Defined as the NEGATION of a terminal status, not an enumeration of
2767
+ * "running" states — this repo has no `background_tasks` type definition
2768
+ * (grep turns up nothing), so the real status vocabulary is unverified. An
2769
+ * unrecognized/absent status is treated as in-flight; only a recognized
2770
+ * terminal status clears it.
2771
+ *
2772
+ * Fail-open: a missing or non-array `background_tasks` is treated as "no
2773
+ * in-flight subagent" (the field may simply not be sent by this Stop event),
2774
+ * so in-flight detection degrades gracefully to the git-uncommitted signal
2775
+ * alone rather than spuriously blocking.
2776
+ */
2777
+ export function hasInFlightSubagent(payload) {
2778
+ const tasks = payload && payload.background_tasks;
2779
+ if (!Array.isArray(tasks)) return false;
2780
+ const TERMINAL = /^(completed|complete|done|finished|failed|error|errored|cancelled|canceled)$/i;
2781
+ return tasks.some(
2782
+ (t) => t && t.type === 'subagent' && (t.status == null || !TERMINAL.test(String(t.status))),
2783
+ );
2784
+ }
2785
+
2786
+ // The reconfirm reason (hypo-auto-minimal-crystallize.mjs emitBlock's
2787
+ // reconfirm branch) instructs the close-now option under this exact label —
2788
+ // used below to correlate an AskUserQuestion tool_use to OUR close-reconfirm
2789
+ // prompt specifically, not just any AskUserQuestion the model happens to ask.
2790
+ // Exported so the hook builds its reason text off this SAME literal instead
2791
+ // of a second hardcoded copy: two independent literals would let a reworded
2792
+ // reason silently break this correlation with no test to catch it.
2793
+ export const CLOSE_RECONFIRM_MARK = '지금 닫기';
2794
+
2795
+ /**
2796
+ * True iff the LATEST correlated AskUserQuestion answer in the transcript
2797
+ * declined an autoclose reconfirm prompt ("아직" / "나중" / "not yet" /
2798
+ * "later") — order-sensitive so a decline is suppressed again once the user
2799
+ * signals a NEW close intent afterward (re-arm).
2800
+ *
2801
+ * Read-only, forward scan over the FULL transcript (no tail truncation — a
2802
+ * decline can precede the next Stop by any number of turns). Reuses the same
2803
+ * askIds + tool_use_id correlation `hasUserCloseSignal` (above) uses to bind
2804
+ * an AskUserQuestion answer to its own tool_use, so an unrelated tool_result
2805
+ * string can't forge a decline. Unlike `hasUserCloseSignal` (which is a
2806
+ * "any evidence, ever" OR), this tracks a single latest-wins boolean as it
2807
+ * scans: a decline answer sets it true, and any later GENUINE USER close
2808
+ * signal resets it to false — the user asked to close again, so the prior
2809
+ * decline no longer applies.
2810
+ *
2811
+ * Two correlation guards (the same input-boundary concern class as
2812
+ * extractUserMessages' tool_result/injection exclusion above):
2813
+ *
2814
+ * 1. Re-arm is gated to a real user-typed close signal ONLY — mirrors
2815
+ * extractUserMessages' own boundary filter (isMeta / promptSource
2816
+ * system|sdk / tool_result exclusion, see extractUserMessages above)
2817
+ * instead of re-deriving a separate policy. Without this, the MODEL'S
2818
+ * OWN assistant reasoning text (which can itself contain "세션 마무리"),
2819
+ * or a tool_result (e.g. a Read of a file that happens to quote a close
2820
+ * phrase), could silently clear a recorded decline and re-nag the user
2821
+ * who already said "아직" — the opposite of what a decline means.
2822
+ * 2. The AskUserQuestion tool_use is only added to `askIds` when its input
2823
+ * carries CLOSE_RECONFIRM_MARK — i.e. it IS the close-reconfirm prompt,
2824
+ * not some unrelated question the model asked around the same time. An
2825
+ * unrelated question whose answer happens to contain a decline word
2826
+ * ("나중"/"later") must not correlate.
2827
+ *
2828
+ * Fail-open: a missing/unreadable transcript → false (do not suppress; keep
2829
+ * reconfirming) — the caller stays on the safe (nag, not silent-close) side.
2830
+ */
2831
+ export function isCloseReconfirmDeclined(transcriptPath) {
2832
+ if (!transcriptPath) return false;
2833
+ let lines;
2834
+ try {
2835
+ lines = readFileSync(transcriptPath, 'utf-8').split('\n');
2836
+ } catch {
2837
+ return false;
2838
+ }
2839
+ const DECLINE = /(아직|나중|not\s?yet|later)/i;
2840
+ const askIds = new Set();
2841
+ let declined = false;
2842
+ for (const line of lines) {
2843
+ if (!line.trim()) continue;
2844
+ let obj;
2845
+ try {
2846
+ obj = JSON.parse(line);
2847
+ } catch {
2848
+ continue;
2849
+ }
2850
+ const msg = obj.message ?? obj;
2851
+ const content = msg.content ?? obj.content;
2852
+
2853
+ // Re-arm boundary (guard 1): only a genuine user-typed close signal may
2854
+ // re-arm. Same exclusions as extractUserMessages — isMeta injection,
2855
+ // system/sdk synthetic prompts, and (via the array-content branch below)
2856
+ // tool_result blocks, which are role:'user' in the transcript but are NOT
2857
+ // user-typed text.
2858
+ const isInjected = obj.isMeta === true || obj.promptSource === 'system' || obj.promptSource === 'sdk';
2859
+ const role = msg.role ?? obj.role ?? obj.type;
2860
+ if (!isInjected && role === 'user') {
2861
+ if (typeof content === 'string') {
2862
+ if (!content.startsWith('Stop hook feedback') && isClosePattern(content)) declined = false;
2863
+ } else if (Array.isArray(content)) {
2864
+ for (const b of content) {
2865
+ // Only type:'text' blocks are genuine user-typed text — a
2866
+ // tool_result block (also role:'user') is tool output, not
2867
+ // something the user typed, and must not re-arm.
2868
+ if (b && b.type === 'text' && typeof b.text === 'string' && isClosePattern(b.text)) {
2869
+ declined = false;
2870
+ }
2871
+ }
2872
+ }
2873
+ }
2874
+
2875
+ if (!Array.isArray(content)) continue;
2876
+ for (const b of content) {
2877
+ if (!b || typeof b !== 'object') continue;
2878
+ if (b.type === 'tool_use' && b.name === 'AskUserQuestion' && b.id) {
2879
+ // Correlation guard 2: only OUR close-reconfirm prompt counts.
2880
+ const inputStr = JSON.stringify(b.input ?? null);
2881
+ if (inputStr.includes(CLOSE_RECONFIRM_MARK)) askIds.add(b.id);
2882
+ continue;
2883
+ }
2884
+ if (b.type === 'tool_result' && b.tool_use_id && askIds.has(b.tool_use_id)) {
2885
+ const s = typeof b.content === 'string' ? b.content : JSON.stringify(b.content);
2886
+ for (const m of s.matchAll(/="([^"]*)"/g)) {
2887
+ if (DECLINE.test(m[1])) declined = true;
2888
+ }
2889
+ }
2890
+ }
2891
+ }
2892
+ return declined;
2893
+ }
2894
+
2601
2895
  /**
2602
2896
  * Build hook output for Claude Code (additionalContext channel).
2603
2897
  * Codex hooks write systemMessage directly in their own files.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hypomnema",
3
- "version": "1.5.1",
3
+ "version": "1.6.1",
4
4
  "description": "LLM-native personal wiki system for Claude Code",
5
5
  "type": "module",
6
6
  "license": "MIT",