hypomnema 1.8.2 → 1.9.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 (63) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +141 -51
  4. package/README.ko.md +18 -17
  5. package/README.md +16 -15
  6. package/commands/crystallize.md +89 -24
  7. package/commands/doctor.md +3 -1
  8. package/docs/ARCHITECTURE.md +45 -14
  9. package/docs/CONTRIBUTING.md +26 -8
  10. package/hooks/atomic-write.mjs +44 -0
  11. package/hooks/base-store.mjs +409 -90
  12. package/hooks/close-gate-store.mjs +131 -16
  13. package/hooks/close-journal.mjs +250 -0
  14. package/hooks/close-receipt.mjs +559 -0
  15. package/hooks/hooks.json +3 -38
  16. package/hooks/hypo-auto-commit.mjs +80 -10
  17. package/hooks/hypo-auto-minimal-crystallize.mjs +129 -25
  18. package/hooks/hypo-auto-stage.mjs +13 -1
  19. package/hooks/hypo-close-guard.mjs +2 -1
  20. package/hooks/hypo-cwd-change.mjs +45 -20
  21. package/hooks/hypo-file-watch.mjs +44 -6
  22. package/hooks/hypo-first-prompt.mjs +15 -9
  23. package/hooks/hypo-hot-rebuild.mjs +169 -112
  24. package/hooks/hypo-personal-check.mjs +9 -1
  25. package/hooks/hypo-session-record.mjs +1 -1
  26. package/hooks/hypo-session-start.mjs +566 -20
  27. package/hooks/hypo-shared.mjs +2250 -206
  28. package/hooks/hypo-stop.mjs +207 -0
  29. package/hooks/proposal-store.mjs +201 -35
  30. package/hooks/shared.json +15 -0
  31. package/hooks/version-check.mjs +9 -7
  32. package/package.json +3 -1
  33. package/scripts/crystallize.mjs +3 -4
  34. package/scripts/doctor.mjs +549 -123
  35. package/scripts/init.mjs +86 -109
  36. package/scripts/lib/core-hooks.mjs +48 -22
  37. package/scripts/lib/crystallize-close-apply.mjs +2397 -188
  38. package/scripts/lib/crystallize-close-check.mjs +43 -23
  39. package/scripts/lib/design-history-stale.mjs +36 -9
  40. package/scripts/lib/frontmatter.mjs +39 -0
  41. package/scripts/lib/git-hooks-dir.mjs +427 -52
  42. package/scripts/lib/hook-inventory.mjs +180 -0
  43. package/scripts/lib/page-usage.mjs +1 -1
  44. package/scripts/lib/pkg-provenance.mjs +18 -1
  45. package/scripts/lib/plugin-detect.mjs +43 -9
  46. package/scripts/lib/project-create.mjs +40 -44
  47. package/scripts/lib/schema-vocab.mjs +22 -11
  48. package/scripts/lib/template-schema-version.mjs +48 -0
  49. package/scripts/lib/time.mjs +46 -0
  50. package/scripts/lint.mjs +66 -18
  51. package/scripts/proposal.mjs +423 -18
  52. package/scripts/resume.mjs +3 -2
  53. package/scripts/session-audit.mjs +2 -1
  54. package/scripts/uninstall.mjs +144 -66
  55. package/scripts/upgrade.mjs +523 -234
  56. package/scripts/verify.mjs +2 -1
  57. package/scripts/weekly-report.mjs +2 -1
  58. package/templates/SCHEMA.md +27 -7
  59. package/templates/gitignore +11 -0
  60. package/templates/hypo-automation.md +10 -13
  61. package/templates/hypo-config.md +1 -1
  62. package/templates/hypo-guide.md +53 -25
  63. package/templates/hot.md +0 -24
