hypomnema 1.7.0 → 1.7.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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.ko.md +79 -50
- package/README.md +63 -34
- package/hooks/hooks.json +11 -0
- package/hooks/hypo-auto-commit.mjs +92 -15
- package/hooks/hypo-auto-stage.mjs +28 -18
- package/hooks/hypo-close-guard.mjs +246 -0
- package/hooks/hypo-hot-rebuild.mjs +43 -5
- package/hooks/hypo-session-start.mjs +166 -13
- package/hooks/hypo-shared.mjs +1358 -130
- package/hooks/version-check.mjs +92 -0
- package/package.json +4 -1
- package/scripts/crystallize.mjs +112 -2
- package/scripts/doctor.mjs +627 -17
- package/scripts/graph.mjs +27 -12
- package/scripts/init.mjs +66 -7
- package/scripts/lib/git-hooks-dir.mjs +229 -0
- package/scripts/lib/pkg-provenance.mjs +166 -0
- package/scripts/lib/project-create.mjs +5 -1
- package/scripts/lib/rename-marker.mjs +39 -0
- package/scripts/lint.mjs +84 -2
- package/scripts/rename.mjs +223 -18
- package/scripts/stats.mjs +14 -2
- package/scripts/uninstall.mjs +12 -0
- package/scripts/upgrade.mjs +8 -0
- package/templates/gitignore +4 -0
- package/templates/hypo-config.md +1 -1
package/hooks/hypo-shared.mjs
CHANGED
|
@@ -17,13 +17,15 @@ import {
|
|
|
17
17
|
readdirSync,
|
|
18
18
|
realpathSync,
|
|
19
19
|
openSync,
|
|
20
|
-
closeSync,
|
|
21
20
|
unlinkSync,
|
|
22
21
|
renameSync,
|
|
22
|
+
linkSync,
|
|
23
23
|
} from 'fs';
|
|
24
|
-
import { join, relative, basename, dirname } from 'path';
|
|
24
|
+
import { join, relative, basename, dirname, isAbsolute } from 'path';
|
|
25
25
|
import { homedir, hostname, tmpdir } from 'os';
|
|
26
26
|
import { spawnSync } from 'child_process';
|
|
27
|
+
import { randomBytes, createHash } from 'crypto';
|
|
28
|
+
import { fileURLToPath } from 'url';
|
|
27
29
|
|
|
28
30
|
const HOME = homedir();
|
|
29
31
|
|
|
@@ -94,19 +96,289 @@ export const LOG_PATH = join(HYPO_DIR, 'log.md');
|
|
|
94
96
|
export const HOT_PATH = join(HYPO_DIR, 'hot.md');
|
|
95
97
|
export const GUIDE_PATH = join(HYPO_DIR, 'hypo-guide.md');
|
|
96
98
|
|
|
97
|
-
// Package root: written by init/upgrade to ~/.claude/hypo-pkg.json
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
99
|
+
// Package root: written by init/upgrade to ~/.claude/hypo-pkg.json.
|
|
100
|
+
//
|
|
101
|
+
// The plugin channel is the primary distribution path, and
|
|
102
|
+
// Claude Code's own plugin manager updates hooks WITHOUT ever touching
|
|
103
|
+
// hypo-pkg.json — only our own init/upgrade write it. So after a plugin
|
|
104
|
+
// auto-update this file can point at a stale pkgRoot indefinitely with no
|
|
105
|
+
// signal to the user, and PKG_ROOT (resolved here) is what lint/feedback
|
|
106
|
+
// scripts get resolved through — a hook can run at the new version while the
|
|
107
|
+
// script it shells out to still runs from the old one.
|
|
108
|
+
//
|
|
109
|
+
// An earlier version of this cross-check queried ~/.claude/settings.json's
|
|
110
|
+
// `enabledPlugins` + the plugin registry to positively attribute an active
|
|
111
|
+
// install. That was wrong: Claude Code layers `enabledPlugins` across
|
|
112
|
+
// user/project/local/managed settings (project overrides user), and a plugin
|
|
113
|
+
// with no explicit key is enabled by default — so a single-file read of the
|
|
114
|
+
// user settings can neither prove "enabled" nor "disabled". Querying it was
|
|
115
|
+
// certain to misjudge some real layout.
|
|
116
|
+
//
|
|
117
|
+
// This code already knows the one thing that can't be wrong: where IT is
|
|
118
|
+
// running from. `import.meta.url`, walked up to the nearest package.json,
|
|
119
|
+
// names the actual root of the code executing right now — no
|
|
120
|
+
// settings/registry guessing needed. That's `selfLocationPkgRoot()` below.
|
|
121
|
+
//
|
|
122
|
+
// Never throw: this runs at hook-module load time (`export const PKG_ROOT =
|
|
123
|
+
// resolvePkgRoot()`), so a throw here takes the whole hook process down.
|
|
124
|
+
// Every read below is try/caught and every helper fails open to null.
|
|
125
|
+
|
|
126
|
+
/** Non-mutating read of the cached pointer, or null on any absence/corruption. */
|
|
127
|
+
function readCachedPkgRoot() {
|
|
101
128
|
try {
|
|
129
|
+
const p = join(HOME, '.claude', 'hypo-pkg.json');
|
|
130
|
+
if (!existsSync(p)) return null;
|
|
102
131
|
const v = JSON.parse(readFileSync(p, 'utf-8')).pkgRoot;
|
|
103
132
|
return typeof v === 'string' && v ? v : null;
|
|
104
133
|
} catch {
|
|
105
134
|
return null;
|
|
106
135
|
}
|
|
107
136
|
}
|
|
137
|
+
|
|
138
|
+
// A pkgRoot is usable only as an ABSOLUTE path to a real package directory
|
|
139
|
+
// whose package.json carries a version. A relative path (e.g. ".") resolves
|
|
140
|
+
// against whatever cwd the reading process happens to have; a directory that
|
|
141
|
+
// merely EXISTS but carries no versioned package.json is a pointer nothing
|
|
142
|
+
// can actually resolve scripts through. Same contract for every source of a
|
|
143
|
+
// pkgRoot value in this file — the cache included (a cached `pkgRoot: "."`
|
|
144
|
+
// used to pass on directory-existence alone).
|
|
145
|
+
//
|
|
146
|
+
// NOT sufficient by itself for self-location (see selfLocationPkgRoot below):
|
|
147
|
+
// "absolute path + a package.json with SOME version" is true of any Node
|
|
148
|
+
// package anywhere on disk, including one that has nothing to do with
|
|
149
|
+
// Hypomnema — e.g. `$HOME/package.json` from an unrelated project. This
|
|
150
|
+
// contract answers "is this a real, resolvable directory", not "is this OUR
|
|
151
|
+
// package". Callers that need the latter also require self-containment.
|
|
152
|
+
//
|
|
153
|
+
// Exported so scripts/doctor.mjs can apply the SAME predicate to a sidecar's
|
|
154
|
+
// recorded pkgRoot that readVerifiedProvenancePkgRoot() below applies at
|
|
155
|
+
// runtime — doctor used to hand-roll a thinner name+hash check that a
|
|
156
|
+
// version-less "name":"hypomnema" root would pass while the runtime resolver
|
|
157
|
+
// rejects it, so doctor could PASS a sidecar the runtime treats as null. The
|
|
158
|
+
// import direction stays scripts/ → hooks/, never the reverse.
|
|
159
|
+
export function isUsablePkgRootLocal(pkgRoot) {
|
|
160
|
+
if (typeof pkgRoot !== 'string' || !pkgRoot || !isAbsolute(pkgRoot)) return false;
|
|
161
|
+
try {
|
|
162
|
+
const v = JSON.parse(readFileSync(join(pkgRoot, 'package.json'), 'utf-8')).version;
|
|
163
|
+
return typeof v === 'string' && v.length > 0;
|
|
164
|
+
} catch {
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// True iff `candidateRoot` actually CONTAINS the module that is running right
|
|
170
|
+
// now — i.e. `<candidateRoot>/hooks/hypo-shared.mjs` is, on disk, the exact
|
|
171
|
+
// same file as `ownRealPath` (this module's own realpath). This is the
|
|
172
|
+
// decisive check self-location needs and isUsablePkgRootLocal alone cannot
|
|
173
|
+
// give it: a versioned package.json only proves SOME package lives at
|
|
174
|
+
// `candidateRoot`, not that it is the Hypomnema install this code is part of.
|
|
175
|
+
// Without this, walking up from an unrelated location (a standalone-copied
|
|
176
|
+
// hooks/ dir sitting under a directory that happens to have its own,
|
|
177
|
+
// unrelated package.json a few levels up — e.g. `$HOME/package.json`) would
|
|
178
|
+
// silently adopt that unrelated tree as PKG_ROOT, and every script path built
|
|
179
|
+
// from it (`join(PKG_ROOT, 'scripts', 'lint.mjs')`, the crystallize recovery
|
|
180
|
+
// command in hypo-auto-minimal-crystallize.mjs, etc.) would point at files
|
|
181
|
+
// that were never shipped there.
|
|
182
|
+
//
|
|
183
|
+
// Both paths are realpath'd before comparing so a symlinked candidate or a
|
|
184
|
+
// symlinked ancestor of the running module (macOS's /var → /private/var, a
|
|
185
|
+
// symlinked plugin cache dir) still compares correctly. Fails closed (false)
|
|
186
|
+
// on any read error — a candidate this can't positively confirm is never
|
|
187
|
+
// adopted.
|
|
188
|
+
function candidateContainsRunningModule(candidateRoot, ownRealPath) {
|
|
189
|
+
try {
|
|
190
|
+
return realpathSync(join(candidateRoot, 'hooks', 'hypo-shared.mjs')) === ownRealPath;
|
|
191
|
+
} catch {
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Walk up from the PARENT of the directory this module (hooks/hypo-shared.mjs)
|
|
197
|
+
// is actually running from, looking for the nearest ancestor that is (a) a
|
|
198
|
+
// usable package root AND (b) actually contains this exact running module —
|
|
199
|
+
// that IS the package root of the code currently executing, regardless of
|
|
200
|
+
// which channel put it there (plugin, npm, dev checkout).
|
|
201
|
+
//
|
|
202
|
+
// Plugin mode runs hooks straight out of the plugin's own cache directory
|
|
203
|
+
// (`${CLAUDE_PLUGIN_ROOT}/hooks/...`), so this resolves directly to whatever
|
|
204
|
+
// the plugin loader most recently updated — exactly the channel that can
|
|
205
|
+
// silently drift ahead of hypo-pkg.json. A manual/npm install COPIES hooks
|
|
206
|
+
// standalone into ~/.claude/hooks/ with no package.json alongside (the "hooks
|
|
207
|
+
// import only Node built-ins, nothing outside the hooks dir" contract), so
|
|
208
|
+
// this correctly finds nothing self-containing there and resolvePkgRoot()
|
|
209
|
+
// falls through to the cache — which IS authoritative for that channel, since
|
|
210
|
+
// our own init/upgrade own writing it. And even if SOME unrelated ancestor
|
|
211
|
+
// happens to carry its own versioned package.json (a plain "usable" root),
|
|
212
|
+
// candidateContainsRunningModule rejects it: that ancestor's own hooks/
|
|
213
|
+
// subdirectory (if it even has one) is not this file.
|
|
214
|
+
//
|
|
215
|
+
// Bounded walk: up to 6 candidate ancestors above hooksDir's own parent (the
|
|
216
|
+
// ordinary root sits at the very first one; a few extra levels tolerate an
|
|
217
|
+
// unusual nesting depth). Never throws, fails open to null on any error.
|
|
218
|
+
//
|
|
219
|
+
// Split out from selfLocationPkgRoot() (below) so scripts/doctor.mjs can ask
|
|
220
|
+
// the same question about an ARBITRARY installed hooks directory
|
|
221
|
+
// (~/.claude/hooks, ~/.codex/hooks) instead of only about wherever THIS
|
|
222
|
+
// running module happens to live. doctor needs that to tell "self-location
|
|
223
|
+
// genuinely can't resolve for this install" (the standalone-copy steady
|
|
224
|
+
// state, silence is correct) apart from "self-location would resolve but the
|
|
225
|
+
// provenance sidecar is missing/broken" (a real gap — see CONCERN 4's
|
|
226
|
+
// PKG_ROOT-null-and-silent fix in checkProvenanceSidecar). Exported;
|
|
227
|
+
// scripts/ → hooks/ stays the only allowed import direction.
|
|
228
|
+
export function selfLocationPkgRootFrom(hooksDir) {
|
|
229
|
+
let ownRealPath;
|
|
230
|
+
try {
|
|
231
|
+
ownRealPath = realpathSync(join(hooksDir, 'hypo-shared.mjs'));
|
|
232
|
+
} catch {
|
|
233
|
+
return null; // can't even resolve the module at hooksDir — nothing to self-contain against
|
|
234
|
+
}
|
|
235
|
+
try {
|
|
236
|
+
let dir = dirname(hooksDir); // first candidate: the ordinary root, one level above hooks/
|
|
237
|
+
for (let i = 0; i < 6; i++) {
|
|
238
|
+
if (isUsablePkgRootLocal(dir) && candidateContainsRunningModule(dir, ownRealPath)) {
|
|
239
|
+
return dir;
|
|
240
|
+
}
|
|
241
|
+
const parent = dirname(dir);
|
|
242
|
+
if (parent === dir) break; // reached filesystem root
|
|
243
|
+
dir = parent;
|
|
244
|
+
}
|
|
245
|
+
return null;
|
|
246
|
+
} catch {
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function selfLocationPkgRoot() {
|
|
252
|
+
let hooksDir;
|
|
253
|
+
try {
|
|
254
|
+
hooksDir = dirname(fileURLToPath(import.meta.url));
|
|
255
|
+
} catch {
|
|
256
|
+
return null; // can't even resolve our own path
|
|
257
|
+
}
|
|
258
|
+
return selfLocationPkgRootFrom(hooksDir);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Copy-time provenance sidecar (`.hypo-provenance.json`, written next to a
|
|
262
|
+
// standalone-copied hooks/ dir by installHooks/applyHookFiles — see
|
|
263
|
+
// scripts/lib/pkg-provenance.mjs, the writer half of this contract) is the
|
|
264
|
+
// ONLY fallback resolvePkgRoot() gets when self-location can't resolve. The
|
|
265
|
+
// filename and the "hypomnema" name check below must stay byte-identical
|
|
266
|
+
// with scripts/lib/pkg-provenance.mjs — hooks can't import scripts/ (no
|
|
267
|
+
// reaching outside hooks/), so the contract is duplicated, not shared.
|
|
268
|
+
//
|
|
269
|
+
// This is accidental-staleness protection, not a security boundary: any
|
|
270
|
+
// process running as this OS user can edit this JSON file (or hypo-shared.mjs
|
|
271
|
+
// itself) freely. It only catches what installHooks/applyHookFiles left
|
|
272
|
+
// unguarded before this fix — a manual-install hooks/ copy left pointing at
|
|
273
|
+
// an old pkgVersion because a skip-then-refresh-the-cache-anyway sequence
|
|
274
|
+
// (init re-run against an unchanged hooks/ dir) recorded a version the copy
|
|
275
|
+
// on disk never actually became.
|
|
276
|
+
//
|
|
277
|
+
// Producer proof, BOTH required before this pkgRoot is trusted:
|
|
278
|
+
// - the recorded pkgRoot's package.json "name" must be "hypomnema" (not
|
|
279
|
+
// merely "some usable versioned package.json" — isUsablePkgRootLocal
|
|
280
|
+
// alone would accept $HOME/package.json from an unrelated project)
|
|
281
|
+
// - the recorded hypoSharedSha256 must match the SHA-256 of THIS running
|
|
282
|
+
// hypo-shared.mjs file — ties the sidecar to the exact copy it was
|
|
283
|
+
// written next to, so a sidecar surviving a partial re-install (new
|
|
284
|
+
// hooks/*.mjs dropped in, old sidecar left behind) is rejected rather
|
|
285
|
+
// than silently trusted.
|
|
286
|
+
//
|
|
287
|
+
// Scope, stated honestly: hypoSharedSha256 pins ONE file — this file. A
|
|
288
|
+
// sidecar surviving a re-install that touched some OTHER hook (e.g.
|
|
289
|
+
// hypo-personal-check.mjs got a new version, hypo-shared.mjs itself didn't
|
|
290
|
+
// change a byte) still passes this check and PKG_ROOT still resolves,
|
|
291
|
+
// because the running module — the one thing this function can verify
|
|
292
|
+
// without cost — never changed. Catching that wider drift needs hashing
|
|
293
|
+
// every file hooks.json wires up, which is too expensive to do on every
|
|
294
|
+
// hook load (this function runs on every single hook invocation); that
|
|
295
|
+
// broader, once-per-`doctor`-run check is `hooksDigest`
|
|
296
|
+
// (scripts/lib/pkg-provenance.mjs's computeHooksDigest, verified by
|
|
297
|
+
// scripts/doctor.mjs), deliberately NOT read here.
|
|
298
|
+
const PROVENANCE_FILENAME = '.hypo-provenance.json';
|
|
299
|
+
const EXPECTED_PKG_NAME = 'hypomnema';
|
|
300
|
+
|
|
301
|
+
function readVerifiedProvenancePkgRoot(hooksDir) {
|
|
302
|
+
try {
|
|
303
|
+
const raw = JSON.parse(readFileSync(join(hooksDir, PROVENANCE_FILENAME), 'utf-8'));
|
|
304
|
+
const { pkgRoot, hypoSharedSha256 } = raw || {};
|
|
305
|
+
if (!isUsablePkgRootLocal(pkgRoot)) return null;
|
|
306
|
+
const producerPkgJson = JSON.parse(readFileSync(join(pkgRoot, 'package.json'), 'utf-8'));
|
|
307
|
+
if (producerPkgJson.name !== EXPECTED_PKG_NAME) return null;
|
|
308
|
+
if (typeof hypoSharedSha256 !== 'string' || !hypoSharedSha256) return null;
|
|
309
|
+
const ownHash = createHash('sha256')
|
|
310
|
+
.update(readFileSync(join(hooksDir, 'hypo-shared.mjs')))
|
|
311
|
+
.digest('hex');
|
|
312
|
+
if (ownHash !== hypoSharedSha256) return null;
|
|
313
|
+
return pkgRoot;
|
|
314
|
+
} catch {
|
|
315
|
+
return null;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Resolution order: self-location wins whenever it resolves — it is a direct,
|
|
320
|
+
// self-containment-verified fact about the code currently running, not an
|
|
321
|
+
// inference. Only when it cannot resolve (the standalone-copied hooks case
|
|
322
|
+
// above, or a genuine read failure) does the verified provenance sidecar get
|
|
323
|
+
// to answer, checked against the same directory this module is actually
|
|
324
|
+
// running from. The cache (readCachedPkgRoot/hypo-pkg.json) is deliberately
|
|
325
|
+
// NOT a resolution fallback here — a disagreeing provenance sidecar means
|
|
326
|
+
// "stop", not "ask the cache", because the cache is exactly what can be
|
|
327
|
+
// stale (that staleness is this fix's whole premise). readCachedPkgRoot stays
|
|
328
|
+
// in this file only for pkgRootDriftStatus()'s surfacing comparison below.
|
|
329
|
+
function resolvePkgRoot() {
|
|
330
|
+
const self = selfLocationPkgRoot();
|
|
331
|
+
if (self) return self;
|
|
332
|
+
try {
|
|
333
|
+
const hooksDir = dirname(fileURLToPath(import.meta.url));
|
|
334
|
+
return readVerifiedProvenancePkgRoot(hooksDir);
|
|
335
|
+
} catch {
|
|
336
|
+
return null;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
108
339
|
export const PKG_ROOT = resolvePkgRoot();
|
|
109
340
|
|
|
341
|
+
// realpath a path for comparison, falling back to the raw value on any error
|
|
342
|
+
// (missing/unreadable/etc — the comparison then just degrades to a literal
|
|
343
|
+
// string compare, same as before this existed). Never throws.
|
|
344
|
+
function canonicalPath(p) {
|
|
345
|
+
if (typeof p !== 'string' || !p) return p;
|
|
346
|
+
try {
|
|
347
|
+
return realpathSync(p);
|
|
348
|
+
} catch {
|
|
349
|
+
return p;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Tri-state comparison between the cache and the code's own resolved
|
|
354
|
+
// location, for the SURFACING decision only (resolvePkgRoot() above already
|
|
355
|
+
// self-corrects PKG_ROOT in memory regardless of this). Three outcomes:
|
|
356
|
+
// 'match' — cache and self-location agree (or both resolve to nothing
|
|
357
|
+
// comparable) → any earlier drift mark should be CLEARED.
|
|
358
|
+
// 'drift' — self-location resolves and DISAGREES with the cache → notify.
|
|
359
|
+
// 'unknown' — self-location could not be resolved at all → leave any
|
|
360
|
+
// existing mark untouched. This is the PERMANENT steady state
|
|
361
|
+
// for the npm/manual channel (no package.json ships next to the
|
|
362
|
+
// standalone-copied hooks), not a rare hiccup — collapsing it
|
|
363
|
+
// into 'match' would let that channel silently clear a mark it
|
|
364
|
+
// never had grounds to judge, and collapsing it into 'drift'
|
|
365
|
+
// would falsely warn a channel with nothing to compare against.
|
|
366
|
+
//
|
|
367
|
+
// The equality check canonicalizes BOTH sides first: the cache may record a
|
|
368
|
+
// symlink alias of the very same physical root selfLocationPkgRoot() just
|
|
369
|
+
// walked to (a symlinked plugin cache dir, or the same /var vs /private/var
|
|
370
|
+
// split candidateContainsRunningModule already has to handle) — a literal
|
|
371
|
+
// string compare would misreport that as drift.
|
|
372
|
+
//
|
|
373
|
+
// Never throws (every helper it calls already fails open).
|
|
374
|
+
export function pkgRootDriftStatus() {
|
|
375
|
+
const self = selfLocationPkgRoot();
|
|
376
|
+
if (!self) return { status: 'unknown' };
|
|
377
|
+
const cached = readCachedPkgRoot();
|
|
378
|
+
if (canonicalPath(self) === canonicalPath(cached)) return { status: 'match' };
|
|
379
|
+
return { status: 'drift', cached, self };
|
|
380
|
+
}
|
|
381
|
+
|
|
110
382
|
// Optional H2 allowlist for hot.md validation.
|
|
111
383
|
// Set HYPO_ALLOWED_HOT_H2=comma,separated,headings to enable.
|
|
112
384
|
const _allowedH2Env = process.env.HYPO_ALLOWED_HOT_H2;
|
|
@@ -334,18 +606,30 @@ export function hasAnyTodayLogEntry(hypoDir) {
|
|
|
334
606
|
}
|
|
335
607
|
|
|
336
608
|
/**
|
|
337
|
-
*
|
|
338
|
-
*
|
|
339
|
-
* while hypo-hot-rebuild
|
|
340
|
-
*
|
|
341
|
-
*
|
|
609
|
+
* Local and UTC calendar-day strings for a given instant. Both matter because
|
|
610
|
+
* some writers stamp a date in the user's local zone (Claude writing file
|
|
611
|
+
* content) while others stamp UTC (hypo-hot-rebuild, the session-closed
|
|
612
|
+
* marker's `closed_at`) — comparing only one representation opens a
|
|
613
|
+
* ~timezone-offset window where a same-moment date-comparison reads as two
|
|
614
|
+
* different days. Shared by `freshDates()` (today) and any caller comparing
|
|
615
|
+
* against an arbitrary past timestamp (e.g. doctor correlating a marker's
|
|
616
|
+
* `closed_at` against an artifact's local-dated heading).
|
|
617
|
+
* @param {Date} [date]
|
|
618
|
+
* @returns {string[]} 1-2 ISO dates (YYYY-MM-DD), local first.
|
|
619
|
+
*/
|
|
620
|
+
export function localAndUtcDates(date = new Date()) {
|
|
621
|
+
const local = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
|
622
|
+
const utc = date.toISOString().slice(0, 10);
|
|
623
|
+
return local === utc ? [local] : [local, utc];
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* Date strings that count as "today" for freshness checks. See
|
|
628
|
+
* {@link localAndUtcDates} for why both representations are accepted.
|
|
342
629
|
* @returns {string[]} 1-2 ISO dates (YYYY-MM-DD), most-relevant first.
|
|
343
630
|
*/
|
|
344
631
|
export function freshDates() {
|
|
345
|
-
|
|
346
|
-
const local = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
|
347
|
-
const utc = d.toISOString().slice(0, 10);
|
|
348
|
-
return local === utc ? [local] : [local, utc];
|
|
632
|
+
return localAndUtcDates(new Date());
|
|
349
633
|
}
|
|
350
634
|
|
|
351
635
|
// Parse a single frontmatter scalar (mirrors hypo-session-start.mjs /
|
|
@@ -422,7 +706,11 @@ export function pageUsageGuardCachePath(sessionId, hypoDir) {
|
|
|
422
706
|
return join(tmpdir(), `hypo-pageusage-guard-${safe}-${h.toString(36)}.json`);
|
|
423
707
|
}
|
|
424
708
|
|
|
425
|
-
|
|
709
|
+
// `probeFn` is test-only: tests inject a fake to control the "did git answer"
|
|
710
|
+
// outcome deterministically instead of racing a real subprocess under shard
|
|
711
|
+
// load. Production callers never pass it, so they always get `runGitCheckIgnore`
|
|
712
|
+
// below, byte-for-byte the same spawnSync call this file always made.
|
|
713
|
+
export function pageUsageLoggingAllowed(hypoDir, sessionId, probeFn) {
|
|
426
714
|
// The load-bearing commit gate is .hypoignore: it is what hypo-auto-stage and
|
|
427
715
|
// commitWikiChanges actually filter on. Re-check it FRESH on every call (it is
|
|
428
716
|
// cheap, no subprocess) so that if coverage is removed mid-session the guard
|
|
@@ -439,38 +727,99 @@ export function pageUsageLoggingAllowed(hypoDir, sessionId) {
|
|
|
439
727
|
}
|
|
440
728
|
if (!hypoIgnored) return false;
|
|
441
729
|
|
|
442
|
-
return gitIgnoresPageUsageCached(hypoDir, sessionId);
|
|
730
|
+
return gitIgnoresPageUsageCached(hypoDir, sessionId, probeFn);
|
|
443
731
|
}
|
|
444
732
|
|
|
445
733
|
// git check-ignore is the belt signal (defends a manual `git add`); it spawns a
|
|
446
734
|
// subprocess, so cache its result per session. The verdict cached here is only
|
|
447
735
|
// the git signal, never the composite allow decision, so the fresh .hypoignore
|
|
448
736
|
// re-check above always still runs.
|
|
449
|
-
|
|
737
|
+
// How long an unanswered probe suppresses the next one. Long enough that a git
|
|
738
|
+
// that is genuinely wedged costs one 10s wait per half-minute instead of one per
|
|
739
|
+
// prompt, short enough that a blip clears on its own well inside a session.
|
|
740
|
+
const PROBE_BACKOFF_MS = 30000;
|
|
741
|
+
|
|
742
|
+
// The real probe: check-ignore answers with an exit code, 0 = ignored, 1 = not
|
|
743
|
+
// ignored. Split out to a named function so `gitIgnoresPageUsageCached` can take
|
|
744
|
+
// a substitute in tests without touching what production actually spawns.
|
|
745
|
+
function runGitCheckIgnore(hypoDir) {
|
|
746
|
+
return spawnSync('git', ['-C', hypoDir, 'check-ignore', '-q', '--', PAGE_USAGE_REL], {
|
|
747
|
+
timeout: 10000,
|
|
748
|
+
});
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
function gitIgnoresPageUsageCached(hypoDir, sessionId, probeFn = runGitCheckIgnore) {
|
|
752
|
+
// Without a session id every caller lands on the same `default` cache file, so
|
|
753
|
+
// one session's verdict would answer for the next one — and the file outlives
|
|
754
|
+
// both, sitting in tmpdir with nothing to expire it. A verdict that cannot be
|
|
755
|
+
// scoped to a session is not cached at all: re-probe every time instead of
|
|
756
|
+
// serving a stale `true` after .gitignore coverage has been removed.
|
|
757
|
+
const scoped = Boolean(sessionId);
|
|
450
758
|
const cachePath = pageUsageGuardCachePath(sessionId, hypoDir);
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
759
|
+
if (scoped) {
|
|
760
|
+
try {
|
|
761
|
+
if (existsSync(cachePath)) {
|
|
762
|
+
const cached = JSON.parse(readFileSync(cachePath, 'utf-8'));
|
|
763
|
+
if (typeof cached.gitIgnored === 'boolean') return cached.gitIgnored;
|
|
764
|
+
// A recorded outage still stands until it expires. This is what keeps a
|
|
765
|
+
// wedged git off the UserPromptSubmit path: hypo-lookup calls this before
|
|
766
|
+
// it prints, so a 10s wait here is 10s of dead prompt, every prompt.
|
|
767
|
+
if (typeof cached.unavailableUntil === 'number' && Date.now() < cached.unavailableUntil) {
|
|
768
|
+
return false;
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
} catch {
|
|
772
|
+
// corrupt cache → recompute below
|
|
455
773
|
}
|
|
456
|
-
} catch {
|
|
457
|
-
// corrupt cache → recompute below
|
|
458
774
|
}
|
|
459
775
|
|
|
460
|
-
|
|
776
|
+
// check-ignore answers with an exit code: 0 = ignored, 1 = not ignored. Every
|
|
777
|
+
// other outcome means the probe never got to answer — 128 for a fatal git
|
|
778
|
+
// error (hypoDir not a repo yet), or a null status when the timeout below
|
|
779
|
+
// fired or the spawn itself failed, which is what a machine under heavy
|
|
780
|
+
// process load produces. Treating those as a plain `false` is the bug this guards
|
|
781
|
+
// against: it is indistinguishable from git actually saying "not ignored",
|
|
782
|
+
// and the verdict then gets cached, so one blip keeps logging disabled for
|
|
783
|
+
// the rest of the session even after the condition clears.
|
|
784
|
+
// The bound is here to survive a git that never returns (an index.lock held by
|
|
785
|
+
// a dead process, a corrupt repo), not to race a busy machine. It used to be
|
|
786
|
+
// 2s, which a loaded box clears by a hair: instrumenting a one-process-per-suite
|
|
787
|
+
// run produced eight ETIMEDOUT probes, every one of them landing between 2011ms
|
|
788
|
+
// and 2254ms. check-ignore on a real vault costs single-digit milliseconds, so
|
|
789
|
+
// the old bound was three orders of magnitude tighter than the work and was
|
|
790
|
+
// measuring scheduler latency instead of git. 10s still catches a true hang.
|
|
791
|
+
let probe = null;
|
|
461
792
|
try {
|
|
462
|
-
|
|
463
|
-
spawnSync('git', ['-C', hypoDir, 'check-ignore', '-q', '--', PAGE_USAGE_REL], {
|
|
464
|
-
timeout: 2000,
|
|
465
|
-
}).status === 0;
|
|
793
|
+
probe = probeFn(hypoDir);
|
|
466
794
|
} catch {
|
|
467
|
-
|
|
795
|
+
probe = null;
|
|
468
796
|
}
|
|
469
797
|
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
798
|
+
// Inconclusive → fail closed for this call. The verdict is never cached as an
|
|
799
|
+
// answer; what gets recorded is that the probe is down, and only until the
|
|
800
|
+
// backoff expires. So a scheduler blip self-heals within the session, while a
|
|
801
|
+
// wedged git is waited on once per backoff window rather than once per prompt.
|
|
802
|
+
if (!probe || (probe.status !== 0 && probe.status !== 1)) {
|
|
803
|
+
if (scoped) {
|
|
804
|
+
try {
|
|
805
|
+
writeFileSync(
|
|
806
|
+
cachePath,
|
|
807
|
+
JSON.stringify({ unavailableUntil: Date.now() + PROBE_BACKOFF_MS }),
|
|
808
|
+
);
|
|
809
|
+
} catch {
|
|
810
|
+
// non-fatal; the probe just runs again next prompt
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
return false;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
const gitIgnored = probe.status === 0;
|
|
817
|
+
if (scoped) {
|
|
818
|
+
try {
|
|
819
|
+
writeFileSync(cachePath, JSON.stringify({ gitIgnored }));
|
|
820
|
+
} catch {
|
|
821
|
+
// cache write failure is non-fatal; the git probe just reruns next prompt
|
|
822
|
+
}
|
|
474
823
|
}
|
|
475
824
|
return gitIgnored;
|
|
476
825
|
}
|
|
@@ -1270,22 +1619,75 @@ function atomicWriteShared(path, content) {
|
|
|
1270
1619
|
renameSync(tmp, path);
|
|
1271
1620
|
}
|
|
1272
1621
|
|
|
1622
|
+
// Read the pid the current holder recorded in its lockfile (see withFileLock).
|
|
1623
|
+
// Returns null for anything we can't trust as a pid: empty/missing content (a
|
|
1624
|
+
// lock written before this pid-recording existed, or already gone), or content
|
|
1625
|
+
// that doesn't parse as a positive integer. null means "unknown holder" and the
|
|
1626
|
+
// caller falls back to the pre-liveness, mtime-only steal rule.
|
|
1627
|
+
function readLockHolderPid(lockPath) {
|
|
1628
|
+
let raw;
|
|
1629
|
+
try {
|
|
1630
|
+
raw = readFileSync(lockPath, 'utf-8').trim();
|
|
1631
|
+
} catch {
|
|
1632
|
+
return null; // vanished/unreadable mid-check — let the caller's own catch handle it
|
|
1633
|
+
}
|
|
1634
|
+
// Canonical decimal only. `Number()` would accept '1e3' and '0x10' as pids,
|
|
1635
|
+
// which contradicts what this function promises its callers: anything we
|
|
1636
|
+
// can't trust is null, not a number we guessed at.
|
|
1637
|
+
if (!/^[1-9][0-9]*$/.test(raw)) return null;
|
|
1638
|
+
const pid = Number(raw);
|
|
1639
|
+
return Number.isSafeInteger(pid) ? pid : null;
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
// Is `pid` still running? EPERM means it exists but we can't signal it (e.g. a
|
|
1643
|
+
// different user) — treat that as alive too, since "can't prove it's dead" must
|
|
1644
|
+
// not be steal-eligible.
|
|
1645
|
+
function isPidAlive(pid) {
|
|
1646
|
+
try {
|
|
1647
|
+
process.kill(pid, 0);
|
|
1648
|
+
return true;
|
|
1649
|
+
} catch (err) {
|
|
1650
|
+
return err.code === 'EPERM';
|
|
1651
|
+
}
|
|
1652
|
+
}
|
|
1653
|
+
|
|
1273
1654
|
/**
|
|
1274
1655
|
* Run `fn` while holding an exclusive lock on `<targetPath>.lock`.
|
|
1275
1656
|
*
|
|
1276
|
-
* Acquire is a spin on `
|
|
1277
|
-
*
|
|
1278
|
-
*
|
|
1279
|
-
*
|
|
1280
|
-
*
|
|
1281
|
-
*
|
|
1282
|
-
*
|
|
1283
|
-
* the
|
|
1284
|
-
*
|
|
1285
|
-
*
|
|
1286
|
-
*
|
|
1287
|
-
*
|
|
1288
|
-
*
|
|
1657
|
+
* Acquire is a spin on `linkSync(tmp, lock)`, where `tmp` already holds this
|
|
1658
|
+
* process's pid in full: EEXIST means another writer holds it, so poll until it
|
|
1659
|
+
* frees. Linking a complete file is what makes the lock's content trustworthy —
|
|
1660
|
+
* a lock is never observable in a half-written state, so "no pid in there" always
|
|
1661
|
+
* means a genuine pre-liveness lockfile and never a holder mid-acquire. The lock
|
|
1662
|
+
* file's content is therefore the holder's own pid,
|
|
1663
|
+
* so a lock whose mtime is older than `staleMs` is only stolen once we can also
|
|
1664
|
+
* confirm the recorded holder is no longer running (`process.kill(pid, 0)`). A
|
|
1665
|
+
* LIVE holder preempted past `staleMs` is therefore left alone — its lock is not
|
|
1666
|
+
* stolen, so the second writer instead polls through to `timeoutMs` and throws,
|
|
1667
|
+
* falling back to the write=proposal gate instead of racing the live holder's
|
|
1668
|
+
* critical section (this was case (1) of the old mtime-only rule, and it's what
|
|
1669
|
+
* closes it). A lock with no readable/parseable pid (written before this
|
|
1670
|
+
* recording existed) falls back to the old mtime-only rule so pre-existing
|
|
1671
|
+
* lockfiles from a live process on disk still get treated as steal-eligible once
|
|
1672
|
+
* stale — see `readLockHolderPid`. Every steal, and every steal we refuse because
|
|
1673
|
+
* the holder is alive, is logged to stderr so a preemption or an actual steal is
|
|
1674
|
+
* never silent. One edge remains, unrelated to holder liveness: between the
|
|
1675
|
+
* stale `statSync` and the `unlinkSync`, the holder can release and a fresh
|
|
1676
|
+
* holder grab the same path, whose lock we then remove — `staleMs` is set well
|
|
1677
|
+
* above a normal close (seconds) to make that extreme-low-probability, and it is
|
|
1678
|
+
* out of scope here. Release is symmetric: we only unlink the lock while it still
|
|
1679
|
+
* records OUR pid, so a writer that WAS stolen from never removes the lock of the
|
|
1680
|
+
* holder that replaced it. If the lock cannot be acquired within `timeoutMs`,
|
|
1681
|
+
* throw so the caller can fall back to the write=proposal gate — for an append
|
|
1682
|
+
* that means blocking the close (proposal-pending) with no artifact; the next
|
|
1683
|
+
* close re-appends (architecturally consistent with the existing fail-safe).
|
|
1684
|
+
*
|
|
1685
|
+
* Note what refusing to steal from a live holder trades away: a holder that is
|
|
1686
|
+
* alive but permanently hung is now never stolen from, so every later close
|
|
1687
|
+
* times out too. That is availability loss, not a deadlock (`timeoutMs` still
|
|
1688
|
+
* bounds each attempt), but unlike an ordinary lock timeout it does NOT self-heal
|
|
1689
|
+
* on the next close — it needs the hung process to go away. Losing an append is
|
|
1690
|
+
* silent; blocking one is visible, so this is the direction to fail in.
|
|
1289
1691
|
*
|
|
1290
1692
|
* @param {string} targetPath file being guarded (lock is a sibling `.lock`)
|
|
1291
1693
|
* @param {() => T} fn critical section
|
|
@@ -1298,59 +1700,140 @@ export function withFileLock(targetPath, fn, opts = {}) {
|
|
|
1298
1700
|
const lockPath = `${targetPath}.lock`;
|
|
1299
1701
|
mkdirSync(dirname(lockPath), { recursive: true });
|
|
1300
1702
|
const start = Date.now();
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1703
|
+
// Publish the lock ATOMICALLY: write the whole pid into a private sibling
|
|
1704
|
+
// first, then `linkSync` it into place. `openSync(lockPath,'wx')` followed by
|
|
1705
|
+
// a write cannot do this — it publishes an EMPTY lock and fills it in after,
|
|
1706
|
+
// and a holder preempted inside that window looks exactly like a pid-less
|
|
1707
|
+
// legacy lock, so a second writer steals it and both run the critical section.
|
|
1708
|
+
// That is the very bug the liveness check exists to close, so the acquire has
|
|
1709
|
+
// to be atomic for the check to mean anything. `link` also gives us the same
|
|
1710
|
+
// EEXIST-if-taken primitive `wx` did, and it leaves nothing behind when the
|
|
1711
|
+
// write fails: no link, no lock.
|
|
1712
|
+
// The staging name is per-call random, and the staging write is exclusive.
|
|
1713
|
+
// A predictable `<lock>.<pid>.tmp` would be reachable again by a later run of
|
|
1714
|
+
// the same pid, and a crash between the link and the staging unlink leaves tmp
|
|
1715
|
+
// and lock as two names for ONE inode — writing the staging file would then
|
|
1716
|
+
// rewrite the live lock's bytes and mtime, resurrecting a dead lock under a
|
|
1717
|
+
// live pid that the liveness check would then protect. Random + `wx` removes
|
|
1718
|
+
// both the collision and the symlink it could otherwise be pointed through.
|
|
1719
|
+
const tmpPath = `${lockPath}.${randomBytes(8).toString('hex')}.tmp`;
|
|
1720
|
+
let staged = false;
|
|
1721
|
+
// The live-holder refusal is re-evaluated on every poll, but it is one event,
|
|
1722
|
+
// not `timeoutMs / pollMs` of them. Log each once per acquire or a single
|
|
1723
|
+
// preemption buries the hook's stderr under ~100 identical lines.
|
|
1724
|
+
let loggedLiveHolder = false;
|
|
1725
|
+
let loggedSteal = false;
|
|
1726
|
+
try {
|
|
1727
|
+
for (;;) {
|
|
1315
1728
|
try {
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1729
|
+
if (!staged) {
|
|
1730
|
+
try {
|
|
1731
|
+
writeFileSync(tmpPath, String(process.pid), { flag: 'wx' });
|
|
1732
|
+
staged = true;
|
|
1733
|
+
} catch (stageErr) {
|
|
1734
|
+
// Staging fails for the same reason acquisition does — an unwritable
|
|
1735
|
+
// directory — and `openSync(lock,'wx')` used to report exactly that as
|
|
1736
|
+
// EEXIST whenever a lock was already sitting there, sending it down the
|
|
1737
|
+
// contention path. Preserve that: contend if a lock exists, and surface
|
|
1738
|
+
// a genuine write failure otherwise rather than masking it as a timeout.
|
|
1739
|
+
if (!existsSync(lockPath)) throw stageErr;
|
|
1740
|
+
throw Object.assign(new Error('lock-contended'), { code: 'EEXIST' });
|
|
1741
|
+
}
|
|
1742
|
+
}
|
|
1743
|
+
// Kept separate from staging on purpose: EPERM/EMLINK from the link are
|
|
1744
|
+
// real failures, not contention, and must not decay into ELOCKTIMEOUT.
|
|
1745
|
+
linkSync(tmpPath, lockPath);
|
|
1746
|
+
break;
|
|
1747
|
+
} catch (err) {
|
|
1748
|
+
if (err.code !== 'EEXIST') throw err;
|
|
1749
|
+
// Held by another writer. Steal ONLY a demonstrably stale lock; otherwise
|
|
1750
|
+
// wait and eventually time out. The stat and the unlink are handled
|
|
1751
|
+
// separately on purpose: an un-removable stale lock (EACCES/EPERM/EBUSY)
|
|
1752
|
+
// and a fresh lock must both fall through to the timeout check — never
|
|
1753
|
+
// `continue` past it, or an un-unlinkable lock spins forever and violates
|
|
1754
|
+
// the timeoutMs → ELOCKTIMEOUT contract (caller falls to the proposal gate).
|
|
1755
|
+
let stale = false;
|
|
1324
1756
|
try {
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
//
|
|
1757
|
+
// Steal-eligible by age alone; liveness is checked separately below
|
|
1758
|
+
// before we actually act on it.
|
|
1759
|
+
stale = Date.now() - statSync(lockPath).mtimeMs > staleMs;
|
|
1760
|
+
} catch (statErr) {
|
|
1761
|
+
if (statErr.code === 'ENOENT') continue; // lock vanished; retry create now
|
|
1762
|
+
throw statErr; // unexpected stat failure — surface it, don't mask
|
|
1331
1763
|
}
|
|
1764
|
+
if (stale) {
|
|
1765
|
+
const holderPid = readLockHolderPid(lockPath);
|
|
1766
|
+
if (holderPid !== null && isPidAlive(holderPid)) {
|
|
1767
|
+
// LIVE holder preempted past staleMs: do NOT steal. Surface it so the
|
|
1768
|
+
// preemption is visible, then fall through to the poll/timeout path
|
|
1769
|
+
// below instead of racing a second writer into the critical section.
|
|
1770
|
+
if (!loggedLiveHolder) {
|
|
1771
|
+
console.error(
|
|
1772
|
+
`[hypomnema] withFileLock: NOT stealing ${lockPath} — holder pid ${holderPid} is still alive past staleMs=${staleMs}`,
|
|
1773
|
+
);
|
|
1774
|
+
loggedLiveHolder = true;
|
|
1775
|
+
}
|
|
1776
|
+
stale = false;
|
|
1777
|
+
} else if (!loggedSteal) {
|
|
1778
|
+
// Also once per acquire: an un-removable stale lock re-enters this
|
|
1779
|
+
// branch on every poll, and the steal is one event either way.
|
|
1780
|
+
console.error(
|
|
1781
|
+
`[hypomnema] withFileLock: stealing stale lock ${lockPath}` +
|
|
1782
|
+
(holderPid !== null
|
|
1783
|
+
? ` (holder pid ${holderPid} is no longer running)`
|
|
1784
|
+
: ' (no readable holder pid — pre-liveness lockfile)'),
|
|
1785
|
+
);
|
|
1786
|
+
loggedSteal = true;
|
|
1787
|
+
}
|
|
1788
|
+
}
|
|
1789
|
+
if (stale) {
|
|
1790
|
+
try {
|
|
1791
|
+
unlinkSync(lockPath);
|
|
1792
|
+
continue; // stole it; retry the create immediately
|
|
1793
|
+
} catch (unlinkErr) {
|
|
1794
|
+
if (unlinkErr.code === 'ENOENT') continue; // another stealer won; retry
|
|
1795
|
+
// Cannot remove it: do NOT spin — fall through to timeout/sleep so
|
|
1796
|
+
// acquisition eventually throws ELOCKTIMEOUT instead of hanging.
|
|
1797
|
+
}
|
|
1798
|
+
}
|
|
1799
|
+
if (Date.now() - start > timeoutMs) {
|
|
1800
|
+
// Tagged so callers can distinguish "could not get the lock" (fall to the
|
|
1801
|
+
// proposal gate) from a real fn() write error (mkdir/openSync/disk-full),
|
|
1802
|
+
// which must NOT be masked as a timeout.
|
|
1803
|
+
const e = new Error(`lock-timeout: ${lockPath}`);
|
|
1804
|
+
e.code = 'ELOCKTIMEOUT';
|
|
1805
|
+
throw e;
|
|
1806
|
+
}
|
|
1807
|
+
sleepSync(pollMs);
|
|
1332
1808
|
}
|
|
1333
|
-
if (Date.now() - start > timeoutMs) {
|
|
1334
|
-
// Tagged so callers can distinguish "could not get the lock" (fall to the
|
|
1335
|
-
// proposal gate) from a real fn() write error (mkdir/openSync/disk-full),
|
|
1336
|
-
// which must NOT be masked as a timeout.
|
|
1337
|
-
const e = new Error(`lock-timeout: ${lockPath}`);
|
|
1338
|
-
e.code = 'ELOCKTIMEOUT';
|
|
1339
|
-
throw e;
|
|
1340
|
-
}
|
|
1341
|
-
sleepSync(pollMs);
|
|
1342
1809
|
}
|
|
1343
|
-
}
|
|
1344
|
-
try {
|
|
1345
|
-
return fn();
|
|
1346
1810
|
} finally {
|
|
1811
|
+
// The sibling is only ever a staging file: once linked, the lock IS the
|
|
1812
|
+
// link, and on every failure path it must not survive as litter.
|
|
1347
1813
|
try {
|
|
1348
|
-
|
|
1814
|
+
unlinkSync(tmpPath);
|
|
1349
1815
|
} catch {
|
|
1350
|
-
/*
|
|
1816
|
+
/* never created, or already gone */
|
|
1351
1817
|
}
|
|
1818
|
+
}
|
|
1819
|
+
try {
|
|
1820
|
+
return fn();
|
|
1821
|
+
} finally {
|
|
1822
|
+
// Only remove the lock if it is still OURS. Recording the pid makes this
|
|
1823
|
+
// checkable: if we were stolen from (a legacy pid-less lock of ours, or the
|
|
1824
|
+
// stat/unlink window below), the path now holds a DIFFERENT holder's lock and
|
|
1825
|
+
// unlinking it unconditionally would hand a third writer the critical section
|
|
1826
|
+
// while that holder is still inside it. Leaving a foreign lock alone costs
|
|
1827
|
+
// nothing — its own holder releases it, or it goes stale.
|
|
1352
1828
|
try {
|
|
1353
|
-
|
|
1829
|
+
const stillOurs = readLockHolderPid(lockPath);
|
|
1830
|
+
if (stillOurs === process.pid) unlinkSync(lockPath);
|
|
1831
|
+
else if (existsSync(lockPath))
|
|
1832
|
+
console.error(
|
|
1833
|
+
`[hypomnema] withFileLock: not releasing ${lockPath} — it now holds ${
|
|
1834
|
+
stillOurs === null ? 'no readable pid' : `pid ${stillOurs}`
|
|
1835
|
+
}, so ours was stolen`,
|
|
1836
|
+
);
|
|
1354
1837
|
} catch {
|
|
1355
1838
|
/* lock already stolen/removed */
|
|
1356
1839
|
}
|
|
@@ -1464,6 +1947,39 @@ function syncStatePath(hypoDir) {
|
|
|
1464
1947
|
return join(hypoDir, '.cache', 'sync-state.json');
|
|
1465
1948
|
}
|
|
1466
1949
|
|
|
1950
|
+
/**
|
|
1951
|
+
* Classify a sync-state `op` into the guidance branch it needs. Centralized
|
|
1952
|
+
* here — rather than each surface repeating its own string check — because
|
|
1953
|
+
* hypo-session-start.mjs's syncStateNotice and doctor.mjs's checkSyncState
|
|
1954
|
+
* used to each carry their own comparison, and drifted: session-start's
|
|
1955
|
+
* exact `=== 'conflict'` check silently missed 'conflict-unresolved' (the
|
|
1956
|
+
* MORE dangerous op, since the abort itself failed and the tree may still be
|
|
1957
|
+
* half-merged) and fell through to the generic "last sync failed" line,
|
|
1958
|
+
* while doctor's `startsWith('conflict')` already caught it. Both
|
|
1959
|
+
* callers now branch on this single function's return value, so they cannot
|
|
1960
|
+
* silently diverge on WHICH op gets which treatment again — only on the
|
|
1961
|
+
* prose each renders for a given branch.
|
|
1962
|
+
*
|
|
1963
|
+
* Both known ops are matched by EXACT string, not startsWith: a future
|
|
1964
|
+
* `conflict-*` value this function has never seen (e.g. a new syncRemote
|
|
1965
|
+
* failure mode added later) must not silently fall into either known
|
|
1966
|
+
* bucket. 'conflict' asserts the abort succeeded and local work is safely
|
|
1967
|
+
* committed; 'conflict-unresolved' asserts the abort itself failed. Neither
|
|
1968
|
+
* claim is known to hold for an op nobody has written a branch for yet, so
|
|
1969
|
+
* it gets its own conservative 'unknown-conflict' bucket instead of
|
|
1970
|
+
* inheriting either surface's reassurance by accident.
|
|
1971
|
+
*
|
|
1972
|
+
* @param {string} op
|
|
1973
|
+
* @returns {'conflict-unresolved'|'conflict'|'unknown-conflict'|'other'}
|
|
1974
|
+
*/
|
|
1975
|
+
export function classifySyncOp(op) {
|
|
1976
|
+
const s = String(op || '');
|
|
1977
|
+
if (s === 'conflict-unresolved') return 'conflict-unresolved';
|
|
1978
|
+
if (s === 'conflict') return 'conflict';
|
|
1979
|
+
if (s.startsWith('conflict')) return 'unknown-conflict';
|
|
1980
|
+
return 'other';
|
|
1981
|
+
}
|
|
1982
|
+
|
|
1467
1983
|
/**
|
|
1468
1984
|
* Append a sync failure entry. Best-effort — never throws, since a failed
|
|
1469
1985
|
* failure-log must not break the Stop hook that calls it.
|
|
@@ -1528,6 +2044,7 @@ export function syncRemote(hypoDir) {
|
|
|
1528
2044
|
const pull = git('pull', '--no-rebase', '-q');
|
|
1529
2045
|
if (pull.status === 0) {
|
|
1530
2046
|
result.pulled = true;
|
|
2047
|
+
recordSyncSuccess(hypoDir, 'pull');
|
|
1531
2048
|
} else {
|
|
1532
2049
|
// A merge conflict leaves unmerged index entries; a network/auth failure
|
|
1533
2050
|
// leaves none. Only the former must be aborted to keep the tree clean.
|
|
@@ -1552,39 +2069,390 @@ export function syncRemote(hypoDir) {
|
|
|
1552
2069
|
appendSyncFailure(hypoDir, 'pull', pull.stderr || pull.stdout);
|
|
1553
2070
|
}
|
|
1554
2071
|
const push = git('push');
|
|
1555
|
-
if (push.status === 0)
|
|
1556
|
-
|
|
2072
|
+
if (push.status === 0) {
|
|
2073
|
+
result.pushed = true;
|
|
2074
|
+
recordSyncSuccess(hypoDir, 'push');
|
|
2075
|
+
} else appendSyncFailure(hypoDir, 'push', push.stderr || push.stdout);
|
|
1557
2076
|
} catch {
|
|
1558
2077
|
// best-effort — never break the Stop hook
|
|
1559
2078
|
}
|
|
1560
2079
|
return result;
|
|
1561
2080
|
}
|
|
1562
2081
|
|
|
2082
|
+
// ── touched-paths (scope the auto-commit to session-touched paths) ───────────
|
|
2083
|
+
//
|
|
2084
|
+
// The old commitWikiChanges swept the ENTIRE working tree: in a shared
|
|
2085
|
+
// multi-project vault with concurrent Claude Code sessions, another session's
|
|
2086
|
+
// staged/dirty files got committed and pushed by THIS session's Stop hook, and
|
|
2087
|
+
// the human-authored commit message was clobbered. The fix is to accumulate,
|
|
2088
|
+
// per session_id, the vault-relative paths this session actually touched, and
|
|
2089
|
+
// have commitWikiChanges commit only that scope.
|
|
2090
|
+
//
|
|
2091
|
+
// Sources that feed the accumulator:
|
|
2092
|
+
// - hypo-auto-stage.mjs (PostToolUse): every Write/Edit/MultiEdit to a file
|
|
2093
|
+
// under the vault.
|
|
2094
|
+
// - hypo-hot-rebuild.mjs (Stop, runs BEFORE auto-commit): hot.md and log.md,
|
|
2095
|
+
// which are hook-generated, not user Write/Edit — without this a scope
|
|
2096
|
+
// built from Write/Edit alone would drop them from the scoped commit.
|
|
2097
|
+
//
|
|
2098
|
+
// No session_id → never accumulate, and never fall back to a shared "default"
|
|
2099
|
+
// bucket: a path recorded under the wrong key could leak between sessions.
|
|
2100
|
+
|
|
2101
|
+
/** Directory holding one session's cache artifacts, incl. touched-paths.json. */
|
|
2102
|
+
function sessionCacheDir(hypoDir, sessionId) {
|
|
2103
|
+
return join(hypoDir, '.cache', 'sessions', sanitizeSessionId(sessionId));
|
|
2104
|
+
}
|
|
2105
|
+
|
|
2106
|
+
/** @returns {string} path to a session's accumulated touched-paths JSON array. */
|
|
2107
|
+
export function touchedPathsPath(hypoDir, sessionId) {
|
|
2108
|
+
return join(sessionCacheDir(hypoDir, sessionId), 'touched-paths.json');
|
|
2109
|
+
}
|
|
2110
|
+
|
|
1563
2111
|
/**
|
|
1564
|
-
*
|
|
1565
|
-
*
|
|
2112
|
+
* Read the raw touched-paths JSON array off disk. Caller's responsibility to
|
|
2113
|
+
* hold the per-session lock first — this has no locking of its own.
|
|
2114
|
+
*
|
|
2115
|
+
* Absent and unreadable are different answers, and callers MUST branch on
|
|
2116
|
+
* the difference: a genuinely absent (or genuinely empty) file returns `[]`
|
|
2117
|
+
* — safe to treat as "nothing accumulated". A file that exists but fails to
|
|
2118
|
+
* read or parse returns `null` — NOT the same as empty. A caller that
|
|
2119
|
+
* conflates the two (codex FIX 1) and then does a set-difference clear on
|
|
2120
|
+
* `null`-as-`[]` will compute "nothing left" and delete the file outright,
|
|
2121
|
+
* silently losing every pending path to a transient I/O or parse error.
|
|
2122
|
+
*
|
|
2123
|
+
* @returns {string[]|null} the array, or `null` on a read/parse failure
|
|
2124
|
+
*/
|
|
2125
|
+
function readTouchedPathsFile(path) {
|
|
2126
|
+
if (!existsSync(path)) return [];
|
|
2127
|
+
try {
|
|
2128
|
+
const parsed = JSON.parse(readFileSync(path, 'utf-8'));
|
|
2129
|
+
return Array.isArray(parsed) ? parsed.filter((p) => typeof p === 'string' && p) : null;
|
|
2130
|
+
} catch {
|
|
2131
|
+
return null; // corrupt/unreadable — NOT the same as empty; see above
|
|
2132
|
+
}
|
|
2133
|
+
}
|
|
2134
|
+
|
|
2135
|
+
/**
|
|
2136
|
+
* Accumulate vault-relative touched paths for `sessionId`. Best-effort,
|
|
2137
|
+
* dedup-on-insert, JSON array (not delimiter-joined — survives non-ASCII and
|
|
2138
|
+
* any byte a filename can legally hold). No-op without a session_id: never
|
|
2139
|
+
* accumulate into a shared bucket.
|
|
2140
|
+
*
|
|
2141
|
+
* Read-merge-write is guarded by the per-session file lock (the SAME lock
|
|
2142
|
+
* `drainTouchedPaths` takes), so a PostToolUse hook accumulating concurrently
|
|
2143
|
+
* with the Stop-chain drain can never lose a path to either a lost update
|
|
2144
|
+
* (two writers merging from the same stale read) or a drain racing between
|
|
2145
|
+
* this function's read and its write.
|
|
2146
|
+
*
|
|
2147
|
+
* @param {string} hypoDir
|
|
2148
|
+
* @param {string|null|undefined} sessionId
|
|
2149
|
+
* @param {string|string[]} relPaths one or more vault-relative paths
|
|
2150
|
+
*/
|
|
2151
|
+
export function recordTouchedPaths(hypoDir, sessionId, relPaths) {
|
|
2152
|
+
if (!sessionId) return;
|
|
2153
|
+
const incoming = (Array.isArray(relPaths) ? relPaths : [relPaths]).filter(
|
|
2154
|
+
(p) => typeof p === 'string' && p.length > 0,
|
|
2155
|
+
);
|
|
2156
|
+
if (incoming.length === 0) return;
|
|
2157
|
+
const path = touchedPathsPath(hypoDir, sessionId);
|
|
2158
|
+
try {
|
|
2159
|
+
withFileLock(path, () => {
|
|
2160
|
+
const current = readTouchedPathsFile(path);
|
|
2161
|
+
// A corrupt/unreadable file (current === null) is recovered from here,
|
|
2162
|
+
// not preserved: an accumulate is additive by nature (there is nothing
|
|
2163
|
+
// salvageable to merge with), so starting fresh from `incoming` is the
|
|
2164
|
+
// correct best-effort behavior — unlike clearTouchedPaths, where the
|
|
2165
|
+
// same `null` MUST NOT be treated as empty (see readTouchedPathsFile).
|
|
2166
|
+
const merged = new Set(current === null ? [] : current);
|
|
2167
|
+
for (const p of incoming) merged.add(p);
|
|
2168
|
+
atomicWriteShared(path, JSON.stringify([...merged]));
|
|
2169
|
+
});
|
|
2170
|
+
} catch {
|
|
2171
|
+
// best-effort: a hook must never fail a tool call over a cache write
|
|
2172
|
+
// (includes a lock-timeout — a dropped accumulation here is the same
|
|
2173
|
+
// fail-safe shape as every other best-effort cache write in this file).
|
|
2174
|
+
}
|
|
2175
|
+
}
|
|
2176
|
+
|
|
2177
|
+
/**
|
|
2178
|
+
* Read and clear a session's accumulated touched paths in one step — "drain",
|
|
2179
|
+
* not "read", because the caller is expected to consume the WHOLE set
|
|
2180
|
+
* unconditionally. Kept for callers that genuinely want that (e.g. a
|
|
2181
|
+
* probe/inspection path); the Stop-chain auto-commit does NOT use this —
|
|
2182
|
+
* see commitTouchedPaths below, which holds ONE lock across a peek, the
|
|
2183
|
+
* commit itself, and a clear scoped to exactly what committed, so neither a
|
|
2184
|
+
* commit failure nor a same-path race loses anything.
|
|
2185
|
+
*
|
|
2186
|
+
* Guarded by the SAME per-session file lock every touched-paths mutation
|
|
2187
|
+
* takes: the read-then-remove here is mutually exclusive with a concurrent
|
|
2188
|
+
* accumulate or commitTouchedPaths call.
|
|
2189
|
+
*
|
|
2190
|
+
* A read/parse failure is NOT treated as an empty set to be cleared: like
|
|
2191
|
+
* clearTouchedPaths and commitTouchedPaths, a corrupt/unreadable file
|
|
2192
|
+
* (readTouchedPathsFile returns `null`) is left on disk untouched — never
|
|
2193
|
+
* deleted — so a transient I/O or parse error can't erase a set that a human
|
|
2194
|
+
* or a later pass could still recover. Drain returns `[]` in that case
|
|
2195
|
+
* (nothing safely consumable), but does not remove the file.
|
|
2196
|
+
*
|
|
2197
|
+
* @param {string} hypoDir
|
|
2198
|
+
* @param {string|null|undefined} sessionId
|
|
2199
|
+
* @returns {string[]} vault-relative paths, deduped; [] when absent, corrupt, or no session_id
|
|
2200
|
+
*/
|
|
2201
|
+
export function drainTouchedPaths(hypoDir, sessionId) {
|
|
2202
|
+
if (!sessionId) return [];
|
|
2203
|
+
const path = touchedPathsPath(hypoDir, sessionId);
|
|
2204
|
+
try {
|
|
2205
|
+
return withFileLock(path, () => {
|
|
2206
|
+
const result = readTouchedPathsFile(path);
|
|
2207
|
+
if (result === null) return []; // corrupt/unreadable: leave the file for inspection, never delete
|
|
2208
|
+
try {
|
|
2209
|
+
rmSync(path, { force: true });
|
|
2210
|
+
} catch {
|
|
2211
|
+
// best-effort
|
|
2212
|
+
}
|
|
2213
|
+
return result;
|
|
2214
|
+
});
|
|
2215
|
+
} catch {
|
|
2216
|
+
// lock-timeout (or an unexpected lock error): fail closed to "nothing
|
|
2217
|
+
// drained" rather than risk reading concurrently with a writer. The set
|
|
2218
|
+
// stays on disk untouched, so it is still there for the next drain.
|
|
2219
|
+
return [];
|
|
2220
|
+
}
|
|
2221
|
+
}
|
|
2222
|
+
|
|
2223
|
+
/**
|
|
2224
|
+
* Read a session's accumulated touched paths WITHOUT clearing them. Kept as
|
|
2225
|
+
* a standalone probe for callers that just want to inspect the set; the
|
|
2226
|
+
* Stop-chain auto-commit does NOT call this on its own — see
|
|
2227
|
+
* commitTouchedPaths, which performs an equivalent internal read but holds
|
|
2228
|
+
* the lock through the commit and clear that follow it too.
|
|
2229
|
+
*
|
|
2230
|
+
* @param {string} hypoDir
|
|
2231
|
+
* @param {string|null|undefined} sessionId
|
|
2232
|
+
* @returns {string[]} vault-relative paths, deduped; [] when absent, corrupt,
|
|
2233
|
+
* no session_id, or a lock-timeout (fails closed to "nothing to commit" —
|
|
2234
|
+
* the set stays on disk, untouched, for the next Stop)
|
|
2235
|
+
*/
|
|
2236
|
+
export function peekTouchedPaths(hypoDir, sessionId) {
|
|
2237
|
+
if (!sessionId) return [];
|
|
2238
|
+
const path = touchedPathsPath(hypoDir, sessionId);
|
|
2239
|
+
try {
|
|
2240
|
+
return withFileLock(path, () => {
|
|
2241
|
+
const result = readTouchedPathsFile(path);
|
|
2242
|
+
return result === null ? [] : result;
|
|
2243
|
+
});
|
|
2244
|
+
} catch {
|
|
2245
|
+
return [];
|
|
2246
|
+
}
|
|
2247
|
+
}
|
|
2248
|
+
|
|
2249
|
+
/**
|
|
2250
|
+
* Remove exactly `paths` from a session's touched-paths set — a set
|
|
2251
|
+
* difference, not a clear, and NOT a drain: any path a concurrent
|
|
2252
|
+
* PostToolUse (or Stop-chain generator) accumulated before this call is read
|
|
2253
|
+
* fresh under the SAME lock and therefore survives, because it was never in
|
|
2254
|
+
* `paths` to begin with.
|
|
2255
|
+
*
|
|
2256
|
+
* Called only after commitWikiChanges has ACTUALLY committed `paths` (or
|
|
2257
|
+
* confirmed there was nothing to commit) — never on a commit failure.
|
|
2258
|
+
*
|
|
2259
|
+
* Two failure modes this function must not turn into data loss (codex FIX 1
|
|
2260
|
+
* + FIX 2 review):
|
|
2261
|
+
*
|
|
2262
|
+
* - A read/parse failure on the touched-paths file (readTouchedPathsFile
|
|
2263
|
+
* returns `null`, NOT `[]`) must NOT be treated as "nothing left" — that
|
|
2264
|
+
* would make the set difference below compute an empty remainder and
|
|
2265
|
+
* DELETE the file outright on a transient I/O or parse error, losing
|
|
2266
|
+
* every pending path. On `null`, this function does nothing at all:
|
|
2267
|
+
* no write, no delete, the file is left exactly as it was.
|
|
2268
|
+
* - If this clear itself fails (lock-timeout, I/O) after a successful
|
|
2269
|
+
* read, the already-committed paths simply stay in the file: the next
|
|
2270
|
+
* Stop re-peeks them and re-runs commitWikiChanges, which is a clean
|
|
2271
|
+
* no-op (INTERSECT against a tree with no more changes at those paths
|
|
2272
|
+
* yields an empty scoped set). Over-retention is safe by construction —
|
|
2273
|
+
* it can never lose a path, only a commit failure (handled by leaving
|
|
2274
|
+
* the file alone entirely, see commitTouchedPaths) can.
|
|
2275
|
+
*
|
|
2276
|
+
* Standalone use of peekTouchedPaths + clearTouchedPaths as two SEPARATE
|
|
2277
|
+
* lock acquisitions still carries the same-path race codex FIX 2 describes
|
|
2278
|
+
* (a write between the two calls is indistinguishable from the one already
|
|
2279
|
+
* peeked, since the set only tracks path presence, not a generation/version
|
|
2280
|
+
* per path) — that is exactly why the Stop hook uses commitTouchedPaths
|
|
2281
|
+
* instead, which holds ONE lock across the whole peek→commit→clear window
|
|
2282
|
+
* so no write can land inside it at all.
|
|
2283
|
+
*
|
|
2284
|
+
* @param {string} hypoDir
|
|
2285
|
+
* @param {string|null|undefined} sessionId
|
|
2286
|
+
* @param {string[]} paths the exact paths that were just committed
|
|
2287
|
+
*/
|
|
2288
|
+
export function clearTouchedPaths(hypoDir, sessionId, paths) {
|
|
2289
|
+
if (!sessionId) return;
|
|
2290
|
+
const toRemove = new Set((Array.isArray(paths) ? paths : []).filter(Boolean));
|
|
2291
|
+
if (toRemove.size === 0) return;
|
|
2292
|
+
const path = touchedPathsPath(hypoDir, sessionId);
|
|
2293
|
+
try {
|
|
2294
|
+
withFileLock(path, () => {
|
|
2295
|
+
const current = readTouchedPathsFile(path);
|
|
2296
|
+
if (current === null) return; // FIX 1: never delete/write on a read failure
|
|
2297
|
+
const remaining = current.filter((p) => !toRemove.has(p));
|
|
2298
|
+
if (remaining.length === 0) {
|
|
2299
|
+
try {
|
|
2300
|
+
rmSync(path, { force: true });
|
|
2301
|
+
} catch {
|
|
2302
|
+
// best-effort
|
|
2303
|
+
}
|
|
2304
|
+
} else {
|
|
2305
|
+
atomicWriteShared(path, JSON.stringify(remaining));
|
|
2306
|
+
}
|
|
2307
|
+
});
|
|
2308
|
+
} catch {
|
|
2309
|
+
// lock-timeout or unexpected error: leave the file as-is. Safe per the
|
|
2310
|
+
// over-retention argument above — the committed paths simply linger
|
|
2311
|
+
// until a later clear (or drain) removes them, at worst causing a
|
|
2312
|
+
// future no-op re-commit attempt, never a lost path.
|
|
2313
|
+
}
|
|
2314
|
+
}
|
|
2315
|
+
|
|
2316
|
+
/**
|
|
2317
|
+
* Peek a session's touched paths, run `commitFn(paths)` (expected to be
|
|
2318
|
+
* `commitWikiChanges` or an equivalent), and — only when it reports
|
|
2319
|
+
* `committed: true` — clear `paths` from the touched-paths set, ALL under
|
|
2320
|
+
* ONE hold of the per-session file lock (codex FIX 2). This is what the
|
|
2321
|
+
* Stop-chain auto-commit uses instead of calling peekTouchedPaths,
|
|
2322
|
+
* commitFn, and clearTouchedPaths as three separate steps.
|
|
2323
|
+
*
|
|
2324
|
+
* Holding one lock across peek → commit → clear (rather than acquiring and
|
|
2325
|
+
* releasing it three times, with the commit itself unlocked in between)
|
|
2326
|
+
* closes a same-path race: without it, a `recordTouchedPaths` call for a
|
|
2327
|
+
* path already in the just-peeked set could land in the window between the
|
|
2328
|
+
* commit and the clear. Since the touched-paths set only tracks path
|
|
2329
|
+
* PRESENCE (no per-path version/generation), that accumulate call is
|
|
2330
|
+
* indistinguishable from the one already peeked — the clear would remove it
|
|
2331
|
+
* anyway, silently orphaning a real edit that was never actually committed
|
|
2332
|
+
* (it landed on disk after `git commit` already ran, but the touched-paths
|
|
2333
|
+
* record of it just got wiped). With one continuous lock hold, that
|
|
2334
|
+
* accumulate call either fully precedes the peek (so it's included in THIS
|
|
2335
|
+
* commit, since commitWikiChanges reads live git status, not a cached
|
|
2336
|
+
* snapshot) or fully follows the clear (so it's untouched, waiting for the
|
|
2337
|
+
* next Stop) — there is no window where it can land in between.
|
|
2338
|
+
*
|
|
2339
|
+
* Lock order stays vault → per-session everywhere in this codebase (the
|
|
2340
|
+
* Stop hook takes the vault lock before calling this; accumulation only
|
|
2341
|
+
* ever takes the per-session lock, never the vault lock), so holding the
|
|
2342
|
+
* per-session lock for the whole commit here introduces no new ordering and
|
|
2343
|
+
* no deadlock risk.
|
|
2344
|
+
*
|
|
2345
|
+
* FIX 1 (read/parse failure) applies here too: a corrupt/unreadable
|
|
2346
|
+
* touched-paths file is never treated as empty. `commitFn` still runs (with
|
|
2347
|
+
* an empty scope — a safe no-op through commitWikiChanges), but nothing is
|
|
2348
|
+
* ever written to the corrupt file; it is left exactly as it was for a
|
|
2349
|
+
* human or a future recovery pass to look at.
|
|
2350
|
+
*
|
|
2351
|
+
* @param {string} hypoDir
|
|
2352
|
+
* @param {string|null|undefined} sessionId
|
|
2353
|
+
* @param {(paths: string[]) => {committed: boolean, [k: string]: unknown}} commitFn
|
|
2354
|
+
* @returns {{committed: boolean, [k: string]: unknown}} whatever `commitFn` returned,
|
|
2355
|
+
* or `{committed: false, reason: 'touched-paths-lock-timeout'}` if the lock
|
|
2356
|
+
* itself could not be acquired (commitFn never ran; the file is untouched)
|
|
2357
|
+
*/
|
|
2358
|
+
export function commitTouchedPaths(hypoDir, sessionId, commitFn) {
|
|
2359
|
+
if (!sessionId) return commitFn([]);
|
|
2360
|
+
const path = touchedPathsPath(hypoDir, sessionId);
|
|
2361
|
+
try {
|
|
2362
|
+
return withFileLock(path, () => {
|
|
2363
|
+
const current = readTouchedPathsFile(path);
|
|
2364
|
+
if (current === null) {
|
|
2365
|
+
// FIX 1: a read/parse failure is not "empty" — commit nothing this
|
|
2366
|
+
// round (a safe no-op scope) and leave the corrupt file untouched,
|
|
2367
|
+
// rather than let a downstream clear compute "nothing left" and
|
|
2368
|
+
// delete it.
|
|
2369
|
+
return commitFn([]);
|
|
2370
|
+
}
|
|
2371
|
+
const result = commitFn(current);
|
|
2372
|
+
if (result && result.committed && current.length > 0) {
|
|
2373
|
+
// Still under the SAME lock acquired above: no recordTouchedPaths
|
|
2374
|
+
// call for this session can have landed since `current` was read
|
|
2375
|
+
// (it takes this exact lock too), so the set on disk right now is
|
|
2376
|
+
// EXACTLY `current` — clearing it needs no re-read or set
|
|
2377
|
+
// difference, just remove what we already know is the whole thing.
|
|
2378
|
+
try {
|
|
2379
|
+
rmSync(path, { force: true });
|
|
2380
|
+
} catch {
|
|
2381
|
+
// best-effort: the committed paths just linger on disk; the next
|
|
2382
|
+
// Stop re-peeks them and re-runs commitFn, a clean no-op.
|
|
2383
|
+
}
|
|
2384
|
+
}
|
|
2385
|
+
return result;
|
|
2386
|
+
});
|
|
2387
|
+
} catch {
|
|
2388
|
+
// Lock-timeout (or an unexpected lock error): never entered the
|
|
2389
|
+
// critical section, so commitFn never ran and nothing was peeked or
|
|
2390
|
+
// cleared. The touched-paths file is untouched on disk, and the next
|
|
2391
|
+
// Stop retries this session's commit from the same scope.
|
|
2392
|
+
return { committed: false, reason: 'touched-paths-lock-timeout' };
|
|
2393
|
+
}
|
|
2394
|
+
}
|
|
2395
|
+
|
|
2396
|
+
/**
|
|
2397
|
+
* The lock target both commit loci (hypo-auto-commit.mjs Stop hook,
|
|
2398
|
+
* crystallize.mjs --apply-session-close) hold while staging+committing (and,
|
|
2399
|
+
* for the Stop hook, syncing) the vault, so two concurrent sessions on a
|
|
2400
|
+
* shared vault never interleave `git add`/`git commit`/`git pull`/`git push`.
|
|
2401
|
+
* A stable, non-content path — withFileLock only ever touches its `.lock`
|
|
2402
|
+
* sibling, this file itself is never created.
|
|
2403
|
+
*/
|
|
2404
|
+
export function vaultCommitLockTarget(hypoDir) {
|
|
2405
|
+
return join(hypoDir, '.cache', 'vault-commit');
|
|
2406
|
+
}
|
|
2407
|
+
|
|
2408
|
+
/** `projects/<slug>/...` → `<slug>`; anything else → its first path segment. */
|
|
2409
|
+
function projectOfPath(relPath) {
|
|
2410
|
+
const parts = relPath.split('/');
|
|
2411
|
+
if (parts[0] === 'projects' && parts.length > 1 && parts[1]) return parts[1];
|
|
2412
|
+
return parts[0] || relPath;
|
|
2413
|
+
}
|
|
2414
|
+
|
|
2415
|
+
/**
|
|
2416
|
+
* Stage + commit ONLY the caller-supplied `paths`, intersected with what is
|
|
2417
|
+
* actually changed on disk right now. Does NOT pull/push; remote
|
|
2418
|
+
* sync stays in the auto-commit Stop hook (commit is local + cheap; sync is
|
|
1566
2419
|
* network + soft-fail). Shared by hypo-auto-commit.mjs and crystallize.mjs's
|
|
1567
2420
|
* --apply-session-close path so the .hypoignore staging filter cannot diverge
|
|
1568
2421
|
* between the two commit loci.
|
|
1569
2422
|
*
|
|
1570
|
-
*
|
|
1571
|
-
*
|
|
2423
|
+
* The caller supplies the scope; this function never re-derives it from the
|
|
2424
|
+
* whole tree. `paths` absent/empty is a clean no-op (`scoped: 0`), not an
|
|
2425
|
+
* error and not a whole-tree fallback — this is how a caller with nothing to
|
|
2426
|
+
* contribute (e.g. no session_id, so nothing was accumulated) skips cleanly.
|
|
2427
|
+
*
|
|
2428
|
+
* The authoritative scope is INTERSECT(paths, currently-changed): a stale
|
|
2429
|
+
* entry (already committed elsewhere, or never actually changed) is dropped
|
|
2430
|
+
* silently rather than erroring. `.hypoignore` filtering, the staged
|
|
2431
|
+
* re-derivation, the commit-message count, and the final commit all use this
|
|
2432
|
+
* SAME scoped set — no step here may widen back to whole-tree, or another
|
|
2433
|
+
* session's dirty/staged files could slip back in through any one of them.
|
|
2434
|
+
*
|
|
2435
|
+
* "Nothing to commit" (empty scope, or only .hypoignore'd changes) is
|
|
2436
|
+
* SUCCESS, not failure — the caller's tree is already in the state it wanted.
|
|
1572
2437
|
*
|
|
1573
2438
|
* @param {string} hypoDir
|
|
1574
|
-
* @
|
|
1575
|
-
*
|
|
1576
|
-
*
|
|
2439
|
+
* @param {string[]} [paths] vault-relative paths this caller wrote/owns this close
|
|
2440
|
+
* @returns {{committed: boolean, scoped?: number, reason?: string}} committed:true
|
|
2441
|
+
* when a commit was created OR nothing needed committing (scoped:0 in the
|
|
2442
|
+
* latter case); committed:false (with reason) on a real failure: not a git
|
|
2443
|
+
* repo, or git status/add/commit erroring.
|
|
1577
2444
|
*/
|
|
1578
|
-
export function commitWikiChanges(hypoDir) {
|
|
2445
|
+
export function commitWikiChanges(hypoDir, paths) {
|
|
1579
2446
|
const git = (...args) =>
|
|
1580
2447
|
spawnSync('git', ['-C', hypoDir, ...args], { encoding: 'utf-8', timeout: 30000 });
|
|
1581
2448
|
if (git('rev-parse', '--is-inside-work-tree').status !== 0)
|
|
1582
2449
|
return { committed: false, reason: `not a git repository: ${hypoDir}` };
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
2450
|
+
|
|
2451
|
+
const supplied = new Set(
|
|
2452
|
+
(Array.isArray(paths) ? paths : []).filter((p) => typeof p === 'string' && p.length > 0),
|
|
2453
|
+
);
|
|
2454
|
+
if (supplied.size === 0) return { committed: true, scoped: 0 };
|
|
2455
|
+
|
|
1588
2456
|
// `-z`: NUL-separated records with verbatim paths — no surrounding quotes and
|
|
1589
2457
|
// no octal escaping, so non-ASCII paths (Korean page names are normal input
|
|
1590
2458
|
// here) survive intact. Without it, `core.quotepath=true` (the default) yields
|
|
@@ -1596,41 +2464,85 @@ export function commitWikiChanges(hypoDir) {
|
|
|
1596
2464
|
// `.hypoignore` is the project privacy boundary. `git add -A` ignores it, so
|
|
1597
2465
|
// enumerate changed paths, drop ignored ones, then stage explicitly.
|
|
1598
2466
|
const ignorePatterns = loadHypoIgnore(hypoDir);
|
|
1599
|
-
const
|
|
2467
|
+
const scoped = []; // pathspec for `git add -A` — worktree/index paths only
|
|
2468
|
+
const commitScope = []; // pathspec for the final diff/commit --only (superset)
|
|
1600
2469
|
const records = (porcelain.stdout || '').split('\0');
|
|
1601
2470
|
for (let i = 0; i < records.length; i++) {
|
|
1602
2471
|
const rec = records[i];
|
|
1603
2472
|
if (!rec) continue;
|
|
1604
2473
|
const xy = rec.slice(0, 2);
|
|
1605
2474
|
const file = rec.slice(3); // `XY <path>`; the destination path for a rename/copy
|
|
2475
|
+
const isRename = xy[0] === 'R' || xy[1] === 'R';
|
|
1606
2476
|
// A rename OR copy emits two records (`to\0from`); consume the trailing
|
|
1607
2477
|
// `from`. Copy `C` records only appear under `status.renames=copies`, but
|
|
1608
2478
|
// when they do, missing this skip feeds the `from` path to `git add` as a
|
|
1609
2479
|
// bogus pathspec — the exact auto-commit failure this parser fixes.
|
|
1610
|
-
|
|
2480
|
+
let fromFile = null;
|
|
2481
|
+
if (isRename || xy[0] === 'C' || xy[1] === 'C') {
|
|
2482
|
+
i++;
|
|
2483
|
+
fromFile = records[i] || null;
|
|
2484
|
+
}
|
|
1611
2485
|
if (!file) continue;
|
|
2486
|
+
if (!supplied.has(file)) continue; // out of this caller's scope
|
|
1612
2487
|
if (ignorePatterns.length > 0 && isIgnored(join(hypoDir, file), hypoDir, ignorePatterns))
|
|
1613
2488
|
continue;
|
|
1614
|
-
|
|
2489
|
+
scoped.push(file);
|
|
2490
|
+
commitScope.push(file);
|
|
2491
|
+
// A rename's `from` path is the SAME change as its destination — without
|
|
2492
|
+
// it, `git commit --only` on the destination alone commits the addition
|
|
2493
|
+
// but leaves the source's deletion staged as residue (a git --only
|
|
2494
|
+
// quirk, verified). It is NOT added to `git add -A` (the deletion is
|
|
2495
|
+
// already staged by the rename itself, and its worktree entry is gone —
|
|
2496
|
+
// `git add -A -- <a gone-and-already-staged path>` errors "did not match
|
|
2497
|
+
// any files"), only to the commit's pathspec. A copy's `from` is
|
|
2498
|
+
// independently-owned, still-existing content, so it is NOT auto-pulled
|
|
2499
|
+
// in at all: the caller must name it explicitly if it wants it in scope.
|
|
2500
|
+
if (isRename && fromFile) commitScope.push(fromFile);
|
|
1615
2501
|
}
|
|
1616
|
-
if (
|
|
1617
|
-
|
|
2502
|
+
if (scoped.length === 0 && commitScope.length === 0) return { committed: true, scoped: 0 };
|
|
2503
|
+
|
|
2504
|
+
if (scoped.length > 0) {
|
|
2505
|
+
const add = git('add', '-A', '--', ...scoped);
|
|
1618
2506
|
if (add.status !== 0)
|
|
1619
2507
|
return {
|
|
1620
2508
|
committed: false,
|
|
1621
2509
|
reason: `git add failed: ${(add.stderr || '').trim() || 'unknown'}`,
|
|
1622
2510
|
};
|
|
1623
2511
|
}
|
|
1624
|
-
|
|
1625
|
-
|
|
2512
|
+
|
|
2513
|
+
// Re-derive from what actually landed in the index, bounded by the SAME
|
|
2514
|
+
// pathspec (commitScope, the rename-aware superset of `scoped`) — this step
|
|
2515
|
+
// must not widen back to whole-tree either, or another session's already-
|
|
2516
|
+
// staged file would slip into the commit here.
|
|
2517
|
+
const staged = git('diff', '--cached', '--name-only', '-z', '--', ...commitScope);
|
|
2518
|
+
const stagedFiles = (staged.stdout || '').split('\0').filter(Boolean);
|
|
2519
|
+
if (stagedFiles.length === 0) return { committed: true, scoped: 0 };
|
|
2520
|
+
|
|
2521
|
+
const projects = new Set(stagedFiles.map(projectOfPath));
|
|
1626
2522
|
const today = new Date().toISOString().slice(0, 10);
|
|
1627
|
-
const
|
|
2523
|
+
const msg = `auto: ${today} wiki update (${stagedFiles.length} paths across ${projects.size} projects)`;
|
|
2524
|
+
|
|
2525
|
+
// `git commit --only -- <paths>` (first use of --only in this repo): commits
|
|
2526
|
+
// ONLY the staged changes under this pathspec, ignoring anything else
|
|
2527
|
+
// staged in the index — e.g. another session's own staged-but-uncommitted
|
|
2528
|
+
// work sharing this working tree. A bare `git commit -m` would sweep in
|
|
2529
|
+
// every staged path, defeating the whole point of the scoped set above.
|
|
2530
|
+
//
|
|
2531
|
+
// Pathspec is `commitScope`, NOT `stagedFiles`: `git diff --name-only`
|
|
2532
|
+
// collapses a rename to its destination alone (rename detection folds the
|
|
2533
|
+
// pair into one line), so re-deriving the commit's own pathspec from that
|
|
2534
|
+
// output would silently drop the `from` path again and reproduce the
|
|
2535
|
+
// exact `--only`-leaves-the-deletion-staged residue this function's rename
|
|
2536
|
+
// handling exists to avoid (verified). `stagedFiles` still governs the
|
|
2537
|
+
// empty-scope check and the N/M count above — both correct as "how many
|
|
2538
|
+
// logical changes", where a rename is rightly one.
|
|
2539
|
+
const commit = git('commit', '--only', '-m', msg, '--', ...commitScope);
|
|
1628
2540
|
if (commit.status !== 0)
|
|
1629
2541
|
return {
|
|
1630
2542
|
committed: false,
|
|
1631
2543
|
reason: `git commit failed: ${(commit.stderr || '').trim() || 'unknown'}`,
|
|
1632
2544
|
};
|
|
1633
|
-
return { committed: true };
|
|
2545
|
+
return { committed: true, scoped: stagedFiles.length };
|
|
1634
2546
|
}
|
|
1635
2547
|
|
|
1636
2548
|
/**
|
|
@@ -1661,6 +2573,134 @@ export function clearSyncState(hypoDir) {
|
|
|
1661
2573
|
}
|
|
1662
2574
|
}
|
|
1663
2575
|
|
|
2576
|
+
// ── sync-last-success ────────────────────────────────────────
|
|
2577
|
+
// `.cache/sync-last-success.json` is a single JSON object, PER-OPERATION:
|
|
2578
|
+
// { "pull": {"timestamp": "<ISO>", "host": "<os.hostname()>"},
|
|
2579
|
+
// "push": {"timestamp": "<ISO>", "host": "<...>"} }
|
|
2580
|
+
// Either key may be absent (pull-only or push-only history). Deliberately
|
|
2581
|
+
// separate from sync-state.json: that file is failure-only and gets wiped
|
|
2582
|
+
// wholesale on recovery (clearSyncState), so a success record living there
|
|
2583
|
+
// would be erased by the very thing it is meant to survive. syncRemote()
|
|
2584
|
+
// records here on a successful pull/push; session-start's independent
|
|
2585
|
+
// `git pull --ff-only` records a pull here too, so doctor never reports
|
|
2586
|
+
// "never synced" right after a healthy startup pull. doctor reads it via
|
|
2587
|
+
// readSyncLastSuccess; schema + parsing live here only.
|
|
2588
|
+
//
|
|
2589
|
+
// Scope: `.cache/` is gitignored (templates/gitignore), same as
|
|
2590
|
+
// sync-state.json — this file never syncs across machines, so there is no
|
|
2591
|
+
// cross-machine merge to protect. The lock below only has to cover the
|
|
2592
|
+
// same-machine case: two concurrent processes on one machine (a pull-writer
|
|
2593
|
+
// and a push-writer, or two overlapping sessions). `host` is kept per record
|
|
2594
|
+
// purely for provenance (which machine last recorded the op), not because a
|
|
2595
|
+
// remote write could ever land here.
|
|
2596
|
+
|
|
2597
|
+
/** @returns {string} path to the sync-last-success JSON file for a wiki root. */
|
|
2598
|
+
function syncLastSuccessPath(hypoDir) {
|
|
2599
|
+
return join(hypoDir, '.cache', 'sync-last-success.json');
|
|
2600
|
+
}
|
|
2601
|
+
|
|
2602
|
+
/**
|
|
2603
|
+
* A valid last-success record: `{timestamp: string, host: string}`. Anything
|
|
2604
|
+
* else (wrong type, missing field, non-object) is malformed.
|
|
2605
|
+
* @param {unknown} v
|
|
2606
|
+
* @returns {boolean}
|
|
2607
|
+
*/
|
|
2608
|
+
function isValidSyncSuccessRecord(v) {
|
|
2609
|
+
return (
|
|
2610
|
+
v !== null &&
|
|
2611
|
+
typeof v === 'object' &&
|
|
2612
|
+
!Array.isArray(v) &&
|
|
2613
|
+
typeof v.timestamp === 'string' &&
|
|
2614
|
+
v.timestamp.trim().length > 0 &&
|
|
2615
|
+
typeof v.host === 'string' &&
|
|
2616
|
+
v.host.trim().length > 0
|
|
2617
|
+
);
|
|
2618
|
+
}
|
|
2619
|
+
|
|
2620
|
+
/**
|
|
2621
|
+
* Read last-success records. A missing file means "never synced" (not an
|
|
2622
|
+
* error) — the caller distinguishes that from a parse failure via
|
|
2623
|
+
* `parseError`. A present `pull`/`push` field that does not match the
|
|
2624
|
+
* `{timestamp, host}` shape (including an empty/whitespace-only timestamp or
|
|
2625
|
+
* host — a technically-typed but content-free record) is dropped from `data`
|
|
2626
|
+
* (never surfaced as if it were a real record) AND flags `parseError: true`,
|
|
2627
|
+
* so the caller warns ("cannot parse ... inspect manually") instead of
|
|
2628
|
+
* silently rendering `undefined` or an empty value — same corrupt-file
|
|
2629
|
+
* handling as sync-state.json. An unrecognized top-level key (anything other
|
|
2630
|
+
* than `pull`/`push`) likewise flags the whole file as corrupt: this schema
|
|
2631
|
+
* has exactly two legal keys, so a third one is evidence of a hand-edit or a
|
|
2632
|
+
* future/foreign writer, not a shape this reader should quietly tolerate.
|
|
2633
|
+
*
|
|
2634
|
+
* @param {string} hypoDir
|
|
2635
|
+
* @returns {{data: {pull?: {timestamp: string, host: string}, push?: {timestamp: string, host: string}}, parseError: boolean}}
|
|
2636
|
+
*/
|
|
2637
|
+
export function readSyncLastSuccess(hypoDir) {
|
|
2638
|
+
const path = syncLastSuccessPath(hypoDir);
|
|
2639
|
+
if (!existsSync(path)) return { data: {}, parseError: false };
|
|
2640
|
+
try {
|
|
2641
|
+
const parsed = JSON.parse(readFileSync(path, 'utf-8'));
|
|
2642
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
2643
|
+
return { data: {}, parseError: true };
|
|
2644
|
+
let malformed = Object.keys(parsed).some((k) => k !== 'pull' && k !== 'push');
|
|
2645
|
+
const data = {};
|
|
2646
|
+
for (const op of ['pull', 'push']) {
|
|
2647
|
+
if (parsed[op] === undefined) continue;
|
|
2648
|
+
if (isValidSyncSuccessRecord(parsed[op])) data[op] = parsed[op];
|
|
2649
|
+
else malformed = true; // drop the bad field; flag the file as corrupt
|
|
2650
|
+
}
|
|
2651
|
+
return { data, parseError: malformed };
|
|
2652
|
+
} catch {
|
|
2653
|
+
return { data: {}, parseError: true };
|
|
2654
|
+
}
|
|
2655
|
+
}
|
|
2656
|
+
|
|
2657
|
+
/**
|
|
2658
|
+
* Record a successful pull or push. Concurrency-safe on the same machine:
|
|
2659
|
+
* takes the vault file lock on the target path, re-reads the CURRENT file
|
|
2660
|
+
* under the lock, updates only the given op's field (the other op's existing
|
|
2661
|
+
* value survives — a same-machine concurrent pull-writer and push-writer must
|
|
2662
|
+
* not erase each other), then commits via temp-write + rename so a crash or a
|
|
2663
|
+
* concurrent read never sees a half-written file. `.cache/` is gitignored, so
|
|
2664
|
+
* this file never syncs across machines — there is no cross-machine case to
|
|
2665
|
+
* protect here. A pre-existing but unparseable file, or a sibling field that
|
|
2666
|
+
* does not match `{timestamp, host}` (including an empty/whitespace-only
|
|
2667
|
+
* value) or that carries an unrecognized key, is dropped rather than carried
|
|
2668
|
+
* forward (never preserve garbage into a freshly-written record) — the same
|
|
2669
|
+
* `isValidSyncSuccessRecord` predicate readSyncLastSuccess uses.
|
|
2670
|
+
*
|
|
2671
|
+
* Lock acquisition is best-effort — a timeout (or any other failure) is
|
|
2672
|
+
* swallowed, same as appendSyncFailure, since a failed success-log must never
|
|
2673
|
+
* break the caller (Stop hook / SessionStart).
|
|
2674
|
+
*
|
|
2675
|
+
* @param {string} hypoDir
|
|
2676
|
+
* @param {'pull'|'push'} op
|
|
2677
|
+
*/
|
|
2678
|
+
export function recordSyncSuccess(hypoDir, op) {
|
|
2679
|
+
try {
|
|
2680
|
+
const path = syncLastSuccessPath(hypoDir);
|
|
2681
|
+
withFileLock(path, () => {
|
|
2682
|
+
let current = {};
|
|
2683
|
+
try {
|
|
2684
|
+
if (existsSync(path)) {
|
|
2685
|
+
const parsed = JSON.parse(readFileSync(path, 'utf-8'));
|
|
2686
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
2687
|
+
for (const k of ['pull', 'push']) {
|
|
2688
|
+
if (isValidSyncSuccessRecord(parsed[k])) current[k] = parsed[k];
|
|
2689
|
+
}
|
|
2690
|
+
}
|
|
2691
|
+
}
|
|
2692
|
+
} catch {
|
|
2693
|
+
// corrupt existing file: overwrite with a fresh object rather than
|
|
2694
|
+
// carry the corruption forward.
|
|
2695
|
+
}
|
|
2696
|
+
current[op] = { timestamp: new Date().toISOString(), host: hostname() };
|
|
2697
|
+
atomicWriteShared(path, JSON.stringify(current, null, 2) + '\n');
|
|
2698
|
+
});
|
|
2699
|
+
} catch {
|
|
2700
|
+
// best-effort: lock timeout or write failure must never break the caller
|
|
2701
|
+
}
|
|
2702
|
+
}
|
|
2703
|
+
|
|
1664
2704
|
// ── auto-project suggestion ────────────────────────────────────────
|
|
1665
2705
|
// `.cache/project-suggestions.json` is a single JSON object:
|
|
1666
2706
|
// { "skips": [{cwd, declined_at, reason}], "cooldowns": {"<cwd>": "<iso>"} }
|
|
@@ -1960,11 +3000,11 @@ export function clearClearMarker(hypoDir) {
|
|
|
1960
3000
|
// Writer authority lives in crystallize, NOT this hook: the hook only checks
|
|
1961
3001
|
// presence. See amendment 2026-05-19 Q2 for the split rationale.
|
|
1962
3002
|
|
|
1963
|
-
const SESSION_CLOSED_MARKER_STALE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
3003
|
+
export const SESSION_CLOSED_MARKER_STALE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
1964
3004
|
|
|
1965
3005
|
/** Sanitize session_id for filesystem use — Claude session_ids are UUIDs but
|
|
1966
3006
|
* defend against accidental path traversal regardless. */
|
|
1967
|
-
function sanitizeSessionId(sessionId) {
|
|
3007
|
+
export function sanitizeSessionId(sessionId) {
|
|
1968
3008
|
return String(sessionId)
|
|
1969
3009
|
.replace(/[^A-Za-z0-9._-]/g, '_')
|
|
1970
3010
|
.slice(0, 128);
|
|
@@ -3120,6 +4160,118 @@ export function isClosePattern(text) {
|
|
|
3120
4160
|
return [...krPatterns, ...enPatterns].some((re) => re.test(text));
|
|
3121
4161
|
}
|
|
3122
4162
|
|
|
4163
|
+
// ── close ARTIFACTS, not the marker (issue: close gate lives on one writer) ──
|
|
4164
|
+
//
|
|
4165
|
+
// hasUserCloseSignal/isClosePattern answer "did the user ask to close" — the
|
|
4166
|
+
// input side of the gate. This answers a different question: "does this file
|
|
4167
|
+
// or commit message already READ as closed to a human", independent of
|
|
4168
|
+
// whether any marker writer ever ran. The 2026-07-28 incident produced a
|
|
4169
|
+
// closing session-state.md heading, a rewritten hot.md, and a close-worded
|
|
4170
|
+
// commit WITHOUT writing a session-closed marker at all — the hard gate on
|
|
4171
|
+
// the marker writer never fired because the model never called it. Closing
|
|
4172
|
+
// is a set of user-visible artifacts, not one internal file.
|
|
4173
|
+
//
|
|
4174
|
+
// Narrowed scope — this is a PARTIAL defense, not the completed artifact-set
|
|
4175
|
+
// gate: hot.md's REPLACEMENT is a diff against the prior version, which a
|
|
4176
|
+
// pure function taking only current content cannot see. This only catches a
|
|
4177
|
+
// hot.md that carries close vocabulary in its own bold heading — a hot.md
|
|
4178
|
+
// rewritten with a closing narrative that happens not to use 마감/종료
|
|
4179
|
+
// literally (as the incident's own hot.md rewrite did) will NOT trip this
|
|
4180
|
+
// signal. That is a real, known false-negative, not a corner this slice
|
|
4181
|
+
// closes: catching it needs a diff-aware check (comparing against the prior
|
|
4182
|
+
// committed hot.md, or a PreToolUse guard that sees the edit as it happens),
|
|
4183
|
+
// which is future work, not something reusing isClosePattern's heuristics
|
|
4184
|
+
// would fix either. The session-state heading and commit-message signals
|
|
4185
|
+
// still catch the one incident on record, so today's gap is documented, not
|
|
4186
|
+
// silent — but it is a gap, not a boundary this slice was designed to hold.
|
|
4187
|
+
//
|
|
4188
|
+
// A close word used as a NOUN-MODIFIER is not an announcement — "마감
|
|
4189
|
+
// 조건을/여부/로직/절차/정책" all describe something ABOUT closing, not a close
|
|
4190
|
+
// that happened. Enumerating the modifiers (blacklist, round 1) and then the
|
|
4191
|
+
// verb suffixes that ARE an announcement (whitelist, round 2) both kept
|
|
4192
|
+
// finding new words each review round — neither converges, because both are
|
|
4193
|
+
// open lexical classes. The convergent rule is a WORD-BOUNDARY, not a word
|
|
4194
|
+
// list: Korean verb conjugation attaches directly with no space (마감했다,
|
|
4195
|
+
// 마감되었습니다, 마감했습니다, 마감됨 — see the corpus in
|
|
4196
|
+
// tests/close-signals.test.mjs), while a noun-modifier is a SEPARATE word
|
|
4197
|
+
// after a space (마감 조건, 마감 정책, 종료 여부, 종료 절차). So: a close word
|
|
4198
|
+
// followed immediately (no space) by more Hangul is a conjugation and
|
|
4199
|
+
// announces a close; a close word followed by whitespace THEN Hangul is a
|
|
4200
|
+
// modifier and does not. `CLOSE_WORD_TAIL` consumes the (possibly empty)
|
|
4201
|
+
// directly-attached conjugation run, then requires the close word to be
|
|
4202
|
+
// effectively the LAST content in its heading: only a parenthetical, ":" +
|
|
4203
|
+
// trailing narrative, terminal punctuation, or the bold-heading's end may
|
|
4204
|
+
// follow — a bare space-separated Hangul word after it fails to match any of
|
|
4205
|
+
// those and rejects. Known accepted limit: a same-word-no-space compound like
|
|
4206
|
+
// "마감일" (deadline-date, a noun) would match — over the FN/FP asymmetry this
|
|
4207
|
+
// check is built on (a missed real close is the exact failure mode it exists
|
|
4208
|
+
// to catch; a stray warn is mild noise), that's the accepted direction.
|
|
4209
|
+
const CLOSE_WORD_TAIL = '[가-힣]*(?:\\s*:[^*\\n]*|(?:\\([^)]*\\))?[.,]?\\s*)';
|
|
4210
|
+
const SESSION_STATE_CLOSE_HEADING = new RegExp(
|
|
4211
|
+
`\\*\\*(\\d{4}-\\d{2}-\\d{2})[^*\\n]*?마감${CLOSE_WORD_TAIL}\\*\\*`,
|
|
4212
|
+
);
|
|
4213
|
+
const HOT_CLOSE_NARRATIVE = new RegExp(
|
|
4214
|
+
`\\*\\*(\\d{4}-\\d{2}-\\d{2})[^*\\n]*?(?:마감|세션\\s*종료)${CLOSE_WORD_TAIL}\\*\\*`,
|
|
4215
|
+
);
|
|
4216
|
+
// EN: requires the CLOSE's object to actually be "session" ("close the
|
|
4217
|
+
// session" / "close the Nth session" — an ORDINAL or digit-ordinal, the only
|
|
4218
|
+
// forms a real close commit uses), not any noun ("close the database
|
|
4219
|
+
// session", "close the browser session" are technical commits, not a
|
|
4220
|
+
// session-close). KR: same word-boundary rule as the heading patterns above,
|
|
4221
|
+
// applied as a lookahead only (a commit subject isn't bold-wrapped, so there
|
|
4222
|
+
// is no terminal structure to consume into) — reject only when a bare SPACE
|
|
4223
|
+
// separates the close word from a following Hangul word; a directly-attached
|
|
4224
|
+
// conjugation (세션을 종료했다) still matches. "을/를" is the object particle
|
|
4225
|
+
// Korean puts between "세션" and its verb.
|
|
4226
|
+
const ORDINAL =
|
|
4227
|
+
'(?:\\d+(?:st|nd|rd|th)|first|second|third|fourth|fifth|sixth|seventh|eighth|ninth|tenth|' +
|
|
4228
|
+
'eleventh|twelfth|thirteenth|fourteenth|fifteenth|sixteenth|seventeenth|eighteenth|nineteenth|twentieth)';
|
|
4229
|
+
const CLOSE_COMMIT_MESSAGE = new RegExp(
|
|
4230
|
+
`\\b(?:session:\\s*)?close(?:s|d)?\\s+(?:the|this)\\s+(?:${ORDINAL}\\s+)?session\\b|` +
|
|
4231
|
+
`세션(?:을|를)?\\s*(?:마무리|마감|종료)(?!\\s+[가-힣])`,
|
|
4232
|
+
'i',
|
|
4233
|
+
);
|
|
4234
|
+
|
|
4235
|
+
/**
|
|
4236
|
+
* Pure predicate: is this file content, or this commit message, a
|
|
4237
|
+
* session-close ARTIFACT (something a human would read as "this session was
|
|
4238
|
+
* closed")? Never reads the filesystem — callers own IO. `path`'s basename
|
|
4239
|
+
* selects which pattern applies (`session-state.md` vs `hot.md`); pass
|
|
4240
|
+
* `commitMessage` instead for a git log entry.
|
|
4241
|
+
*
|
|
4242
|
+
* Consumed today by doctor's post-hoc check: an artifact with no matching
|
|
4243
|
+
* session-closed marker for its date is a signature of a hand-made close
|
|
4244
|
+
* that never went through the gate. Meant to also back a future PreToolUse
|
|
4245
|
+
* guard on the same definition, so the two defenses can't drift apart on
|
|
4246
|
+
* what "close" means.
|
|
4247
|
+
*
|
|
4248
|
+
* @param {{path?: string|null, content?: string|null, commitMessage?: string|null}} input
|
|
4249
|
+
* @returns {{matched: boolean, kind: string|null, date: string|null}} `date`
|
|
4250
|
+
* is the YYYY-MM-DD captured from the artifact's own heading, or null for a
|
|
4251
|
+
* commit-message match (the caller already has the commit's date).
|
|
4252
|
+
*/
|
|
4253
|
+
export function detectSessionCloseArtifact({
|
|
4254
|
+
path = null,
|
|
4255
|
+
content = null,
|
|
4256
|
+
commitMessage = null,
|
|
4257
|
+
} = {}) {
|
|
4258
|
+
if (typeof commitMessage === 'string' && CLOSE_COMMIT_MESSAGE.test(commitMessage)) {
|
|
4259
|
+
return { matched: true, kind: 'commit-message', date: null };
|
|
4260
|
+
}
|
|
4261
|
+
if (typeof path === 'string' && typeof content === 'string') {
|
|
4262
|
+
const base = path.split(/[\\/]/).pop();
|
|
4263
|
+
if (base === 'session-state.md') {
|
|
4264
|
+
const m = SESSION_STATE_CLOSE_HEADING.exec(content);
|
|
4265
|
+
if (m) return { matched: true, kind: 'session-state-heading', date: m[1] };
|
|
4266
|
+
}
|
|
4267
|
+
if (base === 'hot.md') {
|
|
4268
|
+
const m = HOT_CLOSE_NARRATIVE.exec(content);
|
|
4269
|
+
if (m) return { matched: true, kind: 'hot-narrative', date: m[1] };
|
|
4270
|
+
}
|
|
4271
|
+
}
|
|
4272
|
+
return { matched: false, kind: null, date: null };
|
|
4273
|
+
}
|
|
4274
|
+
|
|
3123
4275
|
/**
|
|
3124
4276
|
* Resolve a session's transcript path from its (globally-unique) session id by
|
|
3125
4277
|
* globbing every Claude project dir: ~/.claude/projects/<slug>/<id>.jsonl.
|
|
@@ -3181,6 +4333,9 @@ export function resolveTranscriptBySessionId(
|
|
|
3181
4333
|
* • INVALIDATE a fresh user intent that expires the lease: any other genuine
|
|
3182
4334
|
* user text, `/clear`, `popAll`, a non-close queued_command, a
|
|
3183
4335
|
* non-close AskUserQuestion selection.
|
|
4336
|
+
* • NEUTRAL additionally: a typed `apply-proposals <nonce>` whose nonce this
|
|
4337
|
+
* transcript shows `proposal challenge` minting (see "the approval
|
|
4338
|
+
* line" below).
|
|
3184
4339
|
* • NEUTRAL everything the model can produce or the harness injects: system/
|
|
3185
4340
|
* sdk replay, isMeta bodies, sidechain, interruptedMessageId
|
|
3186
4341
|
* companions, assistant, tool_result, task-notification.
|
|
@@ -3193,9 +4348,45 @@ export function resolveTranscriptBySessionId(
|
|
|
3193
4348
|
* known limit (no leaf pointer exists to resolve it — see the branch note on the
|
|
3194
4349
|
* function), mitigated by the lease.
|
|
3195
4350
|
*
|
|
4351
|
+
* THE APPROVAL LINE IS NOT A CHANGE OF MIND. The lease exists to catch a user who
|
|
4352
|
+
* changed their mind, and it also caught the user for doing what the close
|
|
4353
|
+
* procedure told them to do. `proposal challenge` instructs the user to type
|
|
4354
|
+
* `apply-proposals <nonce>`, which can never be a close phrase, so typing it
|
|
4355
|
+
* expired the close grant given a turn earlier (measured 2026-08-06: the close
|
|
4356
|
+
* needed three approval round trips because of it). Two gates read one transcript
|
|
4357
|
+
* under two rules, and passing one broke the other.
|
|
4358
|
+
*
|
|
4359
|
+
* So one carve-out: a user message that IS the approval line, for a nonce this
|
|
4360
|
+
* transcript shows being minted, is NEUTRAL. It never grants; a lease that was not
|
|
4361
|
+
* already live stays dead. The mint has to be correlated, not merely present:
|
|
4362
|
+
* `collectMinted` reads only a tool_result correlated to a Bash tool_use, and only
|
|
4363
|
+
* when the surrounding challenge framing is there too, so a hex the model typed in
|
|
4364
|
+
* its own prose (or any other tool's output) is not a mint. Order is part of it:
|
|
4365
|
+
* the set fills as the walk goes, so a message can never mint the nonce it spends.
|
|
4366
|
+
* An unminted hex is ordinary text and still expires the lease.
|
|
4367
|
+
*
|
|
4368
|
+
* What this does NOT cover: an AskUserQuestion answer during the close procedure
|
|
4369
|
+
* (the skill's Step 1a reflections) still expires the lease. A carve-out was
|
|
4370
|
+
* written for it and withdrawn, because "the answer does not read as a retraction"
|
|
4371
|
+
* is fail-open on an open class of phrasings, and this repo routes an unknown value
|
|
4372
|
+
* to the cautious branch. Recognizing a close-procedure answer POSITIVELY needs a
|
|
4373
|
+
* marker on the question, and the model authors the question. The round trip stays.
|
|
4374
|
+
*
|
|
3196
4375
|
* Threat model: an honest-but-drifting model that does not forge the
|
|
3197
4376
|
* transcript. This is a safety interlock, not an authorization boundary — a model
|
|
3198
|
-
* that forges records is out of scope and documented so.
|
|
4377
|
+
* that forges records is out of scope and documented so. The carve-out does not
|
|
4378
|
+
* move that boundary: it only PRESERVES a grant the user already gave, so the model
|
|
4379
|
+
* still cannot manufacture one. The residual it does carry: a model that runs an
|
|
4380
|
+
* arbitrary Bash command can print the challenge framing itself and plant a mint.
|
|
4381
|
+
* That buys it nothing on its own, because the user still has to send the approval
|
|
4382
|
+
* line, and the result is neutrality rather than a grant.
|
|
4383
|
+
*
|
|
4384
|
+
* A command invocation is NOT a channel here. `/hypo:crystallize` typed by the user
|
|
4385
|
+
* was accepted as a grant for one review round and removed: the harness records a
|
|
4386
|
+
* model-issued Skill call as the same non-meta `<command-name>` user record
|
|
4387
|
+
* (verified against Claude Code 2.1.243's `processPromptSlashCommand`), so the
|
|
4388
|
+
* model could have closed the session by calling its own skill. The premise that a
|
|
4389
|
+
* command tag proves a human typed it is false.
|
|
3199
4390
|
*
|
|
3200
4391
|
* Fail-closed: any read/parse error, or an unresolvable branch, → false.
|
|
3201
4392
|
*/
|
|
@@ -3292,9 +4483,42 @@ export function hasUserCloseSignal(transcriptPath) {
|
|
|
3292
4483
|
// staleness limit, mitigated by the lease: any later live user intent, on any
|
|
3293
4484
|
// branch, still expires it.
|
|
3294
4485
|
const askIds = new Set();
|
|
4486
|
+
// Bash tool_use ids, so a mint can be tied to a command that actually ran rather
|
|
4487
|
+
// than to any string in the file. Filled in the same content scan as askIds.
|
|
4488
|
+
const bashIds = new Set();
|
|
3295
4489
|
let granted = false;
|
|
4490
|
+
// The nonces this transcript shows `proposal challenge` minting. Producer, not
|
|
4491
|
+
// just presence: the hex has to arrive in a NON-error tool_result of a Bash
|
|
4492
|
+
// tool_use, wrapped in the challenge's own framing. Model prose carrying a
|
|
4493
|
+
// plausible hex, an isMeta/system body, and any other tool's output all fail
|
|
4494
|
+
// that, which matters because a mint neutralizes the user's next message. The
|
|
4495
|
+
// set fills as the walk goes, so a message cannot mint the nonce it spends.
|
|
4496
|
+
const mintedNonces = new Set();
|
|
4497
|
+
const challengeMint = new RegExp(
|
|
4498
|
+
`must type this line in the conversation:\\s*${APPROVAL_PHRASE} ([a-f0-9]{32,})\\s*Then run: hypomnema proposal resolve`,
|
|
4499
|
+
'g',
|
|
4500
|
+
);
|
|
4501
|
+
const approvalLine = new RegExp(`^${APPROVAL_PHRASE}\\s+([a-f0-9]{32,})$`);
|
|
4502
|
+
const collectMinted = (o) => {
|
|
4503
|
+
const c = (o.message ?? o).content;
|
|
4504
|
+
if (!Array.isArray(c)) return;
|
|
4505
|
+
for (const b of c) {
|
|
4506
|
+
if (!b || typeof b !== 'object') continue;
|
|
4507
|
+
if (b.type !== 'tool_result' || !b.tool_use_id || !bashIds.has(b.tool_use_id)) continue;
|
|
4508
|
+
if (b.is_error === true) continue;
|
|
4509
|
+
const text = typeof b.content === 'string' ? b.content : JSON.stringify(b.content);
|
|
4510
|
+
if (typeof text !== 'string' || !text.includes(APPROVAL_PHRASE)) continue;
|
|
4511
|
+
for (const m of text.matchAll(challengeMint)) mintedNonces.add(m[1]);
|
|
4512
|
+
}
|
|
4513
|
+
};
|
|
3296
4514
|
|
|
3297
4515
|
for (const o of recs) {
|
|
4516
|
+
// Genuine user text of this record, or null when the record is on a channel
|
|
4517
|
+
// the model can reach. Computed once here: the mint scan below is the exact
|
|
4518
|
+
// complement of it (mint from everything that is NOT the user's own text).
|
|
4519
|
+
const userText = eventUserText(o);
|
|
4520
|
+
if (userText == null) collectMinted(o);
|
|
4521
|
+
|
|
3298
4522
|
// Queue operations. The queue carries no correlation key (measured), so the
|
|
3299
4523
|
// ENQUEUE content is the decision — not a later contentless dequeue, which
|
|
3300
4524
|
// would need pairing we cannot do. Reading the enqueue also keeps the live
|
|
@@ -3344,25 +4568,29 @@ export function hasUserCloseSignal(transcriptPath) {
|
|
|
3344
4568
|
}
|
|
3345
4569
|
|
|
3346
4570
|
// Record AskUserQuestion tool_use ids (assistant record, always precedes its
|
|
3347
|
-
// answer in line order).
|
|
4571
|
+
// answer in line order). Bash ids ride along in the same scan, for the mint
|
|
4572
|
+
// correlation above.
|
|
3348
4573
|
const content = (o.message ?? o).content;
|
|
3349
4574
|
if (Array.isArray(content)) {
|
|
3350
4575
|
for (const b of content) {
|
|
3351
|
-
if (
|
|
3352
|
-
|
|
3353
|
-
|
|
3354
|
-
b.type === 'tool_use' &&
|
|
3355
|
-
b.name === 'AskUserQuestion' &&
|
|
3356
|
-
b.id
|
|
3357
|
-
)
|
|
3358
|
-
askIds.add(b.id);
|
|
4576
|
+
if (!b || typeof b !== 'object' || b.type !== 'tool_use' || !b.id) continue;
|
|
4577
|
+
if (b.name === 'AskUserQuestion') askIds.add(b.id);
|
|
4578
|
+
else if (b.name === 'Bash') bashIds.add(b.id);
|
|
3359
4579
|
}
|
|
3360
4580
|
}
|
|
3361
4581
|
|
|
3362
|
-
// Genuine user text → grant on a close phrase, invalidate on anything else
|
|
3363
|
-
|
|
3364
|
-
if (
|
|
3365
|
-
|
|
4582
|
+
// Genuine user text → grant on a close phrase, invalidate on anything else,
|
|
4583
|
+
// minus the one carve-out for what the product itself asked the user to send.
|
|
4584
|
+
if (userText != null && userText !== '') {
|
|
4585
|
+
const spend = approvalLine.exec(userText.trim());
|
|
4586
|
+
if (spend && mintedNonces.has(spend[1])) {
|
|
4587
|
+
// The approval line `proposal challenge` tells the user to type, bound to a
|
|
4588
|
+
// nonce this transcript minted. Neutral, never a grant: an untouched
|
|
4589
|
+
// `granted` that was false stays false. An unminted hex is ordinary text and
|
|
4590
|
+
// falls through to the invalidating branch below.
|
|
4591
|
+
continue;
|
|
4592
|
+
}
|
|
4593
|
+
granted = isClosePattern(userText);
|
|
3366
4594
|
continue;
|
|
3367
4595
|
}
|
|
3368
4596
|
|