hypomnema 1.6.1 → 1.7.0

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.
Files changed (62) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.ko.md +39 -14
  4. package/README.md +39 -14
  5. package/commands/capture.md +8 -6
  6. package/commands/crystallize.md +39 -20
  7. package/docs/ARCHITECTURE.md +49 -14
  8. package/docs/CONTRIBUTING.md +31 -29
  9. package/hooks/base-store.mjs +265 -0
  10. package/hooks/hooks.json +7 -1
  11. package/hooks/hypo-auto-minimal-crystallize.mjs +77 -31
  12. package/hooks/hypo-auto-stage.mjs +30 -0
  13. package/hooks/hypo-cwd-change.mjs +31 -2
  14. package/hooks/hypo-file-watch.mjs +21 -2
  15. package/hooks/hypo-first-prompt.mjs +19 -3
  16. package/hooks/hypo-lookup.mjs +86 -29
  17. package/hooks/hypo-personal-check.mjs +24 -3
  18. package/hooks/hypo-session-record.mjs +2 -3
  19. package/hooks/hypo-session-start.mjs +89 -7
  20. package/hooks/hypo-shared.mjs +944 -147
  21. package/hooks/proposal-store.mjs +513 -0
  22. package/package.json +40 -14
  23. package/scripts/capture.mjs +556 -37
  24. package/scripts/crystallize.mjs +639 -108
  25. package/scripts/doctor.mjs +304 -9
  26. package/scripts/feedback-sync.mjs +515 -44
  27. package/scripts/graph.mjs +9 -2
  28. package/scripts/init.mjs +230 -34
  29. package/scripts/lib/extensions.mjs +656 -1
  30. package/scripts/lib/hypo-ignore.mjs +54 -6
  31. package/scripts/lib/hypo-root.mjs +56 -6
  32. package/scripts/lib/page-usage.mjs +15 -2
  33. package/scripts/lib/pkg-json.mjs +40 -0
  34. package/scripts/lib/plugin-detect.mjs +96 -6
  35. package/scripts/lib/wd-match.mjs +23 -5
  36. package/scripts/lib/wikilink.mjs +32 -6
  37. package/scripts/lint.mjs +20 -4
  38. package/scripts/proposal.mjs +1032 -0
  39. package/scripts/query.mjs +25 -4
  40. package/scripts/resume.mjs +34 -12
  41. package/scripts/stats.mjs +28 -8
  42. package/scripts/uninstall.mjs +141 -6
  43. package/scripts/upgrade.mjs +197 -15
  44. package/skills/crystallize/SKILL.md +44 -7
  45. package/skills/debate/SKILL.md +88 -0
  46. package/skills/debate/references/orchestration-patterns.md +83 -0
  47. package/templates/.hyposcanignore +10 -0
  48. package/templates/SCHEMA.md +12 -0
  49. package/templates/gitignore +5 -0
  50. package/templates/hypo-config.md +1 -1
  51. package/templates/hypo-guide.md +6 -0
  52. package/scripts/.gitkeep +0 -0
  53. package/scripts/check-bilingual.mjs +0 -153
  54. package/scripts/check-readme-version.mjs +0 -126
  55. package/scripts/check-tracker-ids.mjs +0 -426
  56. package/scripts/check-versions.mjs +0 -171
  57. package/scripts/install-git-hooks.mjs +0 -293
  58. package/scripts/lib/changelog-classify.mjs +0 -216
  59. package/scripts/lib/check-bilingual.mjs +0 -244
  60. package/scripts/lib/check-tracker-ids.mjs +0 -217
  61. package/scripts/lib/pre-commit-format.mjs +0 -251
  62. package/scripts/pre-commit-format.mjs +0 -198
@@ -36,8 +36,9 @@
36
36
  * gate above was found to be unable to distinguish "close now" from "close
37
37
  * once X is done" by regex alone — same sentence shape either way). When the
38
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
39
+ * (an uncommitted wiki, or pending background work a non-terminal
40
+ * background task or a scheduled cron wake per hasPendingBackgroundWork),
41
+ * step 6's block reason is replaced with an
41
42
  * AskUserQuestion instruction instead of a crystallize command — the model
42
43
  * asks the user "close now?" rather than deciding for them. A correlated
43
44
  * "아직"/"나중"/"not yet"/"later" answer (isCloseReconfirmDeclined) suppresses
@@ -65,7 +66,7 @@ import {
65
66
  isClosePattern,
66
67
  isGateSkipped,
67
68
  precompactGateStatus,
68
- hasInFlightSubagent,
69
+ hasPendingBackgroundWork,
69
70
  isCloseReconfirmDeclined,
70
71
  CLOSE_RECONFIRM_MARK,
71
72
  } from './hypo-shared.mjs';
