hypomnema 1.10.0 → 1.10.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -27,6 +27,8 @@ import { homedir } from 'os';
27
27
  import {
28
28
  HYPO_DIR,
29
29
  PKG_ROOT,
30
+ GATE_DEADLINE_MS,
31
+ remainingSpawnTimeoutMs,
30
32
  precompactGateStatus,
31
33
  readChecklist,
32
34
  isGateSkipped,
@@ -38,6 +40,24 @@ import {
38
40
  import { closeCheckpointState, isCloseComplete } from './close-receipt.mjs';
39
41
  import { closeGateStatus } from './close-gate-store.mjs';
40
42
 
43
+ // The least time the drift self-heal --write is started with (ms): a node start plus the
44
+ // write needs about this much, and a child given less only dies by its timeout.
45
+ const MIN_HEAL_WRITE_MS = 1500;
46
+
47
+ // The drift self-heal --write runs after the gate on a share of this hook's own limit, not on what
48
+ // the gate left of GATE_DEADLINE_MS (a value set by Stop's 10 second limit): hooks.json gives
49
+ // PreCompact 30 seconds, and a large vault whose gate takes most of 7000 ms would otherwise skip the
50
+ // self-heal on every /compact. The share is what is left of the limit after the gate and the
51
+ // report's own second, capped at HEAL_WRITE_CAP_MS. performance.now() counts from the process
52
+ // start, the closest this hook gets to when the harness started its limit.
53
+ const HOOK_LIMIT_MS = 30000;
54
+ const REPORT_SHARE_MS = 1000;
55
+ const HEAL_WRITE_CAP_MS = 20000;
56
+ const hookDeadline = { end: HOOK_LIMIT_MS - REPORT_SHARE_MS };
57
+
58
+ // A path for a command line a person copies: quoted when it holds anything a shell would split.
59
+ const shellArg = (s) => (/^[\w@%+=:,./-]+$/.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`);
60
+
41
61
  const WARNING_FILE = join(homedir(), '.claude', 'state', 'wiki-context-warning.json');
42
62
 
43
63
  let raw = '';
@@ -119,6 +139,10 @@ process.stdin.on('end', () => {
119
139
  const attributionScope = resolveGateProjectOverride(HYPO_DIR, { sessionCwd });
120
140
 
121
141
  let gate;
142
+ // One deadline for the gate and for the feedback notice check below, so a slow gate leaves it
143
+ // less time instead of stacking a second full budget on top of it. The drift self-heal --write
144
+ // has its own share of the hook limit (hookDeadline).
145
+ const deadline = { end: performance.now() + GATE_DEADLINE_MS };
122
146
  try {
123
147
  // The marker only scopes this gate (log-only mode, attribution) when
124
148
  // closeCheckpointState calls this session's close finished, the verdict
@@ -138,6 +162,7 @@ process.stdin.on('end', () => {
138
162
  : {}),
139
163
  ...(sessionCwd ? { sessionCwd } : {}),
140
164
  ...(attributionScope ? { attributionScope } : {}),
165
+ deadline,
141
166
  });
142
167
  } catch (err) {
143
168
  // Defense-in-depth: precompactGateStatus fails open per-check, but if it ever
@@ -166,24 +191,46 @@ process.stdin.on('end', () => {
166
191
  const healNotices = [];
167
192
  if (gate.ok && gate.driftTargets.length > 0) {
168
193
  const feedbackPath = PKG_ROOT ? join(PKG_ROOT, 'scripts', 'feedback-sync.mjs') : null;
169
- const w = feedbackPath
170
- ? spawnSync(
171
- process.execPath,
172
- [
173
- feedbackPath,
174
- '--write',
175
- '--no-input',
176
- '--json',
177
- // this hook shows the report's warnings below, so notices an older hook run
178
- // left pending can be reported (and cleared) here
179
- '--ack-notices',
180
- `--hypo-dir=${HYPO_DIR}`,
181
- `--claude-home=${join(homedir(), '.claude')}`,
182
- ],
183
- { encoding: 'utf-8', timeout: 30000 },
184
- )
185
- : { status: 1 };
186
- if (w.error || w.status === null || w.status !== 0) {
194
+ const writeLeft = Math.min(remainingSpawnTimeoutMs(hookDeadline), HEAL_WRITE_CAP_MS);
195
+ // The same write a person runs by hand when this one is cut: the arguments that decide what is
196
+ // written (the hook's own --json, --no-input and --ack-notices only shape its report).
197
+ const manualWrite = feedbackPath
198
+ ? `node ${shellArg(feedbackPath)} --write --hypo-dir=${shellArg(HYPO_DIR)} --claude-home=${shellArg(join(homedir(), '.claude'))}`
199
+ : 'hypomnema feedback-sync --write';
200
+ // Below the floor a node start plus a write cannot finish, so the child would only be
201
+ // killed by its timeout and the kill read as a failed sync.
202
+ const w =
203
+ writeLeft < MIN_HEAL_WRITE_MS
204
+ ? { skippedForTime: true }
205
+ : feedbackPath
206
+ ? spawnSync(
207
+ process.execPath,
208
+ [
209
+ feedbackPath,
210
+ '--write',
211
+ '--no-input',
212
+ '--json',
213
+ // this hook shows the report's warnings below, so notices an older hook run
214
+ // left pending can be reported (and cleared) here
215
+ '--ack-notices',
216
+ `--hypo-dir=${HYPO_DIR}`,
217
+ `--claude-home=${join(homedir(), '.claude')}`,
218
+ ],
219
+ { encoding: 'utf-8', timeout: writeLeft },
220
+ )
221
+ : { status: 1 };
222
+ if (w.skippedForTime) {
223
+ // No time left after the gate: not a failure of the sync, the next /compact repeats it.
224
+ healNotices.push(
225
+ `[WIKI CHECK] feedback projection drift (${gate.driftTargets.join(', ')}) was not re-synced: the gate left too little of the ${HOOK_LIMIT_MS} ms PreCompact hook limit to run the sync. The next /compact tries again, or run it now: ${manualWrite}`,
226
+ );
227
+ } else if (w.error?.code === 'ETIMEDOUT') {
228
+ // The write was started with the time that was left and ran out of it: the same
229
+ // time limit, not a sync that failed.
230
+ healNotices.push(
231
+ `[WIKI CHECK] feedback projection drift (${gate.driftTargets.join(', ')}) was not re-synced: the sync was still running when its ${writeLeft} ms share of the ${HOOK_LIMIT_MS} ms PreCompact hook limit ran out. The next /compact tries again, or run it now: ${manualWrite}`,
232
+ );
233
+ } else if (w.error || w.status === null || w.status !== 0) {
187
234
  gate.ok = false;
188
235
  gate.blockers.push({
189
236
  type: 'feedback',
@@ -223,40 +270,48 @@ process.stdin.on('end', () => {
223
270
  // notices and removes them on exit 0, so they reach a reader here. Only those notices are
224
271
  // shown: --check's other warnings (a skipped MEMORY projection) are not new information
225
272
  // on every compact. Fail open: any fault means no extra notice.
226
- try {
227
- const c = spawnSync(
228
- process.execPath,
229
- [
230
- join(PKG_ROOT, 'scripts', 'feedback-sync.mjs'),
231
- '--check',
232
- '--no-input',
233
- '--json',
234
- '--ack-notices',
235
- `--hypo-dir=${HYPO_DIR}`,
236
- `--claude-home=${join(homedir(), '.claude')}`,
237
- ],
238
- { encoding: 'utf-8', timeout: 30000 },
273
+ const checkLeft = remainingSpawnTimeoutMs(deadline);
274
+ if (checkLeft === 0) {
275
+ healNotices.push(
276
+ `[WIKI CHECK] feedback-sync notice check skipped: the gate used up its ${GATE_DEADLINE_MS} ms deadline. Pending notices stay for the next /compact.`,
239
277
  );
240
- if (!c.error && c.status === 0) {
241
- const report = JSON.parse(c.stdout || '');
242
- for (const m of Array.isArray(report.warnings) ? report.warnings : []) {
243
- if (typeof m === 'string' && /kept the hand-written line in /.test(m))
244
- healNotices.push(`[WIKI CHECK] feedback-sync notice: ${m}`);
278
+ } else {
279
+ try {
280
+ const c = spawnSync(
281
+ process.execPath,
282
+ [
283
+ join(PKG_ROOT, 'scripts', 'feedback-sync.mjs'),
284
+ '--check',
285
+ '--no-input',
286
+ '--json',
287
+ '--ack-notices',
288
+ `--hypo-dir=${HYPO_DIR}`,
289
+ `--claude-home=${join(homedir(), '.claude')}`,
290
+ ],
291
+ { encoding: 'utf-8', timeout: Math.min(30000, checkLeft) },
292
+ );
293
+ if (!c.error && c.status === 0) {
294
+ const report = JSON.parse(c.stdout || '');
295
+ for (const m of Array.isArray(report.warnings) ? report.warnings : []) {
296
+ if (typeof m === 'string' && /kept the hand-written line in /.test(m))
297
+ healNotices.push(`[WIKI CHECK] feedback-sync notice: ${m}`);
298
+ }
245
299
  }
300
+ } catch {
301
+ /* fail open */
246
302
  }
247
- } catch {
248
- /* fail open */
249
303
  }
250
304
  }
251
305
 
252
306
  // Non-blocking heads-up, one line per notice TYPE (session-close-scope-boundary
253
- // spec §4). gate.notices can carry any of seven types: git, git-sync,
307
+ // spec §4). gate.notices can carry these types: unresolved, foreign-entry,
308
+ // foreign-append, local-entry, touched-paths-unreadable, git-sync,
254
309
  // close-debt, close-cwd-unresolved, lint, design-history, feedback. Folding
255
310
  // them all into one "pre-existing lint issue(s)" sentence (the old
256
311
  // renderer) mislabeled git/git-sync/close-cwd-unresolved as lint debt and
257
312
  // dropped close-cwd-unresolved plus most feedback notices outright: visible
258
313
  // but lying about what kind of issue it was and what fixes it. A count that
259
- // can grow unbounded (foreign git paths, close-debt projects) is capped and
314
+ // can grow unbounded (unresolved paths, close-debt projects) is capped and
260
315
  // the rest folds into "+N more", mirroring the existing lint/design-history
261
316
  // fold (a vault-wide lint sweep once produced 125 lines here).
262
317
  const NOTICE_LIST_CAP = 5;
@@ -267,12 +322,34 @@ process.stdin.on('end', () => {
267
322
  const byType = (t) => gate.notices.filter((n) => n.type === t);
268
323
  const noticeLines = [];
269
324
 
270
- // git: a dirty file outside this session's scope (§2b's structural demotion,
271
- // or the trusted-transcript foreign-file demotion above it).
272
- const foreignGit = byType('git');
273
- if (foreignGit.length > 0) {
325
+ // unresolved: a dirty file this session has no record of writing, so it is
326
+ // not blocking and its owner is unknown (the gate's git axis, which is the
327
+ // same for every caller).
328
+ const unresolvedGit = byType('unresolved').filter((n) => n.file);
329
+ if (unresolvedGit.length > 0) {
330
+ noticeLines.push(
331
+ `[WIKI CHECK] ${unresolvedGit.length} uncommitted file(s) this session has no record of writing (not blocking): ${foldNames(unresolvedGit.map((n) => n.file))}.`,
332
+ );
333
+ }
334
+ // An enumeration the gate deadline cut has no file to name: its reason is the whole sentence.
335
+ for (const n of byType('unresolved').filter((x) => !x.file))
336
+ noticeLines.push(`[WIKI CHECK] ${n.reason}.`);
337
+ // foreign-entry, foreign-append, local-entry, touched-paths-unreadable: each
338
+ // carries its own full sentence in `reason` (what the file is and what to do
339
+ // or not do about it), so it is one line apiece. Past the cap the rest fold
340
+ // into a count with their names.
341
+ const reasonNotices = [
342
+ 'foreign-entry',
343
+ 'foreign-append',
344
+ 'local-entry',
345
+ 'touched-paths-unreadable',
346
+ ].flatMap(byType);
347
+ for (const n of reasonNotices.slice(0, NOTICE_LIST_CAP))
348
+ noticeLines.push(`[WIKI CHECK] ${n.reason}.`);
349
+ if (reasonNotices.length > NOTICE_LIST_CAP) {
350
+ const rest = reasonNotices.slice(NOTICE_LIST_CAP);
274
351
  noticeLines.push(
275
- `[WIKI CHECK] ${foreignGit.length} uncommitted file(s) outside this session's scope (not blocking): ${foldNames(foreignGit.map((n) => n.file || n.reason))}.`,
352
+ `[WIKI CHECK] +${rest.length} more ownership notice(s) (not blocking): ${foldNames(rest.map((n) => n.file || n.type))}.`,
276
353
  );
277
354
  }
278
355
  // git-sync / close-cwd-unresolved: single-fact notices, their own `reason`
@@ -359,13 +436,21 @@ process.stdin.on('end', () => {
359
436
  return;
360
437
  }
361
438
 
439
+ // What was skipped and why, from the gate's own `skippedWhy` (a missing tool, a time limit, an
440
+ // error). A check that ended by an error already has a `check-skipped` blocker whose reason
441
+ // says so, so it is not listed twice. The cause sits in one pair of parentheses with commas
442
+ // inside, never a second pair: after a comma it ran into the list's own commas and read as one
443
+ // more item ("reinstall the plugin, feedback-sync skipped, ...").
444
+ const skipLines = Object.entries({ lint: 'lint', feedback: 'feedback-sync' })
445
+ .filter(
446
+ ([c]) =>
447
+ gate.skipped[c] && !gate.blockers.some((b) => b.type === 'check-skipped' && b.check === c),
448
+ )
449
+ .map(([c, name]) => `${name} skipped (${gate.skippedWhy[c]})`);
450
+
362
451
  // ── Bypass 3: HYPO_SKIP_GATE ──
363
452
  if (isGateSkipped()) {
364
- const skipped = [
365
- ...gate.blockers.map((b) => b.reason),
366
- gate.skipped.lint ? 'lint skipped (hypo-pkg.json missing)' : '',
367
- gate.skipped.feedback ? 'feedback-sync skipped (hypo-pkg.json missing)' : '',
368
- ]
453
+ const skipped = [...gate.blockers.map((b) => b.reason), ...skipLines]
369
454
  .filter(Boolean)
370
455
  .join(', ');
371
456
  console.log(
@@ -385,10 +470,7 @@ process.stdin.on('end', () => {
385
470
  // gate.blockers already carry per-type reasons in the canonical order
386
471
  // (git, hot, close, lint, design-history, feedback) — same strings as before
387
472
  // Now sourced from the shared gate instead of inline checks.
388
- const reasons = [
389
- ...gate.blockers.map((b) => b.reason),
390
- gate.skipped.lint ? 'lint skipped (run `hypomnema init` to enable lint gate)' : '',
391
- ].filter(Boolean);
473
+ const reasons = [...gate.blockers.map((b) => b.reason), ...skipLines].filter(Boolean);
392
474
 
393
475
  const checklist = readChecklist(today);
394
476
  const checklistText =
@@ -413,7 +495,7 @@ process.stdin.on('end', () => {
413
495
  ` [ ] 13. git commit & push`,
414
496
  ` [ ] 14. verify: run /hypo:crystallize (--check-session-close --session-id=${sessionId ?? '<id>'});`,
415
497
  ` the close is recorded once it reports close_state: closed (or legacy-closed).`,
416
- ` The Stop hook still checks this session's own project folder and has the final say.`,
498
+ ` The Stop hook runs the same check on this session's write record and has the final say.`,
417
499
  ].join('\n');
418
500
 
419
501
  const closeIntentNote = hasCloseIntent
@@ -53,6 +53,7 @@ import {
53
53
  setAsideNotices,
54
54
  upstreamIsMigrated,
55
55
  vaultHasNoGit,
56
+ stampSessionRecord,
56
57
  } from './hypo-shared.mjs';
57
58
  import { buildInjection, entryVisible, isBaselineId, nextUpItems } from './session-entries.mjs';
58
59
  import {
@@ -660,12 +661,14 @@ function pendingProposalNotice() {
660
661
  }
661
662
  }
662
663
  // ── foreign-project uncommitted notice ──────────────────────────────────────
663
- // Same signal precompactGateStatus already computes for its own gate
664
- // (closeAccountableScope / sessionTouchTrusted, hypo-shared.mjs), surfaced
665
- // here instead for the AGENT reading additionalContext: a project this
666
- // session isn't scoped to may still have uncommitted changes sitting in the
667
- // shared vault, and without this line the agent has no way to tell those
668
- // apart from its own unfinished work. Cannot import hypo-shared.mjs's
664
+ // A path-prefix signal of the same kind the close gate used to compute for
665
+ // its own git check. The gate now judges that axis by the session's
666
+ // touched-paths record instead (hypo-shared.mjs), so this notice is no longer
667
+ // the same judgment as the gate's. It is surfaced here for the AGENT
668
+ // reading additionalContext: a project this session isn't scoped to may
669
+ // still have uncommitted changes sitting in the shared vault, and without
670
+ // this line the agent has no way to tell those apart from its own
671
+ // unfinished work. Cannot import hypo-shared.mjs's
669
672
  // `projectOfPath` / `gitDirtyFiles` here, not a technical constraint (both
670
673
  // are simply not exported), but an ownership decision: hypo-shared.mjs
671
674
  // belongs to a different lane, so this hook keeps a local, self-contained
@@ -1266,6 +1269,14 @@ process.stdin.on('end', () => {
1266
1269
  // `data.session_id` is used raw rather than the 'default' fallback above. A session with no id
1267
1270
  // has no base and closes down the legacy direct-write path.
1268
1271
  if (data.session_id) {
1272
+ // Before the base snapshot: a session whose base already exists began under an older
1273
+ // version and is not stamped (see stampSessionRecord). Only a session that starts here is
1274
+ // stamped: `startup`, or `clear` (a new session id). A `resume` or `compact` start, or a
1275
+ // payload with no `source`, may continue a session that ran on older hooks for a while, so it
1276
+ // gets no stamp; one stamped when it started keeps its stamp file.
1277
+ if (data.source === 'startup' || data.source === 'clear') {
1278
+ stampSessionRecord(HYPO_DIR, data.session_id);
1279
+ }
1269
1280
  snapshotBase(HYPO_DIR, data.session_id, overwriteTargets(null));
1270
1281
  // Bumps the observed generation on EVERY SessionStart (first run and resume/compact alike),
1271
1282
  // before the injections below record into it. A resume that ends up injecting nothing still