hypomnema 1.6.1 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.ko.md +39 -14
  4. package/README.md +39 -14
  5. package/commands/capture.md +8 -6
  6. package/commands/crystallize.md +39 -20
  7. package/docs/ARCHITECTURE.md +49 -14
  8. package/docs/CONTRIBUTING.md +31 -29
  9. package/hooks/base-store.mjs +265 -0
  10. package/hooks/hooks.json +7 -1
  11. package/hooks/hypo-auto-minimal-crystallize.mjs +77 -31
  12. package/hooks/hypo-auto-stage.mjs +30 -0
  13. package/hooks/hypo-cwd-change.mjs +31 -2
  14. package/hooks/hypo-file-watch.mjs +21 -2
  15. package/hooks/hypo-first-prompt.mjs +19 -3
  16. package/hooks/hypo-lookup.mjs +86 -29
  17. package/hooks/hypo-personal-check.mjs +24 -3
  18. package/hooks/hypo-session-record.mjs +2 -3
  19. package/hooks/hypo-session-start.mjs +89 -7
  20. package/hooks/hypo-shared.mjs +944 -147
  21. package/hooks/proposal-store.mjs +513 -0
  22. package/package.json +40 -14
  23. package/scripts/capture.mjs +556 -37
  24. package/scripts/crystallize.mjs +639 -108
  25. package/scripts/doctor.mjs +304 -9
  26. package/scripts/feedback-sync.mjs +515 -44
  27. package/scripts/graph.mjs +9 -2
  28. package/scripts/init.mjs +230 -34
  29. package/scripts/lib/extensions.mjs +656 -1
  30. package/scripts/lib/hypo-ignore.mjs +54 -6
  31. package/scripts/lib/hypo-root.mjs +56 -6
  32. package/scripts/lib/page-usage.mjs +15 -2
  33. package/scripts/lib/pkg-json.mjs +40 -0
  34. package/scripts/lib/plugin-detect.mjs +96 -6
  35. package/scripts/lib/wd-match.mjs +23 -5
  36. package/scripts/lib/wikilink.mjs +32 -6
  37. package/scripts/lint.mjs +20 -4
  38. package/scripts/proposal.mjs +1032 -0
  39. package/scripts/query.mjs +25 -4
  40. package/scripts/resume.mjs +34 -12
  41. package/scripts/stats.mjs +28 -8
  42. package/scripts/uninstall.mjs +141 -6
  43. package/scripts/upgrade.mjs +197 -15
  44. package/skills/crystallize/SKILL.md +44 -7
  45. package/skills/debate/SKILL.md +88 -0
  46. package/skills/debate/references/orchestration-patterns.md +83 -0
  47. package/templates/.hyposcanignore +10 -0
  48. package/templates/SCHEMA.md +12 -0
  49. package/templates/gitignore +5 -0
  50. package/templates/hypo-config.md +1 -1
  51. package/templates/hypo-guide.md +6 -0
  52. package/scripts/.gitkeep +0 -0
  53. package/scripts/check-bilingual.mjs +0 -153
  54. package/scripts/check-readme-version.mjs +0 -126
  55. package/scripts/check-tracker-ids.mjs +0 -426
  56. package/scripts/check-versions.mjs +0 -171
  57. package/scripts/install-git-hooks.mjs +0 -293
  58. package/scripts/lib/changelog-classify.mjs +0 -216
  59. package/scripts/lib/check-bilingual.mjs +0 -244
  60. package/scripts/lib/check-tracker-ids.mjs +0 -217
  61. package/scripts/lib/pre-commit-format.mjs +0 -251
  62. package/scripts/pre-commit-format.mjs +0 -198
