hypomnema 1.6.2 → 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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.ko.md +39 -14
- package/README.md +39 -14
- package/commands/capture.md +8 -6
- package/commands/crystallize.md +39 -20
- package/docs/ARCHITECTURE.md +49 -14
- package/docs/CONTRIBUTING.md +31 -29
- package/hooks/base-store.mjs +265 -0
- package/hooks/hooks.json +7 -1
- package/hooks/hypo-auto-minimal-crystallize.mjs +63 -20
- package/hooks/hypo-auto-stage.mjs +30 -0
- package/hooks/hypo-cwd-change.mjs +31 -2
- package/hooks/hypo-file-watch.mjs +21 -2
- package/hooks/hypo-first-prompt.mjs +19 -3
- package/hooks/hypo-lookup.mjs +86 -29
- package/hooks/hypo-personal-check.mjs +24 -3
- package/hooks/hypo-session-record.mjs +2 -3
- package/hooks/hypo-session-start.mjs +89 -7
- package/hooks/hypo-shared.mjs +904 -128
- package/hooks/proposal-store.mjs +513 -0
- package/package.json +40 -14
- package/scripts/capture.mjs +556 -37
- package/scripts/crystallize.mjs +639 -108
- package/scripts/doctor.mjs +304 -9
- package/scripts/feedback-sync.mjs +515 -44
- package/scripts/graph.mjs +9 -2
- package/scripts/init.mjs +230 -34
- package/scripts/lib/extensions.mjs +656 -1
- package/scripts/lib/hypo-ignore.mjs +54 -6
- package/scripts/lib/hypo-root.mjs +56 -6
- package/scripts/lib/page-usage.mjs +15 -2
- package/scripts/lib/pkg-json.mjs +40 -0
- package/scripts/lib/plugin-detect.mjs +96 -6
- package/scripts/lib/wd-match.mjs +23 -5
- package/scripts/lib/wikilink.mjs +32 -6
- package/scripts/lint.mjs +20 -4
- package/scripts/proposal.mjs +1032 -0
- package/scripts/query.mjs +25 -4
- package/scripts/resume.mjs +34 -12
- package/scripts/stats.mjs +28 -8
- package/scripts/uninstall.mjs +141 -6
- package/scripts/upgrade.mjs +197 -15
- package/skills/crystallize/SKILL.md +44 -7
- package/skills/debate/SKILL.md +88 -0
- package/skills/debate/references/orchestration-patterns.md +83 -0
- package/templates/.hyposcanignore +10 -0
- package/templates/SCHEMA.md +12 -0
- package/templates/gitignore +5 -0
- package/templates/hypo-config.md +1 -1
- package/templates/hypo-guide.md +6 -0
- package/scripts/.gitkeep +0 -0
- package/scripts/check-bilingual.mjs +0 -153
- package/scripts/check-readme-version.mjs +0 -126
- package/scripts/check-tracker-ids.mjs +0 -426
- package/scripts/check-versions.mjs +0 -171
- package/scripts/install-git-hooks.mjs +0 -293
- package/scripts/lib/changelog-classify.mjs +0 -216
- package/scripts/lib/check-bilingual.mjs +0 -244
- package/scripts/lib/check-tracker-ids.mjs +0 -217
- package/scripts/lib/pre-commit-format.mjs +0 -251
- 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.
|
|
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
|
-
"
|
|
13
|
-
"
|
|
14
|
-
"
|
|
15
|
-
"
|
|
16
|
-
"
|
|
17
|
-
"
|
|
18
|
-
"
|
|
19
|
-
"
|
|
20
|
-
"
|
|
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/
|
|
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 &&
|
|
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"
|