@@ -0,0 +1,207 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * hypo-stop.mjs: the ONLY Stop hook registered in hooks/hooks.json.
4
+ *
5
+ * Claude Code runs every hook matched by an event in PARALLEL. Stop used to
6
+ * carry four separate registrations (hot-rebuild, session-record, auto-commit,
7
+ * auto-minimal-crystallize), so the order they ran in was whatever the four
8
+ * processes happened to reach first. hypo-hot-rebuild.mjs was written against
9
+ * an order it did not have: it writes root hot.md and log.md and only THEN
10
+ * claims those paths into the session's touched-paths set, and hypo-auto-commit
11
+ * could take the vault lock in between and commit without them. The rebuild
12
+ * still succeeded, so no health notice fired either, and the fresh bytes just
13
+ * sat uncommitted.
14
+ *
15
+ * One registration, four stages, run in order. The order is the contract now,
16
+ * and STAGES below is the only place it is written down.
17
+ *
18
+ * Stages are spawned as child processes, not imported as functions. Each of the
19
+ * four is a standalone script whose work happens at module top level: it reads
20
+ * the hook payload off its own stdin and prints one hook-protocol JSON object to
21
+ * its own stdout. Importing them would mean rewriting all four into exported
22
+ * functions (auto-minimal-crystallize alone is ~300 lines of top-level decision
23
+ * flow) and would put four sets of module state, and any process.exit or throw,
24
+ * inside one process. A child per stage costs four Node startups, roughly 50ms
25
+ * each against a 110s budget, and buys back the property serialization would
26
+ * otherwise cost: a stage that dies, hangs, or writes garbage cannot take the
27
+ * rest of the chain with it. That was free when the four ran in parallel, and
28
+ * this is what keeps it.
29
+ *
30
+ * Node built-ins only (architecture invariant: hooks never import from
31
+ * scripts/, and a hook's own directory is the only place it may reach).
32
+ */
33
+
34
+ import { spawnSync } from 'node:child_process';
35
+ import { dirname, join } from 'node:path';
36
+ import { fileURLToPath } from 'node:url';
37
+
38
+ const HOOKS_DIR = dirname(fileURLToPath(import.meta.url));
39
+
40
+ /**
41
+ * The Stop chain, in the order it must run.
42
+ *
43
+ * - hot-rebuild first: it writes root hot.md and log.md and claims them into
44
+ * the session's touched-paths set, which is the scope auto-commit later
45
+ * commits. Anything that runs before it contributes nothing to that scope.
46
+ * - session-record next: it only appends to a gitignored cache file, so its
47
+ * position is free; it sits here because it is cheap and cannot fail the
48
+ * ones after it.
49
+ * - auto-commit third: it has to see everything the two above claimed.
50
+ * - auto-minimal-crystallize last: it is the only stage that can emit
51
+ * `decision: "block"`, and its close gate reads the committed state the
52
+ * stage before it produced.
53
+ *
54
+ * `timeoutMs` is each stage's own share of the budget, enforced here rather
55
+ * than by the single hooks.json timeout. Serializing four hooks under one
56
+ * registration makes one registration's timeout the ceiling for all four, so
57
+ * without a per-stage kill a single hung stage would eat the whole budget and
58
+ * the stages after it would never run at all. The values are the per-hook
59
+ * timeouts these four carried in hooks.json before the merge, unchanged.
60
+ */
61
+ const STAGES = [
62
+ { file: 'hypo-hot-rebuild.mjs', timeoutMs: 30000 },
63
+ { file: 'hypo-session-record.mjs', timeoutMs: 10000 },
64
+ { file: 'hypo-auto-commit.mjs', timeoutMs: 60000 },
65
+ { file: 'hypo-auto-minimal-crystallize.mjs', timeoutMs: 10000 },
66
+ ];
67
+
68
+ /**
69
+ * A stage's hook-protocol object, or null when it printed nothing usable.
70
+ *
71
+ * The last non-empty line, not the whole stream: a stage is free to write
72
+ * diagnostics before its result (none do today), and the protocol object is
73
+ * always the last thing out. A parse failure is null, which the caller treats
74
+ * as "this stage said nothing", never as a reason to stop the chain.
75
+ *
76
+ * @param {string} stdout
77
+ * @returns {object|null}
78
+ */
79
+ function parseStageOutput(stdout) {
80
+ const lines = String(stdout || '')
81
+ .split('\n')
82
+ .map((l) => l.trim())
83
+ .filter(Boolean);
84
+ if (lines.length === 0) return null;
85
+ try {
86
+ const parsed = JSON.parse(lines[lines.length - 1]);
87
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
88
+ } catch {
89
+ return null;
90
+ }
91
+ }
92
+
93
+ /**
94
+ * How a stage failed to finish normally, in words a person can act on, or null
95
+ * when it exited 0. `error` is a spawn failure or the per-stage timeout (Node
96
+ * reports the kill as ETIMEDOUT); `signal` without a timeout is a death from
97
+ * outside, such as an OOM kill.
98
+ *
99
+ * @param {{file: string, timeoutMs: number}} stage
100
+ * @param {{error?: Error, status: number|null, signal: string|null}} res
101
+ * @returns {string|null}
102
+ */
103
+ function stageFailure(stage, res) {
104
+ if (res.error?.code === 'ETIMEDOUT') return `${stage.file} timed out after ${stage.timeoutMs}ms`;
105
+ if (res.error) return `${stage.file} failed to run: ${res.error.message ?? String(res.error)}`;
106
+ if (res.signal) return `${stage.file} was killed by ${res.signal}`;
107
+ if (res.status !== 0) return `${stage.file} exited ${res.status}`;
108
+ return null;
109
+ }
110
+
111
+ let raw = '';
112
+ try {
113
+ raw = await new Promise((resolve) => {
114
+ let d = '';
115
+ process.stdin.on('data', (c) => (d += c));
116
+ process.stdin.on('end', () => resolve(d));
117
+ });
118
+ } catch {
119
+ raw = '';
120
+ }
121
+
122
+ // The merged reply. The hook protocol gives one JSON object per registration,
123
+ // so four stage replies have to collapse into one:
124
+ // - systemMessage: every non-empty one, joined. Dropping any would silence a
125
+ // stage that has something to tell the user.
126
+ // - suppressOutput: true only while nothing wants to be seen. One stage
127
+ // asking for output, or contributing a systemMessage, turns it off.
128
+ // - continue / decision: the first stage that says "do not continue" ends the
129
+ // chain (see the loop).
130
+ const messages = [];
131
+ // Stages that did not finish normally. The chain still runs past them (see the
132
+ // fail-open note in the loop), but the reply must not read as a clean Stop:
133
+ // hot-rebuild dying after its rename and before it claims the path leaves a
134
+ // changed, uncommitted hot.md, and stderr alone is not something a person
135
+ // sees. So each one is also named in systemMessage, which turns suppressOutput
136
+ // off. Not a `decision: "block"`: a transient timeout would then keep the
137
+ // session from ending, over and over.
138
+ const failures = [];
139
+ let suppressOutput = true;
140
+ let blocked = null;
141
+ let halted = null;
142
+
143
+ for (const stage of STAGES) {
144
+ let res;
145
+ try {
146
+ res = spawnSync(process.execPath, [join(HOOKS_DIR, stage.file)], {
147
+ input: raw,
148
+ encoding: 'utf-8',
149
+ timeout: stage.timeoutMs,
150
+ });
151
+ } catch (err) {
152
+ process.stderr.write(`[hypo-stop] error: ${stage.file}: ${err?.message ?? String(err)}\n`);
153
+ failures.push(`${stage.file} failed to run: ${err?.message ?? String(err)}`);
154
+ continue;
155
+ }
156
+ if (res.stderr) process.stderr.write(res.stderr);
157
+ // Fail-open, deliberately, and this is the decision serialization would
158
+ // otherwise reverse. In parallel, a stage that crashed or hung took nothing
159
+ // with it; in a chain, "abort on a non-zero exit" would let a hot-rebuild
160
+ // crash cancel the commit that saves the session's work. So a stage that
161
+ // exits non-zero, gets killed at its timeout, or prints unparseable output is
162
+ // logged, reported in the reply, and skipped, and the chain moves on. The
163
+ // ONLY thing that ends the chain early is a stage explicitly asking for it
164
+ // through the protocol.
165
+ const failure = stageFailure(stage, res);
166
+ if (failure) {
167
+ process.stderr.write(`[hypo-stop] error: ${failure}\n`);
168
+ failures.push(failure);
169
+ }
170
+
171
+ const out = parseStageOutput(res.stdout);
172
+ if (!out) continue;
173
+
174
+ if (typeof out.systemMessage === 'string' && out.systemMessage.length > 0) {
175
+ messages.push(out.systemMessage);
176
+ }
177
+ if (out.suppressOutput === false) suppressOutput = false;
178
+
179
+ if (out.decision === 'block') {
180
+ // Only auto-minimal-crystallize emits this today, and it is last, so
181
+ // nothing is actually cut short. The rule still has to be written down: a
182
+ // block means Claude is not allowed to stop, and the protocol carries one
183
+ // reason, so a later stage's reply would either be discarded or have to be
184
+ // merged into a decision that is not a merge.
185
+ blocked = { reason: out.reason, stopReason: out.stopReason };
186
+ break;
187
+ }
188
+ if (out.continue === false) {
189
+ halted = { stopReason: out.stopReason };
190
+ break;
191
+ }
192
+ }
193
+
194
+ if (failures.length > 0) {
195
+ messages.push(
196
+ `[hypo-stop] Stop step(s) did not finish normally: ${failures.join('; ')}. ` +
197
+ 'Their work may be partly on disk and uncommitted; the steps after them still ran.',
198
+ );
199
+ }
200
+
201
+ const reply = blocked
202
+ ? { decision: 'block', reason: blocked.reason, stopReason: blocked.stopReason }
203
+ : { continue: !halted, suppressOutput: suppressOutput && messages.length === 0 };
204
+ if (!blocked && halted && halted.stopReason) reply.stopReason = halted.stopReason;
205
+ if (messages.length > 0) reply.systemMessage = messages.join('\n');
206
+
207
+ console.log(JSON.stringify(reply));
@@ -4,7 +4,7 @@
4
4
  // SessionStart-chain hook (T8's session-start surface) counts pending proposals,
5
5
  // so the store must sit where a hook can import it without reaching up into
6
6
  // scripts/. scripts/ (crystallize, the T7 CLI) already imports from hooks/, never
7
- // the reverse. Registered in hooks.json `shared` alongside base-store.
7
+ // the reverse. Registered in hooks/shared.json alongside base-store.
8
8
  //
9
9
  // When crystallize's close path finds that an OVERWRITE target drifted away from
10
10
  // the base this session observed at start, it withholds the bytes rather than
@@ -45,17 +45,9 @@
45
45
  // fatal) and atomic on the write side (tmp+rename), mirroring base-store.
46
46
 
47
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';
48
+ import { existsSync, readFileSync, readdirSync, unlinkSync, realpathSync, statSync } from 'node:fs';
58
49
  import { join, dirname, resolve } from 'node:path';
50
+ import { atomicWrite } from './atomic-write.mjs';
59
51
 
60
52
  /** `<hypoDir>/.cache/proposals/`. */
61
53
  export function proposalsDir(hypoDir) {
@@ -132,14 +124,6 @@ export function makeProposalId(createdAt, target) {
132
124
  return `${ts}-${slugTarget(target)}-${rand}`;
133
125
  }
134
126
 
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
127
  /**
144
128
  * Read+parse one artifact file by absolute path. Returns null when absent,
145
129
  * unreadable, or malformed (best-effort: a corrupt artifact must never crash a
@@ -157,11 +141,97 @@ function readArtifactFile(path) {
157
141
  }
158
142
  }
159
143
 
144
+ /**
145
+ * Scan an already-`readdirSync`'d proposals dir once, splitting `.json` entries
146
+ * into parsed artifacts and everything readArtifactFile would not hand back.
147
+ * "Everything else" covers three cases indistinguishably (unreadable bytes, a
148
+ * body that is not the artifact shape, an id that does not match its own
149
+ * filename), so a caller that needs to tell those apart already has the
150
+ * filename in hand to go look. Shared by listProposals and
151
+ * listProposalsChecked so a directory is walked and each file parsed exactly
152
+ * once per call, not twice.
153
+ */
154
+ // atomicWrite's OWN temp-file name (`<path>.<pid>.<rand>.tmp`; see
155
+ // atomic-write.mjs), so a `.json.<pid>.<rand>.tmp` sitting in this store is
156
+ // a completed artifact body whose rename never landed: the process that
157
+ // wrote it died in the gap between `writeFileSync` and `renameSync`. That
158
+ // gap holds real, already-serialized proposal bytes, not empty scratch
159
+ // space, so a scan that only ever looked for `.json` names read a vault
160
+ // with nothing BUT one of these as `parkedTotal: 0`, the same silent-zero
161
+ // this file's own listProposalsChecked doc comment already warns about, just
162
+ // reached through a different failure than a corrupt `.json`.
163
+ const PROPOSAL_TMP_RE = /\.json\.\d+\.[a-z0-9]+\.tmp$/;
164
+ // A live writer's tmp file looks identical to an orphan for the instant
165
+ // between its own writeFileSync and renameSync, so only a tmp file idle
166
+ // longer than this counts as unfinished. writeProposal's write is a single
167
+ // synchronous JSON.stringify + one syscall pair, so anything past a few
168
+ // seconds is already generous; a full minute gives slow disks and CI
169
+ // runners headroom without leaving a real crash unreported for long.
170
+ const PROPOSAL_TMP_GRACE_MS = 60_000;
171
+
172
+ /**
173
+ * The filename an orphan tmp's body would have landed at if its rename had
174
+ * finished. Shared with `checkProposals` (doctor.mjs) so its recovery hint
175
+ * names the same target this store would have produced, rather than a second
176
+ * copy of PROPOSAL_TMP_RE's suffix that could drift from this one.
177
+ */
178
+ export function orphanTmpTargetName(name) {
179
+ return name.replace(PROPOSAL_TMP_RE, '.json');
180
+ }
181
+
182
+ function scanProposalFiles(dir, names) {
183
+ const proposals = [];
184
+ const unreadable = [];
185
+ // Subset of `unreadable`: filenames that matched PROPOSAL_TMP_RE past the
186
+ // grace period. A caller keeping only `unreadable` cannot tell a corrupt
187
+ // artifact from a complete proposal body whose rename never landed, and the
188
+ // two have different fixes (discard vs. `mv` back to its `.json` name).
189
+ // Kept as an ADDITIONAL array rather than removed from `unreadable`, so the
190
+ // existing `unreadable.length` contract (what `--json` consumers already
191
+ // parse) does not shrink out from under them; a name can legitimately sit
192
+ // in both.
193
+ const orphanTmp = [];
194
+ for (const name of names) {
195
+ if (PROPOSAL_TMP_RE.test(name)) {
196
+ let stale = true;
197
+ try {
198
+ stale = Date.now() - statSync(join(dir, name)).mtimeMs > PROPOSAL_TMP_GRACE_MS;
199
+ } catch {
200
+ stale = false; // vanished mid-scan: the writer's own rename just landed, not an orphan
201
+ }
202
+ if (stale) {
203
+ unreadable.push(name);
204
+ orphanTmp.push(name);
205
+ }
206
+ continue;
207
+ }
208
+ if (!name.endsWith('.json')) continue;
209
+ const path = join(dir, name);
210
+ const parsed = readArtifactFile(path);
211
+ // A well-formed artifact's `id` field equals its filename stem and is itself
212
+ // id-safe. A mismatch means the body was hand-edited or corrupt; trusting its
213
+ // `id` would let a spoofed value drive supersede or T7 apply against the wrong
214
+ // file, so skip it (it stays on disk but is never acted on).
215
+ if (parsed && isValidProposalId(parsed.id) && parsed.id === name.slice(0, -'.json'.length)) {
216
+ proposals.push({ ...parsed, _path: path });
217
+ } else {
218
+ unreadable.push(name);
219
+ }
220
+ }
221
+ return { proposals, unreadable, orphanTmp };
222
+ }
223
+
160
224
  /**
161
225
  * List parked proposals, newest artifacts included, malformed ones skipped.
162
226
  * Each entry is the parsed body plus `_path` (absolute) so callers (the T7 CLI)
163
227
  * can act on the exact file without re-deriving the path.
164
228
  *
229
+ * A file this could not parse is silently absent from the result, same as
230
+ * before: every existing caller here acts on whatever IS readable and treats
231
+ * one bad artifact as no reason to fail a supersede scan or a T7 listing. A
232
+ * caller that needs to know a bad file exists at all, rather than just not
233
+ * see it, wants listProposalsChecked below, not this.
234
+ *
165
235
  * @returns {Array<object>} parsed artifacts; empty when the dir is absent/empty
166
236
  */
167
237
  export function listProposals(hypoDir) {
@@ -173,20 +243,64 @@ export function listProposals(hypoDir) {
173
243
  } catch {
174
244
  return [];
175
245
  }
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
- }
246
+ return scanProposalFiles(dir, names).proposals;
247
+ }
248
+
249
+ /**
250
+ * Same enumeration as listProposals, but keeps THREE states apart that all
251
+ * look like "nothing to review" from a bare count: an absent store (zero
252
+ * proposals were ever parked, `ok: true, proposals: [], unreadable: []`); a
253
+ * `readdirSync` failure on the store itself, permissions or a file sitting
254
+ * where the directory should be (the count is unmeasured, not zero, `ok:
255
+ * false`); and, the gap this once shared with listProposals, a `.json`
256
+ * candidate that sat right there in a perfectly listable directory but came
257
+ * back corrupt, permission-denied, or hand-edited into an unrecognizable
258
+ * shape. That third case used to fold into the same `[]` listProposals
259
+ * returns for "no proposals", so a vault with one chmod-000 artifact and zero
260
+ * readable ones reported `parkedTotal: 0` with no line of output anywhere
261
+ * naming the file: a silent success over a backlog that was never actually
262
+ * empty. `unreadable` is exactly those filenames (relative, `.json`
263
+ * included), so a caller can print "N candidate(s) unreadable, run doctor" even
264
+ * when the readable count is zero.
265
+ *
266
+ * listProposals above still gates on `existsSync` and stays fail-open on ANY
267
+ * stat or readdir failure: every one of its callers (the supersede scan in
268
+ * writeProposal, the T7 CLI's ordinary listing) wants "proposals I can act on
269
+ * right now", never a three-way judgment, and none of them has anywhere to
270
+ * put an `ok: false`. This function exists because `checkProposals` and the
271
+ * close report DO have somewhere to put it, and folding "could not tell" into
272
+ * "empty" is exactly the silent-zero this file's own docblock warns about.
273
+ * `existsSync` cannot make that call: it returns false for every stat
274
+ * failure, not just ENOENT, so it cannot tell "never created" apart from
275
+ * "could not be examined" (see the errno check below).
276
+ *
277
+ * @returns {{ok: boolean, proposals: object[], unreadable: string[], orphanTmp: string[]}}
278
+ * `orphanTmp` is the subset of `unreadable` whose rename never landed (see
279
+ * `orphanTmpTargetName`): a complete proposal body, recoverable with `mv`,
280
+ * not a corrupt file to discard.
281
+ */
282
+ export function listProposalsChecked(hypoDir) {
283
+ const dir = proposalsDir(hypoDir);
284
+ let names;
285
+ try {
286
+ names = readdirSync(dir);
287
+ } catch (e) {
288
+ // ENOENT is the only errno that means "no store was ever created" (zero
289
+ // proposals, ok: true). Every other errno (EACCES, ENOTDIR when a file
290
+ // sits where the directory should be, ELOOP, ...) means the store could
291
+ // not be examined at all, so the count is unmeasured, not zero.
292
+ //
293
+ // Cost accepted: a vault that has never parked anything now throws and
294
+ // catches one ENOENT on every listProposalsChecked call, instead of the
295
+ // existsSync precheck this replaced. In exchange, there is no longer a
296
+ // window between "does it exist" and "list it" where the directory could
297
+ // change underneath the two calls, and a real access failure can no
298
+ // longer masquerade as "never created".
299
+ if (e && e.code === 'ENOENT') return { ok: true, proposals: [], unreadable: [], orphanTmp: [] };
300
+ return { ok: false, proposals: [], unreadable: [], orphanTmp: [] };
188
301
  }
189
- return out;
302
+ const { proposals, unreadable, orphanTmp } = scanProposalFiles(dir, names);
303
+ return { ok: true, proposals, unreadable, orphanTmp };
190
304
  }
191
305
 
192
306
  /**
@@ -228,13 +342,37 @@ export function deleteProposal(hypoDir, id) {
228
342
  * @param {string} fields.sessionId owning session
229
343
  * @param {string} fields.device machine identifier (crystallize passes currentDevice())
230
344
  * @param {string} [fields.createdAt] ISO timestamp; defaults to now
345
+ * @param {string} [fields.parkReason] human-readable cause of the park (crystallize's
346
+ * conflictWhy(c)) — this is the artifact's own copy of the same string a `--json`
347
+ * close now also puts in that close's conflicts[].why, kept here because the T7
348
+ * CLI's list/apply/discard/challenge/resolve actions read only the artifact and
349
+ * never that close's own stdout. Additive: absent on artifacts written before this
350
+ * field existed, and every reader (this module's own readArtifactFile, and the T7
351
+ * CLI's actions above) treats an absent value as "no cause recorded", never as
352
+ * malformed — none of them requires this key to be present.
353
+ * @param {string[]} [fields.lostSections] the `##` headings a section-loss park
354
+ * dropped (crystallize-close-apply.mjs's sectionLossReason). Present only when
355
+ * `parkReason` names that cause; omitted for every other park reason, same as
356
+ * `diskSectionCount` below.
357
+ * @param {number} [fields.diskSectionCount] how many `##` headings disk had at
358
+ * park time, alongside `lostSections`.
231
359
  * @returns {{id: string, target: string, path: string, supersedeWarnings: string[]}}
232
360
  * `supersedeWarnings` is non-empty only when the new artifact WAS written but an
233
361
  * older same-target sibling could not be removed — a non-fatal condition (the
234
362
  * payload is parked), reported separately from a write failure (which throws).
235
363
  */
236
364
  export function writeProposal(hypoDir, fields) {
237
- const { target, baseHash, currentAtProposalHash, proposedContent, sessionId, device } = fields;
365
+ const {
366
+ target,
367
+ baseHash,
368
+ currentAtProposalHash,
369
+ proposedContent,
370
+ sessionId,
371
+ device,
372
+ parkReason,
373
+ lostSections,
374
+ diskSectionCount,
375
+ } = fields;
238
376
  const createdAt = fields.createdAt || new Date().toISOString();
239
377
 
240
378
  // Match on the parsed `target` field, never on the filename slug: two distinct
@@ -266,6 +404,16 @@ export function writeProposal(hypoDir, fields) {
266
404
  sessionId: sessionId != null ? String(sessionId) : null,
267
405
  device: device != null ? String(device) : null,
268
406
  createdAt,
407
+ // Additive fields (see this function's own doc comment): omitted rather
408
+ // than stored as `null` when the caller does not pass them, so an artifact
409
+ // written before this field existed and one written by a caller that
410
+ // genuinely has no cause to report look identical on disk — both simply
411
+ // lack the key, and every reader already treats an absent key as "not
412
+ // recorded", never as a malformed body (readArtifactFile only requires
413
+ // `id` and `target`; nothing here reads these three as mandatory).
414
+ ...(parkReason != null ? { parkReason: String(parkReason) } : {}),
415
+ ...(Array.isArray(lostSections) ? { lostSections } : {}),
416
+ ...(typeof diskSectionCount === 'number' ? { diskSectionCount } : {}),
269
417
  };
270
418
  // Write the new artifact DURABLY before removing the old one: a crash in the
271
419
  // gap leaves a stale sibling (superseded next close), never a lost payload.
@@ -412,9 +560,13 @@ function sessionChallengeFiles(hypoDir, sessionId) {
412
560
  * un-superseded old nonce would be a second, unreviewed key to the same write).
413
561
  *
414
562
  * @param {string} hypoDir
415
- * @param {{nonce: string, sessionId: string, mintedAt: string,
563
+ * @param {{nonce: string, sessionId: string, closeSessionId?: string, mintedAt: string,
416
564
  * items: Array<{id: string, target: string, proposedHash: string,
417
565
  * freshness: {state: 'hash'|'absent', hash: string|null}}>}} record
566
+ * `closeSessionId` is the session whose close this batch resumes, which the CALLER
567
+ * is running challenge FROM when the two differ (a later session finishing a park
568
+ * an earlier, possibly gone, session left behind). Optional and additive: a caller
569
+ * that omits it is read as "resumes its own session" (see readChallenge).
418
570
  * @returns {string|null} the artifact path, or null when the session id is unsafe
419
571
  */
420
572
  export function writeChallenge(hypoDir, record) {
@@ -475,7 +627,21 @@ export function readChallenge(hypoDir, sessionId) {
475
627
  if (!f || (f.state !== 'hash' && f.state !== 'absent')) return null;
476
628
  if (f.state === 'hash' && typeof f.hash !== 'string') return null;
477
629
  }
478
- return parsed;
630
+ // `closeSessionId` names the close this approval resumes, which can predate
631
+ // this field (see writeChallenge's doc comment). A record that carries it must
632
+ // carry a well-formed one: a hand-edited or malformed value here is a REMINT,
633
+ // the same polarity every other field in this record gets. But a record that
634
+ // never carried it (every challenge minted before this feature, or one minted
635
+ // without `--close-session-id`) is read as resuming its OWN session: the
636
+ // approving session and the parking session were always the same one before
637
+ // this existed, so treating "absent" as "same session" changes no existing
638
+ // behavior and needs no migration.
639
+ if (parsed.closeSessionId !== undefined) {
640
+ if (typeof parsed.closeSessionId !== 'string' || !isValidSessionId(parsed.closeSessionId)) {
641
+ return null;
642
+ }
643
+ }
644
+ return { ...parsed, closeSessionId: parsed.closeSessionId ?? sessionId };
479
645
  } catch {
480
646
  return null;
481
647
  }
@@ -0,0 +1,15 @@
1
+ [
2
+ "atomic-write.mjs",
3
+ "hypo-shared.mjs",
4
+ "version-check.mjs",
5
+ "version-check-fetch.mjs",
6
+ "base-store.mjs",
7
+ "proposal-store.mjs",
8
+ "close-gate-store.mjs",
9
+ "close-journal.mjs",
10
+ "hypo-hot-rebuild.mjs",
11
+ "hypo-session-record.mjs",
12
+ "hypo-auto-commit.mjs",
13
+ "hypo-auto-minimal-crystallize.mjs",
14
+ "close-receipt.mjs"
15
+ ]
@@ -18,7 +18,8 @@
18
18
  * than overwrites so it never erases the hook's `notifiedFor` marks.
19
19
  */
20
20
 
21
- import { readFileSync, writeFileSync, renameSync, mkdirSync, realpathSync, existsSync } from 'fs';
21
+ import { readFileSync, realpathSync, existsSync } from 'fs';
22
+ import { atomicWrite } from './atomic-write.mjs';
22
23
  import { dirname, join, delimiter } from 'path';
23
24
  import { homedir } from 'os';
24
25
 
@@ -165,7 +166,7 @@ export function selectPluginVersion(plugins) {
165
166
  *
166
167
  * Lives here because both hooks and scripts need it and the dependency only
167
168
  * runs one way (scripts import hooks, never the reverse). This file is already
168
- * in hooks.json's `shared` list, so it reaches every standalone install.
169
+ * listed in hooks/shared.json, so it reaches every standalone install.
169
170
  *
170
171
  * Use UPGRADE_APPLY_EITHER when the target channel is genuinely unknown, e.g.
171
172
  * an instruction about a DIFFERENT machine. */
@@ -246,12 +247,13 @@ export function readCache(path) {
246
247
  }
247
248
  }
248
249
 
249
- /** Atomic write: tmp file in the same dir, then rename (last-writer-wins). */
250
+ /** Atomic write: tmp file in the same dir, then rename (last-writer-wins).
251
+ * Delegates to the shared writer so a failure here cleans its temp file up.
252
+ * It did not before, and the callers below swallow errors, so every failure
253
+ * left a `<path>.<pid>.<rand>.tmp` nothing would ever pick up again. This is a
254
+ * per-session cache path, so those accumulated faster than anywhere else. */
250
255
  export function writeCacheAtomic(path, obj) {
251
- mkdirSync(dirname(path), { recursive: true });
252
- const tmp = `${path}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
253
- writeFileSync(tmp, JSON.stringify(obj, null, 2));
254
- renameSync(tmp, path);
256
+ atomicWrite(path, JSON.stringify(obj, null, 2));
255
257
  }
256
258
 
257
259
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hypomnema",
3
- "version": "1.8.2",
3
+ "version": "1.9.0",
4
4
  "description": "LLM-native personal wiki system for Claude Code",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -39,6 +39,7 @@
39
39
  "scripts/lib/feedback-scope.mjs",
40
40
  "scripts/lib/frontmatter.mjs",
41
41
  "scripts/lib/git-hooks-dir.mjs",
42
+ "scripts/lib/hook-inventory.mjs",
42
43
  "scripts/lib/hypo-ignore.mjs",
43
44
  "scripts/lib/hypo-root.mjs",
44
45
  "scripts/lib/page-usage.mjs",
@@ -50,6 +51,7 @@
50
51
  "scripts/lib/schema-vocab.mjs",
51
52
  "scripts/lib/slug-resolver.mjs",
52
53
  "scripts/lib/template-schema-version.mjs",
54
+ "scripts/lib/time.mjs",
53
55
  "scripts/lib/wd-match.mjs",
54
56
  "scripts/lib/wikilink.mjs",
55
57
  "commands/",
@@ -12,13 +12,13 @@
12
12
  * Options:
13
13
  * --hypo-dir=<path> Hypomnema root (default: resolved via HYPO_DIR / hypo-config.md / ~/hypomnema)
14
14
  * --min-group=<n> Min pages per tag group to report (default: 2)
15
- * --check-session-close Verify the strict session-close memory files — 5 mandatory + open-questions conditional
15
+ * --check-session-close Verify the strict session-close memory files: 4 mandatory + open-questions conditional
16
16
  * --project=<slug> Override the recency-inferred project on --check / --mark (single segment
17
17
  * [A-Za-z0-9._-]+, projects/<slug>/ must exist). On --check it NARROWS the
18
18
  * gate to that one project — a project-scoped diagnostic, NOT a global
19
19
  * compact-ready verdict. On --mark it is ATTRIBUTION only; the gate stays
20
20
  * global (the marker == compact-ready invariant). Ignored on --apply.
21
- * --apply-session-close Apply a JSON payload that updates the 5 mandatory memory files
21
+ * --apply-session-close Apply a JSON payload that updates the 4 mandatory memory files
22
22
  * (+ optional open-questions). Idempotent — re-running with the same
23
23
  * payload is a no-op. Always finishes with the strict gate check.
24
24
  *
@@ -40,7 +40,6 @@
40
40
  * "date": "YYYY-MM-DD", // optional — defaults to today (local)
41
41
  * "sessionState": { "content": "<full file>" }, // overwrite (idempotent: identical bytes → skip)
42
42
  * "projectHot": { "content": "<full file>" }, // overwrite
43
- * "rootHot": { "content": "<full file>" }, // overwrite
44
43
  * "sessionLog": { "entry": "## [date] ..." }, // append, skip if heading already present
45
44
  * "log": { "entry": "## [date] session | <project> ..." }, // OPTIONAL (B-1): omit it and apply derives the root log.md entry from this close's sessionLog heading; supply it only for a deliberately custom log line
46
45
  * "openQuestions":{ "content": "<full file>" } // optional overwrite
@@ -53,7 +52,7 @@
53
52
  * Lint gates:
54
53
  * • Preflight — runs `lint.mjs --json` BEFORE any payload byte is written.
55
54
  * Errors in files this payload will OVERWRITE (sessionState/projectHot/
56
- * rootHot/openQuestions) are filtered out — they're about to be replaced,
55
+ * openQuestions) are filtered out: they're about to be replaced,
57
56
  * and not filtering them dead-locks the documented "fix payload and retry"
58
57
  * recovery after a post-apply-lint failure (codex P2). Errors in any other
59
58
  * file → exit 1 with stage='preflight-lint', no apply occurs. PreCompact's