@@ -95,8 +96,8 @@ function emitBlock(sessionId, transcriptPath, gate = null, opts = {}) {
95
96
  JSON.stringify({
96
97
  decision: 'block',
97
98
  reason:
98
- `[WIKI_AUTOCLOSE] close 신호가 잡혔지만 아직 커밋되지 않은 변경(또는 진행 중인 ` +
99
- `위임 작업)이 있어 지금 닫을지 뒤로 미룰지 모호합니다 (session_id=${sessionId}). ` +
99
+ `[WIKI_AUTOCLOSE] close 신호가 잡혔지만 아직 커밋되지 않은 변경 또는 진행 중인 ` +
100
+ `백그라운드·위임 작업이 있어 지금 닫을지 뒤로 미룰지 모호합니다 (session_id=${sessionId}). ` +
100
101
  `임의로 닫지 말고 AskUserQuestion으로 사용자에게 지금 세션을 닫을지 물어보세요. ` +
101
102
  `선택지는 "${CLOSE_RECONFIRM_MARK}"와 "아직, 계속"으로 제시합니다. 사용자가 ` +
102
103
  `"${CLOSE_RECONFIRM_MARK}"를 고른 뒤에만 세션 마무리를 진행하세요. 그전에는 ` +
@@ -122,10 +123,20 @@ function emitBlock(sessionId, transcriptPath, gate = null, opts = {}) {
122
123
  // an install/transcript path contains spaces (display text only — never exec'd
123
124
  // here).
124
125
  const transcriptHint = transcriptPath ? ` --transcript-path="${transcriptPath}"` : '';
126
+ // Recovery command carries EVIDENCE, never the recency-derived close.project.
127
+ // The evidence-backed attribution is a singleton close scope (transcript
128
+ // close-files ∪ this marker's projects); when it is unambiguous, embed it as
129
+ // --project so the re-run is one-shot instead of failing closed for lack of
130
+ // evidence. The session cwd (from the payload) rides along so the re-run's own
131
+ // session-cwd close check runs against the same project this Stop evaluated.
132
+ const scope = gate?.close?.scope || [];
133
+ const evidenceProject = scope.length === 1 ? scope[0] : null;
134
+ const projectHint = evidenceProject ? ` --project=${evidenceProject}` : '';
135
+ const cwdHint = opts.sessionCwd ? ` --session-cwd="${opts.sessionCwd}"` : '';
125
136
  const cliBase = PKG_ROOT ? `node "${join(PKG_ROOT, 'scripts', 'crystallize.mjs')}"` : null;
126
137
  const markCmd = cliBase
127
- ? `${cliBase} --mark-session-closed --session-id=${sessionId}${transcriptHint}`
128
- : `/hypo:crystallize (session_id=${sessionId}${transcriptHint})`;
138
+ ? `${cliBase} --mark-session-closed --session-id=${sessionId}${projectHint}${transcriptHint}${cwdHint}`
139
+ : `/hypo:crystallize (session_id=${sessionId}${projectHint}${transcriptHint})`;
129
140
  // The log-only escape hatch for a non-project (wiki/tooling-only)
130
141
  // session. Offered ONLY as an explicit alternative when a close blocker is
131
142
  // present — never as the default recovery, so a real project session is not
@@ -149,7 +160,9 @@ function emitBlock(sessionId, transcriptPath, gate = null, opts = {}) {
149
160
  // Only when a project-close blocker is what's holding the session: a
150
161
  // non-project session has nothing to close, so offer log-only as the way out
151
162
  // (Claude decides whether this session is project-scoped — no auto-attribution).
152
- if (gate.blockers.some((b) => b.type === 'close')) {
163
+ // A session-cwd close blocker (the session's own cwd project is unstarted) is
164
+ // the same kind of project-close hold, so it gets the same escape.
165
+ if (gate.blockers.some((b) => b.type === 'close' || b.type === 'close-cwd')) {
153
166
  reason += ` If this was a non-project (wiki/tooling-only) session with no project to close, run \`${logOnlyCmd}\` instead (log-only close, no project attribution).`;
154
167
  }
155
168
  } else {
@@ -201,6 +214,9 @@ process.stdin.on('end', () => {
201
214
 
202
215
  const sessionId = payload.session_id || payload.sessionId || null;
203
216
  const transcriptPath = payload.transcript_path || payload.transcriptPath || null;
217
+ // Authoritative session cwd (the one verified cwd source) for the session-cwd
218
+ // close check below. Absent on older Claude Code payloads → the check is skipped.
219
+ const sessionCwd = payload.cwd || null;
204
220
 
205
221
  // 3. substantial-session gate. Pure Q&A / incidental-lookup sessions skip
206
222
  // the block; mutating sessions AND high-volume read-only investigations
@@ -219,10 +235,49 @@ process.stdin.on('end', () => {
219
235
  return;
220
236
  }
221
237
 
222
- // 5. close already verified for this session_id.
223
- if (sessionId && readSessionClosedMarker(HYPO_DIR, sessionId)) {
224
- emitContinue();
225
- return;
238
+ // Read-only /compact gate (same precompactGateStatus the real PreCompact hook
239
+ // uses) sharpens the block message and, with sessionCwd, backs the session-cwd
240
+ // close check below. The hook NEVER writes the marker here (file-header
241
+ // invariant); this is read-only. Any error → null → emitBlock falls back to the
242
+ // generic message (fail-open). Computed lazily: the common closed-session path
243
+ // (a project marker whose cwd project is complete) still short-circuits without
244
+ // paying the gate cost, unless a cwd signal makes the cwd check meaningful.
245
+ let gate = null;
246
+ const computeGate = () => {
247
+ try {
248
+ return precompactGateStatus(HYPO_DIR, {
249
+ ...(transcriptPath ? { transcriptPath } : {}),
250
+ ...(sessionCwd ? { sessionCwd } : {}),
251
+ ...(sessionId ? { sessionId } : {}),
252
+ });
253
+ } catch {
254
+ return null;
255
+ }
256
+ };
257
+
258
+ // 5. close already verified for this session_id — but a project marker only
259
+ // attests the project(s) it recorded. If THIS session's cwd project still has
260
+ // an unstarted close, honoring the marker would end the session green while
261
+ // that project stays open (the session-cwd false-green). So re-check the cwd
262
+ // project before accepting a project marker. A log-only marker (non-project
263
+ // session) is exempt, and without a cwd signal we accept the marker as before
264
+ // (back-compat with payloads that carry no cwd).
265
+ if (sessionId) {
266
+ const marker = readSessionClosedMarker(HYPO_DIR, sessionId);
267
+ if (marker) {
268
+ if (marker.scope === 'log-only' || !sessionCwd) {
269
+ emitContinue();
270
+ return;
271
+ }
272
+ gate = computeGate();
273
+ const cwdBlocked = !!gate?.blockers?.some((b) => b.type === 'close-cwd');
274
+ if (!cwdBlocked) {
275
+ emitContinue();
276
+ return;
277
+ }
278
+ // marker present but the session's cwd project close is incomplete: fall
279
+ // through to block, reusing the gate computed above.
280
+ }
226
281
  }
227
282
 
228
283
  // 6. block — but only when we have a session_id to address the recovery
@@ -233,31 +288,22 @@ process.stdin.on('end', () => {
233
288
  return;
234
289
  }
235
290
 
236
- // Read-only /compact gate (same precompactGateStatus the real
237
- // PreCompact hook uses) sharpens the block message — distinguishes "close
238
- // is compact-ready, only the marker is missing" from "there are real
239
- // blockers". The hook NEVER writes the marker here (file-header invariant);
240
- // this is read-only. Any error → null → emitBlock falls back to the generic
241
- // message (fail-open).
242
- let gate = null;
243
- try {
244
- gate = precompactGateStatus(HYPO_DIR, transcriptPath ? { transcriptPath } : {});
245
- } catch {
246
- gate = null;
247
- }
291
+ if (!gate) gate = computeGate();
248
292
 
249
293
  // Reconfirm decision (conditional-close-reconfirm): "close" and
250
294
  // "work-incomplete" together are exactly the case where the transcript's
251
295
  // NL close phrase can't tell "close now" from "close once X is done" —
252
296
  // 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).
297
+ // work-incomplete signals only: an uncommitted wiki (`git` blocker; real
298
+ // unsaved work) OR pending background work a non-terminal background task
299
+ // (delegated subagent OR shell, e.g. a deferred push/CI wait) or a
300
+ // scheduled cron wake (hasPendingBackgroundWork). `hot` / `lint` / `close`
301
+ // / `design-history` / `feedback` blockers are real close blockers but not
302
+ // evidence of "later" intent, so they keep the existing wording (unchanged
303
+ // from before this feature).
258
304
  const workIncomplete =
259
305
  (gate && gate.blockers && gate.blockers.some((b) => b.type === 'git')) ||
260
- hasInFlightSubagent(payload);
306
+ hasPendingBackgroundWork(payload);
261
307
  if (workIncomplete && isCloseReconfirmDeclined(transcriptPath)) {
262
308
  // The user already answered "아직" to the ambiguous close signal that
263
309
  // triggered this Stop turn — suppressing is a continue, not a
@@ -266,7 +312,7 @@ process.stdin.on('end', () => {
266
312
  return;
267
313
  }
268
314
 
269
- emitBlock(sessionId, transcriptPath, gate, { reconfirm: workIncomplete });
315
+ emitBlock(sessionId, transcriptPath, gate, { reconfirm: workIncomplete, sessionCwd });
270
316
  } catch (err) {
271
317
  // Fail-open on any unexpected error.
272
318
  process.stderr.write(`[hypo-auto-minimal-crystallize] error: ${err?.message ?? String(err)}\n`);
@@ -6,7 +6,17 @@
6
6
  */
7
7
 
8
8
  import { spawnSync } from 'child_process';
9
+ import { relative } from 'path';
9
10
  import { HYPO_DIR, loadHypoIgnore, isIgnored } from './hypo-shared.mjs';
11
+ import { advanceBaseForWrite, hashContent } from './base-store.mjs';
12
+
13
+ // Tools that REPLACE file bytes. The base advance below must fire only for these:
14
+ // this hook has no matcher in hooks.json (it runs on every PostToolUse), and a
15
+ // read-only tool like Read also carries `tool_input.file_path`. Without this
16
+ // allowlist, merely Reading a target another session had drifted would advance
17
+ // the base to that other session's bytes — silently defeating the write=proposal
18
+ // guard at close. tool_name, not file_path, is the write signal.
19
+ const WRITE_TOOLS = new Set(['Write', 'Edit', 'MultiEdit']);
10
20
 
11
21
  let input = {};
12
22
  try {
@@ -29,6 +39,26 @@ if (filePath.startsWith(HYPO_DIR + '/') || filePath === HYPO_DIR) {
29
39
  if (patterns.length === 0 || !isIgnored(filePath, HYPO_DIR, patterns)) {
30
40
  spawnSync('git', ['-C', HYPO_DIR, 'add', filePath], { stdio: 'ignore' });
31
41
  }
42
+
43
+ // Write=proposal gate provenance: when this session's own write lands on one of
44
+ // the overwrite targets it snapshotted at start, advance that target's base so
45
+ // the close guard reads the change as "I wrote this", not "someone else did"
46
+ // (which would fail safe into a false proposal against the session's own edit).
47
+ // Self-scoping — a no-op unless the path is a tracked base key — so it runs
48
+ // regardless of .hypoignore (provenance is independent of privacy). Best-effort.
49
+ //
50
+ // The Write tool carries its full `content`, so advance to the bytes THIS
51
+ // session wrote (race-safe: a concurrent write landing between the tool and
52
+ // this hook cannot be adopted as our base). Edit/MultiEdit have no full content
53
+ // in the payload, so they fall back to a post-write disk read.
54
+ if (WRITE_TOOLS.has(input.tool_name) && input.session_id) {
55
+ const rel = relative(HYPO_DIR, filePath);
56
+ const known =
57
+ input.tool_name === 'Write' && typeof input.tool_input?.content === 'string'
58
+ ? hashContent(input.tool_input.content)
59
+ : null;
60
+ advanceBaseForWrite(HYPO_DIR, input.session_id, rel, filePath, known);
61
+ }
32
62
  }
33
63
 
34
64
  console.log(JSON.stringify({ continue: true, suppressOutput: true }));
@@ -24,6 +24,9 @@ import {
24
24
  pickProjectByCwd,
25
25
  collectProjectWorkingDirs,
26
26
  buildVaultOrientation,
27
+ currentDevice,
28
+ scopeVisible,
29
+ readVisibilityScope,
27
30
  } from './hypo-shared.mjs';
28
31
 
29
32
  const PROJECTS_DIR = join(HYPO_DIR, 'projects');
@@ -32,10 +35,32 @@ const MAX_CHARS = 3000;
32
35
 
33
36
  // Privacy guard: a .hypoignore-matched hot.md must not be
34
37
  // re-emitted into additionalContext on cwd change.
38
+ //
39
+ // Visibility guard: same contract as hypo-file-watch and hypo-session-start. A
40
+ // machine-scoped hot.md stays off every machine but its owner. Scope is read
41
+ // from the RAW content before the MAX_CHARS slice, since slicing first could cut
42
+ // the frontmatter off and fail open. The root hot.md carries no frontmatter, so
43
+ // it reads as '' and passes as shared.
35
44
  function readIfNotIgnored(path, patterns) {
36
45
  if (!path) return null;
37
46
  if (patterns.length > 0 && isIgnored(path, HYPO_DIR, patterns)) return null;
38
- return readFileSync(path, 'utf-8').slice(0, MAX_CHARS);
47
+ const raw = readFileSync(path, 'utf-8');
48
+ if (!scopeVisible(readVisibilityScope(raw), currentDevice())) return null;
49
+ return raw.slice(0, MAX_CHARS);
50
+ }
51
+
52
+ // Scoped-out is not absent. Both make readIfNotIgnored return null, but the
53
+ // absent-case placeholder tells the model the file "will be created at session
54
+ // close". On a foreign machine that invites it to author over a hot.md that
55
+ // exists and belongs elsewhere. Report the fact, never the withheld body.
56
+ function isScopedOut(path, patterns) {
57
+ try {
58
+ if (!path || !existsSync(path)) return false;
59
+ if (patterns.length > 0 && isIgnored(path, HYPO_DIR, patterns)) return false;
60
+ return !scopeVisible(readVisibilityScope(readFileSync(path, 'utf-8')), currentDevice());
61
+ } catch {
62
+ return false;
63
+ }
39
64
  }
40
65
 
41
66
  function findProjectHot(cwd) {
@@ -87,7 +112,11 @@ process.stdin.on('end', () => {
87
112
 
88
113
  if (newHit) {
89
114
  const fromFile = readIfNotIgnored(newHit.hotPath, ignorePatterns);
90
- const content = fromFile ?? '(no hot.md yet — will be created at session close)';
115
+ const content =
116
+ fromFile ??
117
+ (isScopedOut(newHit.hotPath, ignorePatterns)
118
+ ? '(hot.md for this project is scoped to another machine and is not visible here)'
119
+ : '(no hot.md yet — will be created at session close)');
91
120
  // arm the first-prompt marker so the NEXT user prompt re-triggers
92
121
  // hypo-first-prompt, which forces a "Resuming <project>" summary line.
93
122
  // Only arm when real hot content was actually injected — if hot.md is
@@ -8,7 +8,14 @@
8
8
 
9
9
  import { readFileSync, existsSync } from 'fs';
10
10
  import { join } from 'path';
11
- import { HYPO_DIR, loadHypoIgnore, isIgnored } from './hypo-shared.mjs';
11
+ import {
12
+ HYPO_DIR,
13
+ loadHypoIgnore,
14
+ isIgnored,
15
+ currentDevice,
16
+ scopeVisible,
17
+ readVisibilityScope,
18
+ } from './hypo-shared.mjs';
12
19
 
13
20
  const MAX_CHARS = 2000;
14
21
 
@@ -43,7 +50,19 @@ process.stdin.on('end', () => {
43
50
  return;
44
51
  }
45
52
 
46
- const content = readFileSync(filePath, 'utf-8').slice(0, MAX_CHARS);
53
+ const fileRaw = readFileSync(filePath, 'utf-8');
54
+
55
+ // Visibility guard: a machine-scoped page (visibility_scope: machine:<owner>)
56
+ // must not be re-injected on a machine other than its owner. Read the scope
57
+ // from the raw, unsliced content — slicing to MAX_CHARS first could cut the
58
+ // frontmatter off and silently make every scoped page pass fail-open.
59
+ const scope = readVisibilityScope(fileRaw);
60
+ if (!scopeVisible(scope, currentDevice())) {
61
+ console.log(JSON.stringify({ continue: true, suppressOutput: true }));
62
+ return;
63
+ }
64
+
65
+ const content = fileRaw.slice(0, MAX_CHARS);
47
66
  const relPath = filePath.replace(HYPO_DIR + '/', '');
48
67
 
49
68
  console.log(
@@ -48,7 +48,17 @@ process.stdin.on('end', () => {
48
48
  }
49
49
 
50
50
  const hasSnapshot = marker.hasSnapshot ?? (marker.hotPath && existsSync(marker.hotPath));
51
- const snapshotNote = hasSnapshot ? '' : ' (no snapshot yet — first session)';
51
+ // A snapshot withheld by visibility_scope is NOT an absent one. session-start
52
+ // stamps the marker so this hook can tell them apart: without it, a project
53
+ // resumed on a foreign machine is announced as a first session, and the model
54
+ // then has every reason to author a fresh hot.md over the one that exists on
55
+ // the owning machine. Never name the withheld contents, only the fact.
56
+ const scopedOut = marker.scopedOut === true;
57
+ const snapshotNote = hasSnapshot
58
+ ? ''
59
+ : scopedOut
60
+ ? ' (snapshot scoped to another machine)'
61
+ : ' (no snapshot yet — first session)';
52
62
  // a cwd-change re-trigger says "Resuming"; a fresh session start
53
63
  // (default source) says "Previously working on".
54
64
  const verb = marker.source === 'cwd-change' ? 'Resuming' : 'Previously working on';
@@ -63,11 +73,17 @@ process.stdin.on('end', () => {
63
73
  // first-ever session (codex v2 review 2026-05-26).
64
74
  const exampleLine = hasSnapshot
65
75
  ? `${verb} ${projSafe}: [one-line summary]. Continue with [next task]?`
66
- : `${verb} ${projSafe}: no prior snapshot yet — first session. What would you like to start with?`;
76
+ : scopedOut
77
+ ? `${projSafe}: this project has a prior snapshot, but it is scoped to another machine and is not visible here. What would you like to work on?`
78
+ : `${verb} ${projSafe}: no prior snapshot yet — first session. What would you like to start with?`;
67
79
  const fillNote = hasSnapshot
68
80
  ? `Replace the bracketed placeholders using the [HOT] / [SESSION STATE] ` +
69
81
  `context already injected this session — do NOT emit the literal brackets.`
70
- : `Use the line above verbatim — there is no prior snapshot to summarize.`;
82
+ : scopedOut
83
+ ? `Use the line above verbatim. The project has prior work; its snapshot ` +
84
+ `simply belongs to another machine, so treat it as an existing project ` +
85
+ `whose history you cannot see from here.`
86
+ : `Use the line above verbatim — there is no prior snapshot to summarize.`;
71
87
 
72
88
  console.log(
73
89
  JSON.stringify(
@@ -19,6 +19,9 @@ import {
19
19
  staleMarkerFor,
20
20
  pageUsageLoggingAllowed,
21
21
  recordLookupUsage,
22
+ currentDevice,
23
+ scopeVisible,
24
+ readVisibilityScope,
22
25
  } from './hypo-shared.mjs';
23
26
 
24
27
  const INDEX_PATH = join(HYPO_DIR, 'index.md');
@@ -32,7 +35,16 @@ function buildPageMap(dir, root = dir, map = {}, ignorePatterns = [], hypoDir =
32
35
  for (const entry of readdirSync(dir)) {
33
36
  const full = join(dir, entry);
34
37
  if (isIgnored(full, hypoDir, ignorePatterns)) continue;
35
- if (statSync(full).isDirectory()) {
38
+ // Skip an entry we can't stat (dangling symlink, race, permission) instead
39
+ // of throwing: the map is now built before the miss branch too, so one bad
40
+ // entry must not sink the whole lookup into the silent outer catch.
41
+ let st;
42
+ try {
43
+ st = statSync(full);
44
+ } catch {
45
+ continue;
46
+ }
47
+ if (st.isDirectory()) {
36
48
  buildPageMap(full, root, map, ignorePatterns, hypoDir);
37
49
  } else if (entry.endsWith('.md')) {
38
50
  const rel = full.slice(root.length + 1).replace(/\.md$/, '');
@@ -208,25 +220,6 @@ process.stdin.on('end', () => {
208
220
  const topScore = scored[0]?.score ?? 0;
209
221
  const matched = scored.filter((e) => e.score >= topScore * 0.5);
210
222
 
211
- if (matched.length === 0) {
212
- const topic = keywords.slice(0, 5).join(', ');
213
- const closest = bm25Score(keywords, entries)
214
- .map((e) => ({ ...e, score: e.score * typePrior(e.slug) }))
215
- .sort((a, c) => c.score - a.score)
216
- .slice(0, 3)
217
- .map((e) => `[[${e.slug}]]`)
218
- .join(', ');
219
- console.log(
220
- JSON.stringify(
221
- buildOutput(`[WIKI LOOKUP: miss] "${topic}" — no match. Closest: ${closest || 'none'}`, {
222
- continue: true,
223
- suppressOutput: true,
224
- }),
225
- ),
226
- );
227
- return;
228
- }
229
-
230
223
  const ignorePatterns = loadHypoIgnore(HYPO_DIR);
231
224
  const pageMap = {
232
225
  ...buildPageMap(
@@ -245,19 +238,81 @@ process.stdin.on('end', () => {
245
238
  ),
246
239
  };
247
240
 
241
+ // Single device snapshot for the whole prompt (currentDevice() itself stays
242
+ // uncached upstream so tests can override via HYPO_DEVICE; this hook only
243
+ // needs one read per lookup, not one per candidate).
244
+ const device = currentDevice();
245
+
246
+ // Resolve a slug to its on-disk path the same way the injection loop does.
247
+ const resolveSlugPath = (slug) =>
248
+ pageMap[slug] ?? pageMap[slug.replace(/^(pages|projects)\//, '')] ?? pageMap[basename(slug)];
249
+
250
+ // Read a page's raw content at most once per lookup: the visibility gate and
251
+ // the injection loop both need it, so memoize to avoid a double read.
252
+ const rawCache = new Map();
253
+ const readRaw = (path) => {
254
+ if (rawCache.has(path)) return rawCache.get(path);
255
+ let raw = null;
256
+ try {
257
+ raw = readFileSync(path, 'utf-8');
258
+ } catch {
259
+ raw = null;
260
+ }
261
+ rawCache.set(path, raw);
262
+ return raw;
263
+ };
264
+
265
+ // Visibility gate: MUST read raw content and go through readVisibilityScope
266
+ // (never a local frontmatter parser) so first-wins + comment-strip stay in
267
+ // step with the write side — a local last-wins/no-comment parser would miss
268
+ // `machine:devA # note` on devA itself. An unresolved/unreadable slug passes
269
+ // through (nothing to leak; the missing-file paths handle it downstream).
270
+ const isSlugVisible = (slug) => {
271
+ const path = resolveSlugPath(slug);
272
+ if (!path || !existsSync(path)) return true;
273
+ const raw = readRaw(path);
274
+ if (raw === null) return true;
275
+ return scopeVisible(readVisibilityScope(raw), device);
276
+ };
277
+
278
+ // Filter BEFORE the miss decision so a device that matches only machine-other
279
+ // pages takes the clean miss path (with closest VISIBLE suggestions), not the
280
+ // "index hit but files missing" branch. Hidden pages never reach injection,
281
+ // the empty-injection slug branch, or the "+N more" count — all read only
282
+ // visibleMatched, so no machine-other slug can leak through any path.
283
+ const visibleMatched = matched.filter((e) => isSlugVisible(e.slug));
284
+
285
+ if (visibleMatched.length === 0) {
286
+ const topic = keywords.slice(0, 5).join(', ');
287
+ const closest = bm25Score(keywords, entries)
288
+ .map((e) => ({ ...e, score: e.score * typePrior(e.slug) }))
289
+ .sort((a, c) => c.score - a.score)
290
+ .filter((e) => isSlugVisible(e.slug))
291
+ .slice(0, 3)
292
+ .map((e) => `[[${e.slug}]]`)
293
+ .join(', ');
294
+ console.log(
295
+ JSON.stringify(
296
+ buildOutput(`[WIKI LOOKUP: miss] "${topic}" — no match. Closest: ${closest || 'none'}`, {
297
+ continue: true,
298
+ suppressOutput: true,
299
+ }),
300
+ ),
301
+ );
302
+ return;
303
+ }
304
+
248
305
  // UTC to match doctor.mjs' overdue set (D1/D2). STALE is advisory, so a
249
306
  // one-day UTC/local skew never misleads.
250
307
  const TODAY = new Date().toISOString().slice(0, 10);
251
308
 
252
309
  const injected = [];
253
310
  const injectedSlugs = [];
254
- for (const { slug } of matched.slice(0, MAX_HITS)) {
255
- const path =
256
- pageMap[slug] ??
257
- pageMap[slug.replace(/^(pages|projects)\//, '')] ??
258
- pageMap[basename(slug)];
311
+ for (const { slug } of visibleMatched.slice(0, MAX_HITS)) {
312
+ const path = resolveSlugPath(slug);
259
313
  if (path && existsSync(path)) {
260
- const raw = readFileSync(path, 'utf-8');
314
+ const raw = readRaw(path);
315
+ if (raw === null) continue;
261
316
  // Compute the marker on raw (pre-slice) so a long body can't truncate
262
317
  // frontmatter out of reach. fail-open: a marker failure drops the marker,
263
318
  // never the page.
@@ -275,7 +330,7 @@ process.stdin.on('end', () => {
275
330
  }
276
331
 
277
332
  if (injected.length === 0) {
278
- const slugs = matched
333
+ const slugs = visibleMatched
279
334
  .slice(0, MAX_HITS)
280
335
  .map((e) => e.slug)
281
336
  .join(', ');
@@ -297,9 +352,11 @@ process.stdin.on('end', () => {
297
352
  recordLookupUsage(HYPO_DIR, { sessionId, slugs: injectedSlugs });
298
353
  }
299
354
 
355
+ // visibleMatched (not matched): a hidden count would still name a "+N more"
356
+ // total that includes machine-other pages this device can never see.
300
357
  const overflow =
301
- matched.length > MAX_HITS
302
- ? `\n(+${matched.length - MAX_HITS} more matches — search wiki index for more)`
358
+ visibleMatched.length > MAX_HITS
359
+ ? `\n(+${visibleMatched.length - MAX_HITS} more matches — search wiki index for more)`
303
360
  : '';
304
361
 
305
362
  console.log(
@@ -42,6 +42,7 @@ process.stdin.on('data', (chunk) => (raw += chunk));
42
42
  process.stdin.on('end', () => {
43
43
  let transcriptPath = null;
44
44
  let sessionId = null;
45
+ let sessionCwd = null;
45
46
  try {
46
47
  const input = JSON.parse(raw || '{}');
47
48
  transcriptPath = input.transcript_path ?? null;
@@ -49,6 +50,11 @@ process.stdin.on('end', () => {
49
50
  // semantics (no project attribution) so /compact does not block a closed
50
51
  // non-project session on the active/phantom project's files.
51
52
  sessionId = input.session_id ?? input.sessionId ?? null;
53
+ // Authoritative session cwd for the session-cwd close check. This is the one
54
+ // verified cwd source (mirrors hypo-session-record); it lets /compact block a
55
+ // session whose own project close was never started, which the recency-based
56
+ // global status cannot see.
57
+ sessionCwd = input.cwd ?? null;
52
58
  } catch {
53
59
  /* fail-open */
54
60
  }
@@ -107,6 +113,7 @@ process.stdin.on('end', () => {
107
113
  gate = precompactGateStatus(HYPO_DIR, {
108
114
  transcriptPath,
109
115
  ...(sessionId ? { sessionId } : {}),
116
+ ...(sessionCwd ? { sessionCwd } : {}),
110
117
  });
111
118
  } catch (err) {
112
119
  // Defense-in-depth: precompactGateStatus fails open per-check, but if it ever
@@ -122,9 +129,11 @@ process.stdin.on('end', () => {
122
129
  // turn the (otherwise non-blocking) drift into a blocker, since real drift is
123
130
  // confirmed and silently passing it would defeat the gate. --write only applies
124
131
  // when no target conflicts/over-caps (code===0 across ALL targets), so a late
125
- // race exits non-zero and blocks here. It is semantic-preflight atomic but not
126
- // filesystem-atomic (concurrent edit / mid-write I/O fault is best-effort)
127
- // pre-existing engine behavior, not introduced by the self-heal.
132
+ // race exits non-zero and blocks here. Each FILE it writes is atomic (tmp+
133
+ // rename, see feedback-sync's atomicWrite), so a mid-write fault can no longer
134
+ // truncate a target; what is still not atomic is the write ACROSS targets (one
135
+ // file can land before another fails), which the preflight narrows to genuine
136
+ // fs errors and no further.
128
137
  let feedbackHealed = '';
129
138
  if (gate.ok && gate.driftTargets.length > 0) {
130
139
  const feedbackPath = PKG_ROOT ? join(PKG_ROOT, 'scripts', 'feedback-sync.mjs') : null;
@@ -179,6 +188,18 @@ process.stdin.on('end', () => {
179
188
  const fold = `+${otherDebtCount} pre-existing lint issue(s) elsewhere in the vault (other projects / shared pages, not blocking) — run \`/hypo:lint\` for the full list.`;
180
189
  noticeText = noticeText ? `${noticeText}\n${fold}` : `[WIKI CHECK] ${fold}`;
181
190
  }
191
+ // A demoted close: some OTHER session left a project's close incomplete. It no
192
+ // longer blocks this compact, but it must still be SEEN — a notice list that the
193
+ // gate silently swallows (suppressOutput when nothing else surfaced) would turn the
194
+ // demotion into a disappearance, and nobody would ever fix the dangling close
195
+ // (codex design BLOCKER).
196
+ const closeDebt = gate.notices.filter((n) => n.type === 'close-debt');
197
+ if (closeDebt.length > 0) {
198
+ const line = `[WIKI CHECK] ${closeDebt.length} project(s) with an incomplete session close from another session (not blocking this compact): ${closeDebt
199
+ .map((n) => n.project)
200
+ .join(', ')} — each is fixed by that project's next close.`;
201
+ noticeText = noticeText ? `${noticeText}\n${line}` : line;
202
+ }
182
203
  // Surface the self-heal so a re-synced projection is not a silent mutation of
183
204
  // the user's MEMORY.md / CLAUDE.md (transparency).
184
205
  if (feedbackHealed) noticeText = noticeText ? `${noticeText}\n${feedbackHealed}` : feedbackHealed;
@@ -12,8 +12,7 @@
12
12
 
13
13
  import { existsSync, mkdirSync, appendFileSync } from 'fs';
14
14
  import { dirname, join } from 'path';
15
- import { hostname } from 'os';
16
- import { HYPO_DIR } from './hypo-shared.mjs';
15
+ import { HYPO_DIR, currentDevice } from './hypo-shared.mjs';
17
16
 
18
17
  const INDEX_PATH = join(HYPO_DIR, '.cache', 'sessions', 'index.jsonl');
19
18
 
@@ -54,7 +53,7 @@ process.stdin.on('end', () => {
54
53
  // machine identity for multi-machine audit. index.jsonl lives
55
54
  // under .cache/ (gitignored in a normal vault), so this is a LOCAL-only
56
55
  // per-session record — accurate for every session, no sync/privacy cost.
57
- device: hostname() || 'unknown',
56
+ device: currentDevice(),
58
57
  };
59
58
  appendFileSync(INDEX_PATH, JSON.stringify(entry) + '\n');
60
59
  } catch (err) {