hypomnema 1.10.1 → 1.10.2

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.
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  import { spawnSync } from 'child_process';
9
- import { relative } from 'path';
9
+ import { join } from 'path';
10
10
  import {
11
11
  HYPO_DIR,
12
12
  loadHypoIgnore,
@@ -17,6 +17,8 @@ import {
17
17
  readAutoStagedRecord,
18
18
  recordAutoStagedBlob,
19
19
  recordTouchedPaths,
20
+ vaultRelatives,
21
+ remainingSpawnTimeoutMs,
20
22
  vaultCommitLockTarget,
21
23
  withFileLock,
22
24
  } from './hypo-shared.mjs';
@@ -30,6 +32,20 @@ import { advanceBaseForWrite, hashContent } from './base-store.mjs';
30
32
  // guard at close. tool_name, not file_path, is the write signal.
31
33
  const WRITE_TOOLS = new Set(['Write', 'Edit', 'MultiEdit']);
32
34
 
35
+ // One time budget for every wait in this hook. hooks.json gives the hook 10 seconds and kills it
36
+ // after that, so waits that each ran to their own limit (vault lock, git, cache lock, session lock,
37
+ // base lock: 5 seconds apiece) could be cut off before the failure flag was written. The evidence
38
+ // comes first, in time as well as in budget: the ownership record (and, if it fails, the failure
39
+ // flag) runs before anything else, the report keeps its own share, and the side work (staging, the
40
+ // auto-staged cache, the base advance) uses only what the record left. A hook killed during the
41
+ // side work has already left its record. Dropped side work is redone or harmless: Stop stages
42
+ // again, a missing cache entry only means one more backup, a missing base advance parks the close
43
+ // on a proposal (the safe direction).
44
+ const AUTO_STAGE_BUDGET_MS = 8000;
45
+ const REPORT_SHARE_MS = 1000; // kept for the output that must still be printed
46
+ const deadline = { end: performance.now() + AUTO_STAGE_BUDGET_MS };
47
+ const until = (shareMs) => ({ end: deadline.end - shareMs });
48
+
33
49
  let input = {};
34
50
  try {
35
51
  const raw = await new Promise((r) => {
@@ -44,75 +60,43 @@ try {
44
60
  process.exit(0);
45
61
  }
46
62
 
47
- const filePath = input.tool_input?.file_path ?? '';
63
+ const givenPath = input.tool_input?.file_path ?? '';
64
+ // Inside the vault by a path comparison on real paths, not a string prefix: a HYPO_DIR with a
65
+ // trailing separator, or a vault reached through a symlink or a relative spelling, still counts.
66
+ // A string test that misses leaves no touched-paths record and no failure flag for a write that
67
+ // is really in the vault, and the close then does not see that uncommitted file as this session's.
68
+ const rels = typeof givenPath === 'string' && givenPath ? vaultRelatives(HYPO_DIR, givenPath) : [];
69
+ const rel = rels[0];
70
+ // The same file spelled from HYPO_DIR, for every git and ignore call below.
71
+ const filePath = rel === undefined ? givenPath : join(HYPO_DIR, rel);
48
72
 
49
- if (filePath.startsWith(HYPO_DIR + '/') || filePath === HYPO_DIR) {
73
+ if (rel !== undefined) {
50
74
  const patterns = loadHypoIgnore(HYPO_DIR);
51
- // Only a write stages: a Read of a vault file must not change the index.
52
- if (
53
- WRITE_TOOLS.has(input.tool_name) &&
54
- (patterns.length === 0 || !isIgnored(filePath, HYPO_DIR, patterns))
55
- ) {
56
- // Under the vault commit lock, like every other writer of the index: the catch-up pre-step
57
- // (clearGeneratedPathsBlockingPull) reads and releases the index while it holds that lock, and
58
- // an add landing in between would stage bytes it is about to set aside. A lock that cannot be
59
- // taken skips the add: the session's scoped auto-commit stages its own paths, so nothing is
60
- // lost.
61
- try {
62
- withFileLock(
63
- vaultCommitLockTarget(HYPO_DIR),
64
- () => {
65
- const rel = relative(HYPO_DIR, filePath);
66
- // A merge in progress (open conflict, or a resolution waiting for its commit): staging
67
- // now would change that resolution. The path is still in the session's touched set
68
- // below, so Stop commits it once the merge is done.
69
- if (
70
- spawnSync('git', ['-C', HYPO_DIR, 'rev-parse', '-q', '--verify', 'MERGE_HEAD'], {
71
- stdio: 'ignore',
72
- }).status === 0
73
- )
74
- return;
75
- // A version a person already staged for this path must reach the commit intact, so that
76
- // it can be backed up there: `git add` would put the tool's bytes over it first. The
77
- // path is left unstaged (it is still in the session's touched set below, so Stop commits
78
- // it). A staged deletion (`git rm`) is skipped the same way: `git add` would turn it into a
79
- // modification. With no answer from git about the index, skip too: Stop stages it either
80
- // way.
81
- const before = indexAndHeadBlobs(HYPO_DIR, rel);
82
- if (
83
- !before ||
84
- isStagedDeletion(before.head, before.indexed) ||
85
- (before.index &&
86
- before.index !== before.head &&
87
- !isAutoStagedBlob(readAutoStagedRecord(HYPO_DIR), rel, before.index, before.head))
88
- )
89
- return;
90
- const add = spawnSync('git', ['-C', HYPO_DIR, 'add', filePath], { stdio: 'ignore' });
91
- if (add.status !== 0) return;
92
- // Record the blob this hook staged (and the HEAD blob it sat on), so the commit can tell
93
- // it from one a person staged later and does not back up Claude's own write.
94
- // Best-effort: with no record the commit saves the blob as it would for a person's.
95
- const after = indexAndHeadBlobs(HYPO_DIR, rel);
96
- if (after?.index) recordAutoStagedBlob(HYPO_DIR, rel, after.index, before.head);
97
- },
98
- { timeoutMs: Number(process.env.HYPO_VAULT_LOCK_TIMEOUT_MS) || 5000 },
99
- );
100
- } catch (err) {
101
- process.stderr.write(
102
- `[hypo-auto-stage] 볼트 잠금을 못 잡아 ${filePath} 의 git add 를 건너뛰었습니다 (${err?.code ?? err?.message})\n`,
103
- );
104
- }
105
- }
106
-
75
+ // A pattern may name the symlink or the directory it points to, so every spelling is checked
76
+ // (hypo-file-watch does the same). Staging and recording use the real path, so an ignored alias
77
+ // would otherwise bring an ignored directory's content into the commit through its other name.
78
+ const ignoredSpellings =
79
+ patterns.length === 0
80
+ ? []
81
+ : rels.filter((r) => isIgnored(join(HYPO_DIR, r), HYPO_DIR, patterns));
107
82
  if (WRITE_TOOLS.has(input.tool_name)) {
108
- const rel = relative(HYPO_DIR, filePath);
109
-
110
83
  // Accumulate this write into the session's scoped auto-commit
111
84
  // set, keyed by session_id (no-op without one; never a shared bucket).
112
85
  // hypo-auto-commit.mjs drains this at Stop instead of sweeping the whole
113
86
  // working tree, so another session's concurrent writes to this vault
114
87
  // never land in THIS session's commit.
115
- const recorded = recordTouchedPaths(HYPO_DIR, input.session_id, rel);
88
+ // The record runs first, before staging: a hook killed while it stages (the harness timeout, a
89
+ // git that hangs) has then already recorded the write, and nothing staged is left unclaimed. It
90
+ // and a failure flag may use everything up to the report share; staging gets what is left.
91
+ // A path that is ignored only under another spelling is not recorded: the commit would
92
+ // check the real path alone and take it. One ignored under its real path is still recorded
93
+ // (the commit leaves it out and the close gate reports it).
94
+ const aliasOnlyIgnored = ignoredSpellings.length > 0 && !ignoredSpellings.includes(rel);
95
+ const recorded =
96
+ aliasOnlyIgnored ||
97
+ recordTouchedPaths(HYPO_DIR, input.session_id, rel, {
98
+ deadline: until(REPORT_SHARE_MS),
99
+ });
116
100
  if (!recorded) {
117
101
  // A lock-timeout or write failure here leaves the file just written
118
102
  // with nothing in ANY session's touched-paths set to bring it into an
@@ -126,6 +110,77 @@ if (filePath.startsWith(HYPO_DIR + '/') || filePath === HYPO_DIR) {
126
110
  );
127
111
  }
128
112
 
113
+ // A path ignored under any of its spellings is recorded (above) but never staged.
114
+ if (ignoredSpellings.length === 0) {
115
+ // Under the vault commit lock, like every other writer of the index: the catch-up pre-step
116
+ // (clearGeneratedPathsBlockingPull) reads and releases the index while it holds that lock, and
117
+ // an add landing in between would stage bytes it is about to set aside. A lock that cannot be
118
+ // taken skips the add: the session's scoped auto-commit stages its own paths, so nothing is
119
+ // lost. Every wait below is cut to what the record left of the hook budget; with no time left
120
+ // the add is skipped the same way.
121
+ const staging = until(REPORT_SHARE_MS);
122
+ const lockWaitMs = Math.min(
123
+ Number(process.env.HYPO_VAULT_LOCK_TIMEOUT_MS) || 5000,
124
+ remainingSpawnTimeoutMs(staging),
125
+ );
126
+ try {
127
+ if (lockWaitMs > 0)
128
+ withFileLock(
129
+ vaultCommitLockTarget(HYPO_DIR),
130
+ () => {
131
+ // A merge in progress (open conflict, or a resolution waiting for its commit): staging
132
+ // now would change that resolution. The path is already in the session's touched
133
+ // set, so Stop commits it once the merge is done. A check that got no answer in time
134
+ // is treated the same: staging on a guess could change the resolution.
135
+ const mergeTimeout = remainingSpawnTimeoutMs(staging);
136
+ if (mergeTimeout === 0) return;
137
+ const merging = spawnSync(
138
+ 'git',
139
+ ['-C', HYPO_DIR, 'rev-parse', '-q', '--verify', 'MERGE_HEAD'],
140
+ { stdio: 'ignore', timeout: mergeTimeout },
141
+ );
142
+ if (merging.status === 0 || merging.error) return;
143
+ // A version a person already staged for this path must reach the commit intact, so that
144
+ // it can be backed up there: `git add` would put the tool's bytes over it first. The
145
+ // path is left unstaged (it is already in the session's touched set, so Stop commits
146
+ // it). A staged deletion (`git rm`) is skipped the same way: `git add` would turn it into a
147
+ // modification. With no answer from git about the index, skip too: Stop stages it either
148
+ // way.
149
+ const before = indexAndHeadBlobs(HYPO_DIR, rel, { deadline: staging });
150
+ if (
151
+ !before ||
152
+ isStagedDeletion(before.head, before.indexed) ||
153
+ (before.index &&
154
+ before.index !== before.head &&
155
+ !isAutoStagedBlob(readAutoStagedRecord(HYPO_DIR), rel, before.index, before.head))
156
+ )
157
+ return;
158
+ const addTimeout = remainingSpawnTimeoutMs(staging);
159
+ if (addTimeout === 0) return;
160
+ const add = spawnSync('git', ['-C', HYPO_DIR, 'add', filePath], {
161
+ stdio: 'ignore',
162
+ timeout: addTimeout,
163
+ });
164
+ if (add.status !== 0) return;
165
+ // Record the blob this hook staged (and the HEAD blob it sat on), so the commit can tell
166
+ // it from one a person staged later and does not back up Claude's own write.
167
+ // Best-effort: with no record the commit saves the blob as it would for a person's.
168
+ const after = indexAndHeadBlobs(HYPO_DIR, rel, { deadline: staging });
169
+ const cacheWaitMs = Math.min(5000, remainingSpawnTimeoutMs(staging));
170
+ if (after?.index && cacheWaitMs > 0)
171
+ recordAutoStagedBlob(HYPO_DIR, rel, after.index, before.head, {
172
+ timeoutMs: cacheWaitMs,
173
+ });
174
+ },
175
+ { timeoutMs: lockWaitMs },
176
+ );
177
+ } catch (err) {
178
+ process.stderr.write(
179
+ `[hypo-auto-stage] 볼트 잠금을 못 잡아 ${filePath} 의 git add 를 건너뛰었습니다 (${err?.code ?? err?.message})\n`,
180
+ );
181
+ }
182
+ }
183
+
129
184
  // Write=proposal gate provenance: when this session's own write lands on one of
130
185
  // the overwrite targets it snapshotted at start, advance that target's base so
131
186
  // the close guard reads the change as "I wrote this", not "someone else did"
@@ -142,7 +197,12 @@ if (filePath.startsWith(HYPO_DIR + '/') || filePath === HYPO_DIR) {
142
197
  input.tool_name === 'Write' && typeof input.tool_input?.content === 'string'
143
198
  ? hashContent(input.tool_input.content)
144
199
  : null;
145
- advanceBaseForWrite(HYPO_DIR, input.session_id, rel, filePath, known);
200
+ // Only what is left after the report share; none left skips the advance.
201
+ const baseWaitMs = remainingSpawnTimeoutMs(until(REPORT_SHARE_MS));
202
+ if (baseWaitMs > 0)
203
+ advanceBaseForWrite(HYPO_DIR, input.session_id, rel, filePath, known, {
204
+ timeoutMs: Math.min(5000, baseWaitMs),
205
+ });
146
206
  }
147
207
  }
148
208
  }
@@ -49,9 +49,8 @@
49
49
  * before anything else runs.
50
50
  */
51
51
 
52
- import { realpathSync } from 'fs';
53
- import { basename, dirname, isAbsolute, join, relative, sep } from 'path';
54
- import { HYPO_DIR, isGateSkipped } from './hypo-shared.mjs';
52
+ import { isAbsolute } from 'path';
53
+ import { HYPO_DIR, isGateSkipped, vaultRelatives } from './hypo-shared.mjs';
55
54
  import { isGeneratedViewPath, isSessionEntryPath } from './session-entries.mjs';
56
55
 
57
56
  // Mirrors hypo-auto-stage.mjs's WRITE_TOOLS: the tools that replace file bytes.
@@ -61,39 +60,6 @@ const GENERATED_DENY =
61
60
  '생성 파일입니다. close payload의 summary/tracks로 기록하세요 (직접 쓰면 다음 close가 덮어씁니다).';
62
61
  const ENTRY_DENY = '원본 항목은 close apply만 만들고 고치지 않습니다.';
63
62
 
64
- // The vault-relative spellings of `filePath` that are inside `root`: the path as given and, when
65
- // the files exist, their real paths (a symlinked vault written through its real path). A string
66
- // prefix test is not used: a trailing separator on HYPO_DIR or a symlink would switch it off.
67
- function insideRelatives(root, filePath) {
68
- const real = (p) => {
69
- try {
70
- return realpathSync(p);
71
- } catch {
72
- return null;
73
- }
74
- };
75
- // The real path of the nearest ancestor that exists, with the segments below it put back: a file
76
- // that does not exist yet, in a directory that does not either, still resolves. `dirname` reaches
77
- // a fixed point at the filesystem root, which ends the walk (`null` when not even that resolves).
78
- const realThroughAncestor = (p) => {
79
- const rest = [];
80
- for (let d = p; ; d = dirname(d)) {
81
- const r = real(d);
82
- if (r !== null) return join(r, ...rest);
83
- if (dirname(d) === d) return null;
84
- rest.unshift(basename(d));
85
- }
86
- };
87
- const pairs = [[root, filePath]];
88
- const realRoot = real(root);
89
- const realFile = realThroughAncestor(filePath);
90
- if (realRoot !== null && realFile !== null) pairs.push([realRoot, realFile]);
91
- return pairs
92
- .map(([r, f]) => relative(r, f))
93
- .filter((rel) => rel !== '' && rel.split(sep)[0] !== '..' && !isAbsolute(rel))
94
- .map((rel) => rel.replace(/\\/g, '/'));
95
- }
96
-
97
63
  let input = {};
98
64
  try {
99
65
  const raw = await new Promise((r) => {
@@ -120,7 +86,7 @@ try {
120
86
  const root = HYPO_DIR.replace(/[\\/]+$/, '') || HYPO_DIR;
121
87
  let rel = null;
122
88
  let reason = null;
123
- for (const candidate of insideRelatives(root, filePath)) {
89
+ for (const candidate of vaultRelatives(root, filePath)) {
124
90
  const spellings = [candidate, candidate.toLowerCase()];
125
91
  reason = spellings.some(isGeneratedViewPath)
126
92
  ? GENERATED_DENY
@@ -16,12 +16,15 @@
16
16
  * This hook never calls precompactGateStatus: its own hooks.json timeout is
17
17
  * 10s and the gate's lint spawn alone budgets 30s, three times over. Instead
18
18
  * it re-checks only the cheap axes (session log, git, hot.md) directly, and
19
- * narrows the git axis with the SAME project-vs-foreign path rule the gate
20
- * uses (isForeignProjectFile / classifyForeignOnlyDirty in hypo-shared.mjs), so
21
- * a different project's dangling close file does not fire a false alarm here
22
- * either. Unlike the gate, this hook never passes transcriptTouched into that
23
- * rule: parsing the transcript is exactly the evidence spec §5 excludes to
24
- * stay inside the 10s budget, so only the cheap path-prefix axis runs here.
19
+ * narrows the git axis with a project-vs-foreign path rule
20
+ * (isForeignProjectFile / classifyForeignOnlyDirty in hypo-shared.mjs), so
21
+ * a different project's dangling close file does not fire a false alarm here.
22
+ * The gate no longer uses that rule: it judges the git axis by this session's
23
+ * touched-paths record, so the two now differ. The record is a per-session
24
+ * file under .cache/sessions, which this hook does not read, and it never
25
+ * parses the transcript either, to stay inside the 10s budget. Only the
26
+ * cheap path-prefix axis runs here. Moving this hook onto the record is a
27
+ * follow-up, not part of the gate change.
25
28
  */
26
29
 
27
30
  import {
@@ -20,6 +20,7 @@ import {
20
20
  currentDevice,
21
21
  scopeVisible,
22
22
  readVisibilityScope,
23
+ vaultRelatives,
23
24
  } from './hypo-shared.mjs';
24
25
 
25
26
  const MAX_CHARS = 2000;
@@ -34,12 +35,20 @@ process.stdin.on('end', () => {
34
35
  data = JSON.parse(raw);
35
36
  } catch {}
36
37
 
37
- const filePath = data.file_path || data.path || '';
38
+ const givenPath = data.file_path || data.path || '';
38
39
 
39
- if (!filePath.startsWith(HYPO_DIR + '/') && filePath !== HYPO_DIR) {
40
+ // Inside the vault by a path comparison on real paths, not a string prefix: a HYPO_DIR with a
41
+ // trailing separator, or a vault reached through a symlink, still counts. Every check below
42
+ // then uses the file spelled from HYPO_DIR. The .hypoignore guard checks every spelling
43
+ // (a pattern may name the symlink or the directory it points to); the real-path one is read.
44
+ const rels =
45
+ typeof givenPath === 'string' && givenPath ? vaultRelatives(HYPO_DIR, givenPath) : [];
46
+ if (rels.length === 0) {
40
47
  console.log(JSON.stringify({ continue: true, suppressOutput: true }));
41
48
  return;
42
49
  }
50
+ const rel = rels[0];
51
+ const filePath = join(HYPO_DIR, rel);
43
52
 
44
53
  // Privacy guard: refuse to emit .hypoignore-matched paths. Without this,
45
54
  // `.env*` or other secrets under HYPO_DIR are re-emitted in this hook's
@@ -48,7 +57,7 @@ process.stdin.on('end', () => {
48
57
  // FileChanged actually appears on a given machine is unmeasured, so the
49
58
  // only safe assumption is that a matched path must never be put in it.
50
59
  const patterns = loadHypoIgnore(HYPO_DIR);
51
- if (patterns.length > 0 && isIgnored(filePath, HYPO_DIR, patterns)) {
60
+ if (patterns.length > 0 && rels.some((r) => isIgnored(join(HYPO_DIR, r), HYPO_DIR, patterns))) {
52
61
  console.log(JSON.stringify({ continue: true, suppressOutput: true }));
53
62
  return;
54
63
  }
@@ -71,7 +80,7 @@ process.stdin.on('end', () => {
71
80
  }
72
81
 
73
82
  const content = fileRaw.slice(0, MAX_CHARS);
74
- const relPath = filePath.replace(HYPO_DIR + '/', '');
83
+ const relPath = rel;
75
84
 
76
85
  // Built inline rather than through buildOutput(): the hook reference's
77
86
  // FileChanged section documents the same two fields as its CwdChanged
@@ -95,12 +104,11 @@ process.stdin.on('end', () => {
95
104
  // this package returns watchPaths to seed that list. The watch list is
96
105
  // session-global though, not scoped to this plugin: if another hook or
97
106
  // plugin seeds it, this matcher-less group fires on every watched file,
98
- // not just wiki ones. The HYPO_DIR check above (`filePath.startsWith(HYPO_DIR
99
- // + '/')`, a string-prefix test that does not resolve symlinks) still
100
- // returns immediately for any path that does not start with HYPO_DIR, so
101
- // a watch seeded elsewhere cannot point this hook at a path outside the
102
- // wiki. A symlink living inside the wiki is a separate case: its path
103
- // still starts with HYPO_DIR and passes the check above, but
107
+ // not just wiki ones. The vaultRelatives check above still returns
108
+ // immediately for any path that is not inside HYPO_DIR (as given or by its
109
+ // real path), so a watch seeded elsewhere cannot point this hook at a path
110
+ // outside the wiki. A symlink living inside the wiki is a separate case:
111
+ // its path is inside HYPO_DIR as given and passes the check above, but
104
112
  // readFileSync follows it, so whatever that link points to, inside the
105
113
  // wiki or not, is what actually gets emitted.
106
114
  console.log(