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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +31 -0
- package/README.ko.md +5 -5
- package/README.md +5 -5
- package/commands/crystallize.md +21 -15
- package/commands/upgrade.md +13 -2
- package/docs/ARCHITECTURE.md +127 -63
- package/docs/CONTRIBUTING.md +7 -11
- package/hooks/base-store.mjs +31 -17
- package/hooks/hypo-auto-minimal-crystallize.mjs +39 -6
- package/hooks/hypo-auto-stage.mjs +123 -63
- package/hooks/hypo-close-guard.mjs +3 -37
- package/hooks/hypo-compact-guard.mjs +9 -6
- package/hooks/hypo-file-watch.mjs +18 -10
- package/hooks/hypo-personal-check.mjs +137 -55
- package/hooks/hypo-session-start.mjs +17 -6
- package/hooks/hypo-shared.mjs +1507 -569
- package/hooks/hypo-stop.mjs +8 -0
- package/hooks/session-views.mjs +9 -3
- package/package.json +2 -1
- package/scripts/crystallize.mjs +1 -1
- package/scripts/doctor.mjs +160 -9
- package/scripts/init.mjs +60 -22
- package/scripts/lib/crystallize-close-apply.mjs +156 -22
- package/scripts/lib/crystallize-close-check.mjs +1 -1
- package/scripts/lib/extensions.mjs +2 -2
- package/scripts/lib/git-hooks-dir.mjs +228 -31
- package/scripts/lib/plugin-detect.mjs +57 -4
- package/scripts/lib/settings-write.mjs +254 -0
- package/scripts/lib/wikilink.mjs +10 -1
- package/scripts/uninstall.mjs +3 -2
- package/scripts/upgrade.mjs +94 -23
- package/templates/Home.md +1 -1
- package/templates/hypo-config.md +1 -1
- package/templates/hypo-guide.md +60 -22
- package/templates/session-state.md +0 -12
|
@@ -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
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
|
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 (
|
|
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
|
-
//
|
|
271
|
-
//
|
|
272
|
-
|
|
273
|
-
|
|
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]
|
|
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
|
|
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
|
-
//
|
|
664
|
-
//
|
|
665
|
-
//
|
|
666
|
-
//
|
|
667
|
-
//
|
|
668
|
-
//
|
|
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
|