@@ -0,0 +1,513 @@
1
+ // proposal-store.mjs: parked overwrite-conflict artifacts (the write=proposal gate)
2
+ //
3
+ // Lives in hooks/ rather than scripts/lib/ for the same reason base-store does: a
4
+ // SessionStart-chain hook (T8's session-start surface) counts pending proposals,
5
+ // so the store must sit where a hook can import it without reaching up into
6
+ // scripts/. scripts/ (crystallize, the T7 CLI) already imports from hooks/, never
7
+ // the reverse. Registered in hooks.json `shared` alongside base-store.
8
+ //
9
+ // When crystallize's close path finds that an OVERWRITE target drifted away from
10
+ // the base this session observed at start, it withholds the bytes rather than
11
+ // clobber the other writer's edits (base-store's guard). Those withheld bytes have
12
+ // to go somewhere a human can review and re-apply them later, out of band from the
13
+ // session that produced them — that is this artifact:
14
+ // `<hypoDir>/.cache/proposals/<id>.json` (gitignored, never synced, same .cache/
15
+ // neighborhood as base-store's per-session snapshots).
16
+ //
17
+ // Two invariants shape the store:
18
+ //
19
+ // 1. Overwrite only. APPEND conflicts (session-log / log.md lock-timeouts) do
20
+ // NOT become artifacts. A lock-timeout is transient, so the next close
21
+ // self-heals by re-appending; and T7 applies a proposal by REPLACING the
22
+ // whole target, which for an append-only history file would drop every other
23
+ // entry. crystallize filters `kind: 'append'` out before it ever calls here.
24
+ //
25
+ // 2. One artifact per (target, SESSION). Each close re-derives the same drifted
26
+ // target, so without supersede a fresh random id would accumulate a stale
27
+ // artifact on every close and inflate the pending count. But supersede is
28
+ // scoped to the writing session: a LATER close only replaces that same
29
+ // session's own earlier attempt at the target. Two concurrent sessions that
30
+ // both withhold bytes for one target keep BOTH artifacts, because each holds
31
+ // a distinct payload and this artifact is its only durable copy (crystallize
32
+ // never puts withheld bytes on disk and drops them from its reported shape).
33
+ // Deleting across sessions would silently destroy the first session's work,
34
+ // which is exactly the clobber this gate exists to prevent. A session id we
35
+ // cannot match (null on either side) supersedes nothing: never delete a
36
+ // payload we cannot prove is our own earlier attempt.
37
+ // writeProposal writes the new artifact durably FIRST, then deletes the older
38
+ // same-session sibling — so a crash between the two leaves an extra artifact
39
+ // (harmless, superseded next time), never zero. A same-target close that is
40
+ // byte-identical reuses the existing id instead of writing at all (idempotent,
41
+ // and session-agnostic: identical bytes are one payload, so reusing another
42
+ // session's artifact drops nothing).
43
+ //
44
+ // Everything is best-effort on the read side (a malformed artifact is skipped, not
45
+ // fatal) and atomic on the write side (tmp+rename), mirroring base-store.
46
+
47
+ import { createHash } from 'node:crypto';
48
+ import {
49
+ existsSync,
50
+ mkdirSync,
51
+ readFileSync,
52
+ writeFileSync,
53
+ renameSync,
54
+ readdirSync,
55
+ unlinkSync,
56
+ realpathSync,
57
+ } from 'node:fs';
58
+ import { join, dirname, resolve } from 'node:path';
59
+
60
+ /** `<hypoDir>/.cache/proposals/`. */
61
+ export function proposalsDir(hypoDir) {
62
+ return join(hypoDir, '.cache', 'proposals');
63
+ }
64
+
65
+ /** `<hypoDir>/.cache/proposals/<id>.json`. */
66
+ export function proposalPath(hypoDir, id) {
67
+ return join(proposalsDir(hypoDir), `${id}.json`);
68
+ }
69
+
70
+ // A generated id is `<digits>-<slug>-<rand>`: filename-safe tokens joined by
71
+ // hyphens, never a path separator, `.`, or `..`. The T7 CLI takes an id straight
72
+ // off the command line, so an unvalidated id would let `../../hot` resolve to
73
+ // `<hypoDir>/hot.json` and drive readProposal/deleteProposal against a file
74
+ // outside the store. Reject anything that is not the generated shape.
75
+ const PROPOSAL_ID_RE = /^[A-Za-z0-9][A-Za-z0-9-]*$/;
76
+
77
+ /** True only for the filename-safe id shape makeProposalId emits. */
78
+ export function isValidProposalId(id) {
79
+ return typeof id === 'string' && PROPOSAL_ID_RE.test(id);
80
+ }
81
+
82
+ /**
83
+ * Resolve `<id>.json` and confirm it sits DIRECTLY inside the proposals dir.
84
+ * Returns the absolute path, or null when the id is malformed or the resolved
85
+ * path escapes the store — defense in depth behind isValidProposalId, so even a
86
+ * validator gap cannot read or unlink a file elsewhere in the vault.
87
+ */
88
+ function resolvedProposalPath(hypoDir, id) {
89
+ if (!isValidProposalId(id)) return null;
90
+ const p = resolve(proposalPath(hypoDir, id));
91
+ if (dirname(p) !== resolve(proposalsDir(hypoDir))) return null;
92
+ // A lexical resolve+dirname check cannot see a SYMLINKED store: if
93
+ // `.cache/proposals` (or a parent) is a symlink to outside the vault, the id is
94
+ // valid but the path still escapes. When the store exists, require its real
95
+ // (symlink-resolved) path to be the vault's own `.cache/proposals`. When it does
96
+ // not exist yet there is nothing to read or unlink, so the lexical guard stands.
97
+ try {
98
+ if (
99
+ realpathSync(proposalsDir(hypoDir)) !== join(realpathSync(hypoDir), '.cache', 'proposals')
100
+ ) {
101
+ return null;
102
+ }
103
+ } catch {
104
+ /* store or vault not present yet — nothing to escape to */
105
+ }
106
+ return p;
107
+ }
108
+
109
+ /** Slug a vault-relative path into a filename-safe token (no separators). */
110
+ function slugTarget(target) {
111
+ return (
112
+ String(target)
113
+ .replace(/[^A-Za-z0-9]+/g, '-')
114
+ .replace(/^-+|-+$/g, '')
115
+ .toLowerCase() || 'target'
116
+ );
117
+ }
118
+
119
+ /**
120
+ * Build an artifact id: `<compressed-createdAt>-<targetSlug>-<rand>`.
121
+ *
122
+ * createdAt is an ISO string stripped of the characters a filename cannot carry
123
+ * (`-`, `:`, `.`), so it stays human-sortable; targetSlug keeps the id legible in
124
+ * `ls`; rand disambiguates two closes that land in the same millisecond. The id is
125
+ * only an on-disk key — the artifact body carries the authoritative fields.
126
+ */
127
+ export function makeProposalId(createdAt, target) {
128
+ const ts = String(createdAt)
129
+ .replace(/[-:.]/g, '')
130
+ .replace(/[^A-Za-z0-9]/g, '');
131
+ const rand = Math.random().toString(36).slice(2, 8);
132
+ return `${ts}-${slugTarget(target)}-${rand}`;
133
+ }
134
+
135
+ /** Atomic overwrite via tmp+rename, mirroring base-store's atomicWrite. */
136
+ function atomicWrite(path, content) {
137
+ mkdirSync(dirname(path), { recursive: true });
138
+ const tmp = `${path}.${process.pid}.${Math.random().toString(36).slice(2, 10)}.tmp`;
139
+ writeFileSync(tmp, content);
140
+ renameSync(tmp, path);
141
+ }
142
+
143
+ /**
144
+ * Read+parse one artifact file by absolute path. Returns null when absent,
145
+ * unreadable, or malformed (best-effort: a corrupt artifact must never crash a
146
+ * listing or a supersede scan).
147
+ */
148
+ function readArtifactFile(path) {
149
+ if (!existsSync(path)) return null;
150
+ try {
151
+ const parsed = JSON.parse(readFileSync(path, 'utf-8'));
152
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
153
+ if (typeof parsed.id !== 'string' || typeof parsed.target !== 'string') return null;
154
+ return parsed;
155
+ } catch {
156
+ return null;
157
+ }
158
+ }
159
+
160
+ /**
161
+ * List parked proposals, newest artifacts included, malformed ones skipped.
162
+ * Each entry is the parsed body plus `_path` (absolute) so callers (the T7 CLI)
163
+ * can act on the exact file without re-deriving the path.
164
+ *
165
+ * @returns {Array<object>} parsed artifacts; empty when the dir is absent/empty
166
+ */
167
+ export function listProposals(hypoDir) {
168
+ const dir = proposalsDir(hypoDir);
169
+ if (!existsSync(dir)) return [];
170
+ let names;
171
+ try {
172
+ names = readdirSync(dir);
173
+ } catch {
174
+ return [];
175
+ }
176
+ const out = [];
177
+ for (const name of names) {
178
+ if (!name.endsWith('.json')) continue;
179
+ const path = join(dir, name);
180
+ const parsed = readArtifactFile(path);
181
+ // A well-formed artifact's `id` field equals its filename stem and is itself
182
+ // id-safe. A mismatch means the body was hand-edited or corrupt; trusting its
183
+ // `id` would let a spoofed value drive supersede or T7 apply against the wrong
184
+ // file, so skip it (it stays on disk but is never acted on).
185
+ if (parsed && isValidProposalId(parsed.id) && parsed.id === name.slice(0, -'.json'.length)) {
186
+ out.push({ ...parsed, _path: path });
187
+ }
188
+ }
189
+ return out;
190
+ }
191
+
192
+ /**
193
+ * Read one proposal by id. Returns null when the id is unsafe, or the file is
194
+ * absent, unreadable, or malformed.
195
+ */
196
+ export function readProposal(hypoDir, id) {
197
+ const path = resolvedProposalPath(hypoDir, id);
198
+ if (!path) return null; // reject unsafe id — never read outside the store
199
+ return readArtifactFile(path);
200
+ }
201
+
202
+ /**
203
+ * Delete one proposal by id. Returns true when the file is gone afterwards
204
+ * (whether this call removed it or it was already absent), false when the id is
205
+ * unsafe or the unlink itself failed for another reason.
206
+ */
207
+ export function deleteProposal(hypoDir, id) {
208
+ const path = resolvedProposalPath(hypoDir, id);
209
+ if (!path) return false; // reject unsafe id — never unlink outside the store
210
+ try {
211
+ unlinkSync(path);
212
+ return true;
213
+ } catch (e) {
214
+ if (e && e.code === 'ENOENT') return true; // already gone — the desired end state
215
+ return false;
216
+ }
217
+ }
218
+
219
+ /**
220
+ * Park a drifted overwrite target as a proposal artifact.
221
+ *
222
+ * @param {string} hypoDir
223
+ * @param {object} fields
224
+ * @param {string} fields.target vault-relative path the payload wanted to write
225
+ * @param {string|null} fields.baseHash hash this session observed at start (may be null)
226
+ * @param {string|null} fields.currentAtProposalHash disk hash at withhold time
227
+ * @param {string} fields.proposedContent the full page bytes this close withheld
228
+ * @param {string} fields.sessionId owning session
229
+ * @param {string} fields.device machine identifier (crystallize passes currentDevice())
230
+ * @param {string} [fields.createdAt] ISO timestamp; defaults to now
231
+ * @returns {{id: string, target: string, path: string, supersedeWarnings: string[]}}
232
+ * `supersedeWarnings` is non-empty only when the new artifact WAS written but an
233
+ * older same-target sibling could not be removed — a non-fatal condition (the
234
+ * payload is parked), reported separately from a write failure (which throws).
235
+ */
236
+ export function writeProposal(hypoDir, fields) {
237
+ const { target, baseHash, currentAtProposalHash, proposedContent, sessionId, device } = fields;
238
+ const createdAt = fields.createdAt || new Date().toISOString();
239
+
240
+ // Match on the parsed `target` field, never on the filename slug: two distinct
241
+ // rel paths can slug-collide, and a slug-prefix scan could delete an unrelated
242
+ // target's artifact. Reading the body is the only safe discriminator.
243
+ const sameTarget = listProposals(hypoDir).filter((p) => p.target === target);
244
+
245
+ // Idempotent reuse: a re-close that withholds the SAME bytes against the SAME
246
+ // base and disk state reuses the existing id rather than minting a new artifact,
247
+ // so an unchanged close does not churn the store or move the id T7 apply keys on.
248
+ const identical = sameTarget.find(
249
+ (p) =>
250
+ p.baseHash === (baseHash ?? null) &&
251
+ p.currentAtProposalHash === (currentAtProposalHash ?? null) &&
252
+ p.proposedContent === proposedContent,
253
+ );
254
+ if (identical) {
255
+ return { id: identical.id, target, path: identical._path, supersedeWarnings: [] };
256
+ }
257
+
258
+ const id = makeProposalId(createdAt, target);
259
+ const path = proposalPath(hypoDir, id);
260
+ const body = {
261
+ id,
262
+ target,
263
+ baseHash: baseHash ?? null,
264
+ currentAtProposalHash: currentAtProposalHash ?? null,
265
+ proposedContent,
266
+ sessionId: sessionId != null ? String(sessionId) : null,
267
+ device: device != null ? String(device) : null,
268
+ createdAt,
269
+ };
270
+ // Write the new artifact DURABLY before removing the old one: a crash in the
271
+ // gap leaves a stale sibling (superseded next close), never a lost payload.
272
+ atomicWrite(path, JSON.stringify(body, null, 2));
273
+
274
+ const supersedeWarnings = [];
275
+ const mine = body.sessionId;
276
+ for (const p of sameTarget) {
277
+ if (p.id === id) continue; // never delete what we just wrote
278
+ // Same target is NOT enough: a sibling written by a DIFFERENT session holds
279
+ // that session's only copy of its withheld bytes (invariant 2). Supersede
280
+ // only our own earlier attempt, and only when both ids are known.
281
+ //
282
+ // `typeof === 'string'` before comparing, never String(p.sessionId): the body
283
+ // is hand-editable and listProposals validates only `id`, so a field like
284
+ // `["s-A"]` would coerce to a matching primitive. writeProposal always stores
285
+ // a string, so a non-string id belongs to no session we can identify — and an
286
+ // unidentifiable owner is exactly the case that must not be deleted.
287
+ if (mine === null || typeof p.sessionId !== 'string' || p.sessionId !== mine) continue;
288
+ if (!deleteProposal(hypoDir, p.id)) {
289
+ supersedeWarnings.push(`could not supersede stale proposal ${p.id} for ${target}`);
290
+ }
291
+ }
292
+ return { id, target, path, supersedeWarnings };
293
+ }
294
+
295
+ /** sha256 of a UTF-8 string, hex — the same hashing base-store uses for targets. */
296
+ export function hashProposalContent(content) {
297
+ return createHash('sha256').update(content, 'utf-8').digest('hex');
298
+ }
299
+
300
+ // ── approval challenges ───────────────────────────────────────────────────────
301
+ //
302
+ // A challenge is the record a `proposal challenge` run leaves behind so a later
303
+ // `proposal resolve` can prove FOUR things about a batch the user approved in
304
+ // conversation: that the approval postdates the diff (the nonce did not exist
305
+ // before), that it is spent once (resolve deletes the record), that the bytes on
306
+ // disk are still the bytes whose diff was shown, and that the set being applied
307
+ // is the set that was approved.
308
+ //
309
+ // Challenges live in a SUBDIRECTORY, not beside the artifacts. listProposals
310
+ // readdir-scans `<store>/*.json` and would otherwise have to reason about which
311
+ // `.json` files are proposals and which are not; a directory entry never ends in
312
+ // `.json`, so the scan skips it structurally rather than by convention.
313
+ //
314
+ // The record binds `target` per item, not just the id and the content hash. The
315
+ // artifact body's `target` is UNTRUSTED and apply writes to whatever it says at
316
+ // apply time, so binding id+content alone would let the same id, carrying the same
317
+ // approved bytes, land on a DIFFERENT in-vault path than the one the user reviewed.
318
+
319
+ /** `<hypoDir>/.cache/proposals/challenges/`. */
320
+ export function challengesDir(hypoDir) {
321
+ return join(proposalsDir(hypoDir), 'challenges');
322
+ }
323
+
324
+ // A session id reaches this module straight from `--session-id`, so it is subject
325
+ // to the same treatment the proposal id gets: it becomes a FILENAME. Reject
326
+ // anything that is not the transcript-resolver's own id shape.
327
+ const SESSION_ID_RE = /^[A-Za-z0-9_-]+$/;
328
+
329
+ /** True only for a session id shape that is safe to use as a filename. */
330
+ export function isValidSessionId(sessionId) {
331
+ return typeof sessionId === 'string' && SESSION_ID_RE.test(sessionId);
332
+ }
333
+
334
+ // The nonce is the approval, so it is also the challenge's IDENTITY — and the record
335
+ // is stored under it: `challenges/<sessionId>.<nonce>.json`.
336
+ //
337
+ // A path keyed by the session alone (`<sessionId>.json`) cannot be claimed safely. To
338
+ // consume such a record a resolver unlinks the PATH, which claims whatever happens to
339
+ // be sitting there — not the record it actually read and verified. A `challenge` that
340
+ // remints mid-flight replaces that file, and a resolver still authorized by the OLD
341
+ // nonce would then unlink the NEW record and go on to write the old batch: one typed
342
+ // approval buys two write batches, and the new nonce is spent without its own approval.
343
+ // Naming the file after the nonce makes the unlink claim an identity instead of an
344
+ // address, so a resolver can only ever consume the exact approval it holds.
345
+ const NONCE_RE = /^[a-f0-9]{32,}$/;
346
+
347
+ /**
348
+ * The challenges dir, ABSOLUTE, or null if it is not really inside the store — the
349
+ * symlinked-store defense `resolvedProposalPath` applies, hoisted here so it runs BEFORE
350
+ * anything reads the directory rather than after. A scan that lists and parses files out
351
+ * of a redirected dir and only then rejects them has already read what it promised not to.
352
+ *
353
+ * Absolute is not cosmetic: `hypoDir` reaches this module straight from `--hypo-dir` and
354
+ * may be relative (`.`). Every challenge path must be produced the same way or the same
355
+ * file gets two names, and a record minted under one is invisible to the other.
356
+ */
357
+ function safeChallengesDir(hypoDir) {
358
+ const dir = resolve(challengesDir(hypoDir));
359
+ // Anchor to the VAULT, exactly as `resolvedProposalPath` does — not to the store, which
360
+ // is the thing that might be redirected. Comparing the real challenges dir against
361
+ // `realpath(proposalsDir) + '/challenges'` follows a symlinked `.cache/proposals` right
362
+ // out of the vault and then agrees with itself, so the check passes and the escape holds.
363
+ try {
364
+ if (realpathSync(dir) !== join(realpathSync(hypoDir), '.cache', 'proposals', 'challenges')) {
365
+ return null;
366
+ }
367
+ } catch {
368
+ /* challenges dir or store not present yet — nothing to read, unlink, or escape to */
369
+ }
370
+ return dir;
371
+ }
372
+
373
+ /**
374
+ * Resolve `challenges/<sessionId>.<nonce>.json` and confirm it sits DIRECTLY inside the
375
+ * challenges dir. Returns null (fail CLOSED) on a malformed id or nonce, or an escaping
376
+ * path.
377
+ */
378
+ function resolvedChallengePath(hypoDir, sessionId, nonce) {
379
+ if (!isValidSessionId(sessionId)) return null;
380
+ if (typeof nonce !== 'string' || !NONCE_RE.test(nonce)) return null;
381
+ const dir = safeChallengesDir(hypoDir);
382
+ if (!dir) return null;
383
+ const p = resolve(join(dir, `${sessionId}.${nonce}.json`));
384
+ if (dirname(p) !== dir) return null;
385
+ return p;
386
+ }
387
+
388
+ /**
389
+ * Every challenge file currently held by this session (there must be at most one), as
390
+ * absolute paths from the same resolution `resolvedChallengePath` uses.
391
+ *
392
+ * The session id cannot contain a dot (`isValidSessionId`), so matching on `<sessionId>.`
393
+ * cannot reach across sessions: `sess-1.` does not prefix `sess-10.<nonce>.json`.
394
+ */
395
+ function sessionChallengeFiles(hypoDir, sessionId) {
396
+ if (!isValidSessionId(sessionId)) return [];
397
+ const dir = safeChallengesDir(hypoDir);
398
+ if (!dir) return [];
399
+ try {
400
+ return readdirSync(dir)
401
+ .filter((f) => f.startsWith(`${sessionId}.`) && f.endsWith('.json'))
402
+ .map((f) => join(dir, f));
403
+ } catch {
404
+ return [];
405
+ }
406
+ }
407
+
408
+ /**
409
+ * Persist a challenge for one session, replacing any earlier one it had. A session
410
+ * holds at most one live challenge: re-running `challenge` re-reads disk and mints
411
+ * a fresh nonce, and the previous nonce must stop working the moment it does (an
412
+ * un-superseded old nonce would be a second, unreviewed key to the same write).
413
+ *
414
+ * @param {string} hypoDir
415
+ * @param {{nonce: string, sessionId: string, mintedAt: string,
416
+ * items: Array<{id: string, target: string, proposedHash: string,
417
+ * freshness: {state: 'hash'|'absent', hash: string|null}}>}} record
418
+ * @returns {string|null} the artifact path, or null when the session id is unsafe
419
+ */
420
+ export function writeChallenge(hypoDir, record) {
421
+ const path = resolvedChallengePath(hypoDir, record.sessionId, record.nonce);
422
+ if (!path) return null;
423
+ // Supersede BEFORE minting, not after: an old record that outlives the start of a remint
424
+ // can still be consumed by a resolver holding the old nonce, landing a batch the user is
425
+ // at that very moment being asked to re-approve. Removing it first makes any such consume
426
+ // hit ENOENT and refuse. (The window is not zero — a resolver can still consume the old
427
+ // record in the instant between this scan and this unlink — but that batch is the one the
428
+ // old nonce legitimately bought, and its write revalidates the target before touching it.)
429
+ //
430
+ // A record that will NOT go is FATAL to the mint, not a warning. Leaving it behind puts
431
+ // two files under one session, which `readChallenge` reads as no approval at all — so
432
+ // announcing a fresh nonce anyway would hand the user a nonce that can never be spent,
433
+ // and every later remint would strand the session deeper.
434
+ for (const old of sessionChallengeFiles(hypoDir, record.sessionId)) {
435
+ if (old === path) continue; // reminting onto the same nonce: atomicWrite replaces it
436
+ try {
437
+ unlinkSync(old);
438
+ } catch (e) {
439
+ if (!e || e.code !== 'ENOENT') return null; // already gone is fine; anything else is not
440
+ }
441
+ }
442
+ atomicWrite(path, `${JSON.stringify(record, null, 2)}\n`);
443
+ return path;
444
+ }
445
+
446
+ /**
447
+ * Read a session's challenge. Returns null when the id is unsafe, or the record is
448
+ * absent, unreadable, or malformed — all of which the caller must treat as "no
449
+ * approval exists", never as "approval not required". A corrupt record is a REMINT,
450
+ * not a bypass.
451
+ */
452
+ export function readChallenge(hypoDir, sessionId) {
453
+ // A session holds at most one live challenge. Two would mean a remint raced its own
454
+ // supersession, and there is no way to tell which nonce the user was shown — so it is
455
+ // read as NO approval (a remint), never as "pick one".
456
+ const files = sessionChallengeFiles(hypoDir, sessionId);
457
+ if (files.length !== 1) return null;
458
+ const path = files[0];
459
+ if (!existsSync(path)) return null;
460
+ try {
461
+ const parsed = JSON.parse(readFileSync(path, 'utf-8'));
462
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
463
+ if (typeof parsed.nonce !== 'string' || !parsed.nonce) return null;
464
+ if (parsed.sessionId !== sessionId) return null; // record must own itself
465
+ // The record must live under its OWN nonce, or the filename is not the identity the
466
+ // consume claims. A hand-edited body that swaps the nonce is a remint, not a bypass.
467
+ if (resolvedChallengePath(hypoDir, sessionId, parsed.nonce) !== path) return null;
468
+ if (!Array.isArray(parsed.items) || parsed.items.length === 0) return null;
469
+ for (const it of parsed.items) {
470
+ if (!it || typeof it !== 'object') return null;
471
+ if (!isValidProposalId(it.id)) return null;
472
+ if (typeof it.target !== 'string' || !it.target) return null;
473
+ if (typeof it.proposedHash !== 'string' || !it.proposedHash) return null;
474
+ const f = it.freshness;
475
+ if (!f || (f.state !== 'hash' && f.state !== 'absent')) return null;
476
+ if (f.state === 'hash' && typeof f.hash !== 'string') return null;
477
+ }
478
+ return parsed;
479
+ } catch {
480
+ return null;
481
+ }
482
+ }
483
+
484
+ /**
485
+ * Spend the challenge for THIS EXACT NONCE, and report whether THIS CALL is the one
486
+ * that spent it.
487
+ *
488
+ * Both halves of that sentence are the security property.
489
+ *
490
+ * "This call": `unlink` is atomic, so when two resolvers race the same nonce exactly one
491
+ * wins and the loser gets ENOENT. Reading ENOENT as success would tell BOTH they had
492
+ * consumed the approval, and both would write — one typed approval, two write batches.
493
+ * The unlink IS the mutual exclusion; the return value is how the winner learns it won.
494
+ *
495
+ * "This exact nonce": the caller passes the nonce it actually read and verified, and the
496
+ * record lives under that nonce's filename. So a resolver cannot consume a record it was
497
+ * never authorized against — including a FRESHER one that a concurrent `challenge` just
498
+ * minted in its place.
499
+ *
500
+ * False means "do not proceed", whether the record was already gone (someone else took
501
+ * it, or a remint superseded it) or the unlink failed outright (it is still spendable).
502
+ * The caller must write nothing in either case.
503
+ */
504
+ export function consumeChallenge(hypoDir, sessionId, nonce) {
505
+ const path = resolvedChallengePath(hypoDir, sessionId, nonce);
506
+ if (!path) return false;
507
+ try {
508
+ unlinkSync(path);
509
+ return true;
510
+ } catch {
511
+ return false;
512
+ }
513
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hypomnema",
3
- "version": "1.6.1",
3
+ "version": "1.7.0",
4
4
  "description": "LLM-native personal wiki system for Claude Code",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -8,16 +8,41 @@
8
8
  "hypomnema": "scripts/init.mjs"
9
9
  },
