hypomnema 1.6.0 → 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.6.0",
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.6.0",
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.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와 코드 레포 세션에 위키 위치를 안내하는 것이었습니다.
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
 
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.6.0. It completes 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.
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
 
@@ -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.6.0",
3
+ "version": "1.6.1",
4
4
  "description": "LLM-native personal wiki system for Claude Code",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -20,7 +20,11 @@ import { fileURLToPath } from 'url';
20
20
  import { resolveHypoRoot, expandHome } from './lib/hypo-root.mjs';
21
21
  import { loadHypoIgnore, isScanIgnored } from './lib/hypo-ignore.mjs';
22
22
  import { parseFrontmatter } from './lib/frontmatter.mjs';
23
- import { readSyncState, projectSuggestionsPath } from '../hooks/hypo-shared.mjs';
23
+ import {
24
+ readSyncState,
25
+ projectSuggestionsPath,
26
+ collectProjectWorkingDirs,
27
+ } from '../hooks/hypo-shared.mjs';
24
28
  import {
25
29
  discoverExtensions,
26
30
  parseManifest,
@@ -517,6 +521,73 @@ function checkVerifyBy(hypoDir, ignorePatterns = []) {
517
521
  }
518
522
  }
519
523
 
524
+ // ── per-project index.md working_dir anchor coverage ─────────────────────────
525
+ //
526
+ // cwd-first resume (scripts/lib/wd-match.mjs + hooks/hypo-shared.mjs
527
+ // collectProjectWorkingDirs) anchors each project to a cwd via the
528
+ // `working_dir` field in projects/<slug>/index.md. A project born before an
529
+ // index.md was filled in (or one whose index.md never got the field) carries
530
+ // no anchor, so cwd matching silently degrades to recency for it. This is
531
+ // hygiene reporting, never a hard-fail: recency fallback still resolves
532
+ // something, just less precisely.
533
+ //
534
+ // Anchor status is read via hooks/hypo-shared.mjs collectProjectWorkingDirs —
535
+ // the SAME function the deployed CwdChanged/SessionStart hooks use at
536
+ // runtime — rather than the generic (lenient) parseFrontmatter. That matters:
537
+ // parseFrontmatter accepts `working_dir : /repo` (space before the colon),
538
+ // but the runtime hook matcher only recognizes the exact `working_dir:` form
539
+ // (no space). Using parseFrontmatter here would report a project as anchored
540
+ // when cwd-first resume still can't actually match it — a false pass this
541
+ // check exists specifically to prevent.
542
+ function checkProjectIndexAnchors(hypoDir) {
543
+ const projectsDir = join(hypoDir, 'projects');
544
+ if (!existsSync(projectsDir)) return;
545
+
546
+ const anchoredWorkingDir = new Map(
547
+ collectProjectWorkingDirs(hypoDir).map((p) => [p.slug, p.workingDir]),
548
+ );
549
+
550
+ const unanchored = [];
551
+ for (const slug of readdirSync(projectsDir)) {
552
+ if (slug === '_template') continue;
553
+ const dir = join(projectsDir, slug);
554
+ try {
555
+ if (!statSync(dir).isDirectory()) continue;
556
+ } catch {
557
+ continue;
558
+ }
559
+
560
+ // Only a project that actually HAS session activity is worth anchoring —
561
+ // a bare scaffold (e.g. mid-creation) has nothing yet for cwd-first
562
+ // resume to lose.
563
+ const hasArtifacts =
564
+ existsSync(join(dir, 'session-state.md')) ||
565
+ existsSync(join(dir, 'hot.md')) ||
566
+ existsSync(join(dir, 'session-log'));
567
+ if (!hasArtifacts) continue;
568
+
569
+ const indexPath = join(dir, 'index.md');
570
+ if (!existsSync(indexPath)) {
571
+ unanchored.push(`${slug} (no index.md)`);
572
+ continue;
573
+ }
574
+ if (!anchoredWorkingDir.get(slug)) {
575
+ unanchored.push(`${slug} (index.md missing working_dir)`);
576
+ }
577
+ }
578
+
579
+ if (unanchored.length === 0) {
580
+ pass('Project index anchors', 'All session-bearing projects carry a working_dir anchor');
581
+ } else {
582
+ const sample = unanchored.slice(0, 5).join(', ');
583
+ const extra = unanchored.length > 5 ? ` (+${unanchored.length - 5} more)` : '';
584
+ warn(
585
+ 'Project index anchors',
586
+ `${unanchored.length} project(s) cwd-first resume can't anchor: ${sample}${extra}`,
587
+ );
588
+ }
589
+ }
590
+
520
591
  function checkSyncState(hypoDir) {
521
592
  // "open" = file exists with ≥1 entries; session-start clears once
522
593
  // sync is healthy again. Schema + parsing live in hooks/hypo-shared.mjs.
@@ -1135,6 +1206,7 @@ checkStaleSibling();
1135
1206
  if (args.codex) checkCodexPaths();
1136
1207
  if (rootOk) checkExtensions(args.hypoDir, args.claudeHome, 'claude');
1137
1208
  if (rootOk && args.codex) checkExtensions(args.hypoDir, args.claudeHome, 'codex');
1209
+ if (rootOk) checkProjectIndexAnchors(args.hypoDir);
1138
1210
  if (rootOk) checkSyncState(args.hypoDir);
1139
1211
  if (rootOk) checkProjectSuggestions(args.hypoDir);
1140
1212
  if (rootOk) checkFeedbackProjection(args.hypoDir, args.claudeHome, args.projectId);
package/scripts/lint.mjs CHANGED
@@ -132,6 +132,20 @@ const TYPE_CONDITIONAL_FIELDS = {
132
132
  'weekly-journal': ['week'],
133
133
  };
134
134
 
135
+ // type-conditional FORBIDDEN fields: a field that is legitimate on one type
136
+ // only, but has been observed leaking into another via an unvalidated writer.
137
+ // `working_dir` (and its companion `project`) belong solely on
138
+ // `project-index` (index.md) — TYPE_CONDITIONAL_FIELDS above requires it
139
+ // there. Unvalidated crystallize output has planted a stray `working_dir` /
140
+ // `project` key on session-state.md instead, silently pointing injected
141
+ // context at a wrong or stale path. Warn-only (not promoted via
142
+ // STRICT_PROMOTE_IDS): this guards against a writer bug, not confirmed-bad
143
+ // content a real YAML/type check would reject, so an existing vault carrying
144
+ // the stray field is not hard-blocked.
145
+ const TYPE_FORBIDDEN_FIELDS = {
146
+ 'session-state': ['working_dir', 'project'],
147
+ };
148
+
135
149
  const TYPE_ENUM_FIELDS = {
136
150
  prd: { status: ['draft', 'active', 'completed', 'cancelled', 'archived'] },
137
151
  adr: { status: ['proposed', 'accepted', 'deprecated', 'superseded'] },
@@ -379,6 +393,24 @@ function lintPage({ path, rel }, slugMap, tagVocab, pageDirs, validTypes) {
379
393
  }
380
394
  }
381
395
 
396
+ // type-conditional forbidden fields (W11): a field valid only on a
397
+ // DIFFERENT type planted here by an unvalidated writer. Object.hasOwn
398
+ // (not `fm[field]`) so a present-but-empty `working_dir:` still flags —
399
+ // the bug is the key's presence, not its value.
400
+ if (fm.type && TYPE_FORBIDDEN_FIELDS[fm.type]) {
401
+ for (const field of TYPE_FORBIDDEN_FIELDS[fm.type]) {
402
+ if (Object.hasOwn(fm, field)) {
403
+ issue(
404
+ 'warn',
405
+ rel,
406
+ `Forbidden frontmatter field for type "${fm.type}": "${field}" (only valid on project-index)`,
407
+ null,
408
+ 'W11',
409
+ );
410
+ }
411
+ }
412
+ }
413
+
382
414
  // type-specific enum validation
383
415
  if (fm.type && TYPE_ENUM_FIELDS[fm.type]) {
384
416
  for (const [field, allowed] of Object.entries(TYPE_ENUM_FIELDS[fm.type])) {
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  title: Hypomnema Config
3
3
  type: config
4
- version: "1.6.0"
4
+ version: "1.6.1"
5
5
  created: YYYY-MM-DD
6
6
  ---
7
7