10
10
  "files": [
11
- "scripts/",
12
- "!scripts/collect-changelog.mjs",
13
- "!scripts/lib/collect-changelog.mjs",
14
- "!scripts/bump-version.mjs",
15
- "!scripts/smoke-pack.mjs",
16
- "!scripts/smoke-plugin.mjs",
17
- "!scripts/fix-status-verify.mjs",
18
- "!scripts/lib/fix-status-verify.mjs",
19
- "!scripts/lib/fix-manifest.mjs",
20
- "!scripts/lib/adr-corpus.mjs",
11
+ "scripts/capture.mjs",
12
+ "scripts/crystallize.mjs",
13
+ "scripts/doctor.mjs",
14
+ "scripts/feedback-sync.mjs",
15
+ "scripts/feedback.mjs",
16
+ "scripts/graph.mjs",
17
+ "scripts/ingest.mjs",
18
+ "scripts/init.mjs",
19
+ "scripts/lint.mjs",
20
+ "scripts/proposal.mjs",
21
+ "scripts/query.mjs",
22
+ "scripts/rename.mjs",
23
+ "scripts/resume.mjs",
24
+ "scripts/session-audit.mjs",
25
+ "scripts/stats.mjs",
26
+ "scripts/uninstall.mjs",
27
+ "scripts/upgrade.mjs",
28
+ "scripts/verify.mjs",
29
+ "scripts/weekly-report.mjs",
30
+ "scripts/lib/core-hooks.mjs",
31
+ "scripts/lib/design-history-stale.mjs",
32
+ "scripts/lib/extensions.mjs",
33
+ "scripts/lib/failure-type.mjs",
34
+ "scripts/lib/feedback-scope.mjs",
35
+ "scripts/lib/frontmatter.mjs",
36
+ "scripts/lib/hypo-ignore.mjs",
37
+ "scripts/lib/hypo-root.mjs",
38
+ "scripts/lib/page-usage.mjs",
39
+ "scripts/lib/pkg-json.mjs",
40
+ "scripts/lib/plugin-detect.mjs",
41
+ "scripts/lib/project-create.mjs",
42
+ "scripts/lib/schema-vocab.mjs",
43
+ "scripts/lib/template-schema-version.mjs",
44
+ "scripts/lib/wd-match.mjs",
45
+ "scripts/lib/wikilink.mjs",
21
46
  "commands/",
22
47
  "hooks/",
23
48
  "skills/",
@@ -46,7 +71,8 @@
46
71
  "node": ">=18"
47
72
  },
48
73
  "scripts": {
49
- "test": "node tests/runner.mjs",
74
+ "test": "node tests/parallel.mjs",
75
+ "test:serial": "node tests/runner.mjs",
50
76
  "lint": "node scripts/lint.mjs",
51
77
  "fix:verify": "node scripts/fix-status-verify.mjs",
52
78
  "graph": "node scripts/graph.mjs",
@@ -54,12 +80,12 @@
54
80
  "check:versions": "node scripts/check-versions.mjs",
55
81
  "smoke:plugin": "node scripts/smoke-plugin.mjs",
56
82
  "check:bilingual": "node scripts/check-bilingual.mjs --changelog",
57
- "check:readme": "node scripts/check-readme-version.mjs",
58
83
  "check:tracker-ids": "node scripts/check-tracker-ids.mjs --all",
84
+ "check:pack-surface": "node scripts/check-pack-surface.mjs",
59
85
  "format": "prettier --write .",
60
86
  "format:check": "prettier --check .",
61
87
  "prepare": "node scripts/install-git-hooks.mjs",
62
- "prepublishOnly": "npm test && npm run lint && npm run check:versions && npm run smoke:plugin && npm run smoke-pack && npm run check:bilingual && npm run check:readme && npm run check:tracker-ids"
88
+ "prepublishOnly": "npm test && npm run lint && npm run check:versions && npm run smoke:plugin && node scripts/check-pack-surface.mjs && npm run smoke-pack && npm run check:bilingual && npm run check:tracker-ids"
63
89
  },
64
90
  "devDependencies": {
65
91
  "prettier": "^3.8.3"