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.
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 +63 -20
  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 +904 -128
  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,1032 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * proposal.mjs: `hypomnema proposal list|apply|discard|challenge|resolve`, the
4
+ * human-in-the-loop gate for parked overwrite-conflict artifacts (the write=proposal
5
+ * store).
6
+ *
7
+ * When crystallize's close path finds an OVERWRITE target drifted from the base
8
+ * this session observed, it withholds the bytes and parks them under
9
+ * `.cache/proposals/<id>.json` rather than clobber the other writer. This CLI is
10
+ * the only sanctioned way those bytes ever reach the target again, and no path to a
11
+ * write skips a human approval: there is no confirmation-bypass flag, no
12
+ * environment-variable override, and no background auto-apply. An apply is a
13
+ * WHOLE-FILE replacement, so the human reviews a fresh current-vs-proposed diff
14
+ * (that review IS the merge) before approving.
15
+ *
16
+ * The approval reaches us over TWO channels, and both are a human:
17
+ *
18
+ * • `apply <id>` — a person at a shell types the confirm phrase on a TTY.
19
+ * • `challenge` → the user types `apply-proposals <nonce>` in the conversation →
20
+ * `resolve` — the approval is verified in the session TRANSCRIPT.
21
+ *
22
+ * The second channel exists because an AGENT has no TTY, so a drifted close used to
23
+ * dead-end: the only way to finish was to bypass this store entirely with a direct
24
+ * write, which taught the model that the gate is optional. The transcript channel
25
+ * does not weaken the gate, it RELOCATES it: a hook cannot forge a user turn, and
26
+ * the model's own words are role:assistant and are never counted (see
27
+ * hasTypedUserApproval). A click is refused on purpose — the model authors the
28
+ * option labels, so a click proves a click, not approval of this phrase.
29
+ *
30
+ * Every byte that reaches a target goes through writeApprovedProposal(), which does
31
+ * NOT decide authorization; its two callers do, and they pass the outcome in. That
32
+ * is what keeps the two channels from drifting apart.
33
+ *
34
+ * The decision helpers (planApplyAction, classifyFreshness, renderDiff are pure;
35
+ * resolveTargetPath touches the filesystem to resolve symlinks) and the
36
+ * result-returning actors are exported so the runner can drive them in-process with
37
+ * injected TTY / prompt / clock seams. Injecting those seams is a TEST convention,
38
+ * not a supported way to apply unattended: the shipped CLI path carries no bypass,
39
+ * and a regression test pins that no hook reaches this module. main() sits behind an
40
+ * isMain() guard so a static import never runs the CLI (feedback-sync precedent).
41
+ */
42
+
43
+ import {
44
+ existsSync,
45
+ readFileSync,
46
+ writeFileSync,
47
+ renameSync,
48
+ unlinkSync,
49
+ mkdirSync,
50
+ appendFileSync,
51
+ lstatSync,
52
+ realpathSync,
53
+ } from 'node:fs';
54
+ import { join, dirname, resolve, isAbsolute, sep } from 'node:path';
55
+ import { randomBytes } from 'node:crypto';
56
+ import { pathToFileURL } from 'node:url';
57
+ import { resolveHypoRoot, expandHome } from './lib/hypo-root.mjs';
58
+ import {
59
+ listProposals,
60
+ readProposal,
61
+ deleteProposal,
62
+ isValidProposalId,
63
+ isValidSessionId,
64
+ hashProposalContent,
65
+ proposalsDir,
66
+ writeChallenge,
67
+ readChallenge,
68
+ consumeChallenge,
69
+ } from '../hooks/proposal-store.mjs';
70
+ import {
71
+ APPROVAL_PHRASE,
72
+ hasTypedUserApproval,
73
+ resolveTranscriptBySessionId,
74
+ } from '../hooks/hypo-shared.mjs';
75
+
76
+ // ── target-path hardening ─────────────────────────────────────────────────────
77
+
78
+ /**
79
+ * Resolve an artifact's `target` (vault-relative) to a safe absolute path inside
80
+ * the vault, or null when it is unsafe.
81
+ *
82
+ * The `target` field is UNTRUSTED: `.cache/proposals/<id>.json` is plaintext and
83
+ * hand-editable, and the store validates only the id, never the body's target. A
84
+ * body carrying `target: "../../.zshrc"` would drive a whole-file replacement
85
+ * outside the vault. This mirrors, on the target axis, the id hardening the store
86
+ * applies on the filename axis. Every rejection fails CLOSED (returns null).
87
+ *
88
+ * Callers must re-run this immediately before writing, not only before showing the
89
+ * diff: nothing is locked while the human reads, so an ancestor can become a
90
+ * symlink out of the vault between those two moments.
91
+ */
92
+ export function resolveTargetPath(hypoDir, target) {
93
+ if (typeof target !== 'string' || target === '' || isAbsolute(target)) return null;
94
+ // A `..` segment can walk out even when the lexical resolve below lands back
95
+ // inside, so reject it outright.
96
+ if (target.split(/[/\\]/).includes('..')) return null;
97
+
98
+ const root = resolve(hypoDir);
99
+ const full = resolve(join(hypoDir, target));
100
+ // Lexical containment: the resolved path is the vault root or sits under it.
101
+ if (full !== root && !full.startsWith(root + sep)) return null;
102
+
103
+ // The store never writes to itself. A target pointing back into
104
+ // `.cache/proposals/` would have apply write the withheld bytes over a SIBLING
105
+ // artifact (destroying its payload), or over this very artifact and then unlink
106
+ // it, or over the audit log. Each of those exits 0 while losing the only
107
+ // off-disk copy of the bytes. This is an invariant of the store, not an
108
+ // enumeration of crystallize's targets, so it couples to nothing.
109
+ const store = resolve(proposalsDir(hypoDir));
110
+ if (full === store || full.startsWith(store + sep)) return null;
111
+
112
+ // Symlink containment: a lexical check cannot see a parent dir that is a symlink
113
+ // pointing OUT of the vault (a rename target would then land outside). Require
114
+ // the nearest EXISTING ancestor's real (symlink-resolved) path to stay within
115
+ // the vault's own real path. The nearest ancestor is used because the target
116
+ // file itself is usually absent (a fresh create).
117
+ let ancestor = full;
118
+ while (!existsSync(ancestor)) {
119
+ const parent = dirname(ancestor);
120
+ if (parent === ancestor) break;
121
+ ancestor = parent;
122
+ }
123
+ try {
124
+ const realAncestor = realpathSync(ancestor);
125
+ const realRoot = realpathSync(hypoDir);
126
+ if (realAncestor !== realRoot && !realAncestor.startsWith(realRoot + sep)) return null;
127
+
128
+ // The lexical store guard above compares lexical paths, so an in-vault symlink
129
+ // alias (`alias -> .cache/proposals`, target `alias/<id>.json`) slips past it:
130
+ // `alias/...` is not lexically under the store, yet the real write lands inside
131
+ // it and would destroy an artifact. Re-run the store check on the REAL ancestor.
132
+ // This is a static footgun (a pre-planted alias), not the unbounded write-time
133
+ // race; closing it costs one comparison. If the store does not exist yet there
134
+ // is nothing to alias into.
135
+ try {
136
+ const realStore = realpathSync(store);
137
+ if (realAncestor === realStore || realAncestor.startsWith(realStore + sep)) return null;
138
+ } catch {
139
+ /* store absent: no alias target exists */
140
+ }
141
+ } catch {
142
+ return null;
143
+ }
144
+
145
+ // The target itself being a symlink is fail-closed: readTarget follows the link
146
+ // to build the diff the human reviews, but atomicWrite is tmp+rename and
147
+ // REPLACES the link, so the bytes shown and the bytes written would diverge. An
148
+ // absent target is the normal create case and is not a symlink.
149
+ try {
150
+ if (lstatSync(full).isSymbolicLink()) return null;
151
+ } catch {
152
+ /* absent target: nothing to be a symlink, proceed */
153
+ }
154
+ return full;
155
+ }
156
+
157
+ // ── atomic write (exclusive tmp) ──────────────────────────────────────────────
158
+
159
+ /**
160
+ * Atomic overwrite via tmp+rename, with the tmp path created EXCLUSIVELY (`wx`).
161
+ * Exclusive create means a pre-planted tmp symlink cannot be followed out of the
162
+ * vault. A random suffix keeps two applies from colliding on the tmp slot; a
163
+ * failed rename cleans up the tmp it created.
164
+ *
165
+ * The parent directory is NOT created here. The caller creates it and then
166
+ * re-validates containment, because `mkdirSync(recursive)` happily walks a symlink
167
+ * that appeared after the last check.
168
+ */
169
+ function atomicWrite(path, content) {
170
+ const tmp = `${path}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`;
171
+ writeFileSync(tmp, content, { flag: 'wx' });
172
+ try {
173
+ renameSync(tmp, path);
174
+ } catch (e) {
175
+ try {
176
+ unlinkSync(tmp);
177
+ } catch {
178
+ /* best-effort cleanup */
179
+ }
180
+ throw e;
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Read a target's current bytes, distinguishing absent (null) from unreadable
186
+ * (undefined) so classifyFreshness can refuse to guess. Mirrors crystallize's own
187
+ * readTarget, reimplemented here because that one is not exported.
188
+ * @returns {string|null|undefined}
189
+ */
190
+ function readTarget(path) {
191
+ if (!existsSync(path)) return null;
192
+ try {
193
+ return readFileSync(path, 'utf-8');
194
+ } catch {
195
+ return undefined;
196
+ }
197
+ }
198
+
199
+ // ── pure decision functions ───────────────────────────────────────────────────
200
+
201
+ /**
202
+ * Classify the target's current bytes against the hash captured when the artifact
203
+ * was parked: 'fresh' (unchanged), 'drifted' (changed since parked), or
204
+ * 'unreadable' (cannot read what we would replace).
205
+ *
206
+ * NOTE on the null ambiguity: `currentAtProposalHash === null` means EITHER the
207
+ * target was absent at park time OR it was unreadable then (crystallize records
208
+ * `typeof disk === 'string' ? hashContent(disk) : null` for both). So "absent now
209
+ * + null at park" classifies as 'fresh'. That is deliberate and loses no bytes: a
210
+ * still-absent target has nothing to clobber, so it is not a data-loss case. The
211
+ * shell separately announces "target absent, will be created" so the human is not
212
+ * surprised by a create.
213
+ *
214
+ * @param {{current: string|null|undefined, currentAtProposalHash: string|null}} args
215
+ * @returns {'fresh'|'drifted'|'unreadable'}
216
+ */
217
+ export function classifyFreshness({ current, currentAtProposalHash }) {
218
+ if (current === undefined) return 'unreadable';
219
+ const nowHash = current === null ? null : hashProposalContent(current);
220
+ return nowHash === (currentAtProposalHash ?? null) ? 'fresh' : 'drifted';
221
+ }
222
+
223
+ /**
224
+ * The apply decision as a PURE function of the human's confirm and the target's
225
+ * freshness. Kept separate (planMarkerDecision precedent) so a table test can
226
+ * pin the branch priority without any IO.
227
+ *
228
+ * Order is load-bearing: 'unreadable' is checked BEFORE the confirm, so an
229
+ * unreadable target aborts regardless of what the human typed (we must never
230
+ * write over bytes we could not read). applyProposal returns early on
231
+ * 'unreadable' rather than prompt into the void, so that branch is unreachable
232
+ * from the CLI today; it stays because the refusal belongs to the decision
233
+ * contract, not to one caller's ordering.
234
+ *
235
+ * @param {{confirmed: boolean, freshness: 'fresh'|'drifted'|'unreadable'}} args
236
+ * @returns {{action: 'apply'|'abort', reason: string|null, warned?: boolean}}
237
+ */
238
+ export function planApplyAction({ confirmed, freshness }) {
239
+ if (freshness === 'unreadable') return { action: 'abort', reason: 'target-unreadable' };
240
+ if (!confirmed) return { action: 'abort', reason: 'not-confirmed' };
241
+ return { action: 'apply', reason: null, warned: freshness === 'drifted' };
242
+ }
243
+
244
+ /**
245
+ * Replace terminal control characters with a visible placeholder.
246
+ *
247
+ * Both the proposed bytes and the target string come from a hand-editable
248
+ * artifact, and the whole gate rests on the human trusting the diff they were
249
+ * shown. Raw escape sequences could clear the screen, redraw a benign diff, or
250
+ * hide added lines above the confirm prompt. Tabs and newlines are kept (they
251
+ * carry real content); the rest of C0, DEL, and the C1 range are neutralized. C1
252
+ * matters because it carries its own CSI introducer that redraws like ESC does.
253
+ * Only the DISPLAY is sanitized: the bytes written to the target stay verbatim.
254
+ */
255
+ function sanitizeForDisplay(text, { allowNewlines = true } = {}) {
256
+ // eslint-disable-next-line no-control-regex
257
+ const controls = allowNewlines
258
+ ? /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f\u0080-\u009f]/g
259
+ : /[\u0000-\u001f\u007f\u0080-\u009f]/g;
260
+ return String(text).replace(controls, '\ufffd');
261
+ }
262
+
263
+ /**
264
+ * A minimal line diff of current vs proposed: common prefix/suffix are trimmed so
265
+ * the human sees only the changed span, removed lines as `- ` and added as `+ `.
266
+ * Not a full LCS; the goal is a legible review, not a patch format. Only
267
+ * byte-equal line boundaries are trimmed, so the trim can neither hide a changed
268
+ * line nor invent one.
269
+ */
270
+ export function renderDiff(current, proposed) {
271
+ const a = String(current ?? '').split('\n');
272
+ const b = String(proposed).split('\n');
273
+ let start = 0;
274
+ while (start < a.length && start < b.length && a[start] === b[start]) start++;
275
+ let endA = a.length;
276
+ let endB = b.length;
277
+ while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) {
278
+ endA--;
279
+ endB--;
280
+ }
281
+ const out = [];
282
+ for (let i = start; i < endA; i++) out.push(`- ${a[i]}`);
283
+ for (let i = start; i < endB; i++) out.push(`+ ${b[i]}`);
284
+ return out.length === 0 ? '(no textual difference)' : out.join('\n');
285
+ }
286
+
287
+ // ── audit log ─────────────────────────────────────────────────────────────────
288
+
289
+ /**
290
+ * Append one JSONL line to `<hypoDir>/.cache/proposals/applied.log`. The `.log`
291
+ * suffix keeps it out of listProposals' `.json`-only readdir scan. This is the
292
+ * apply audit contract, so callers treat a failure here as FATAL, not
293
+ * best-effort.
294
+ *
295
+ * appendFileSync follows a symlink, so a planted `applied.log -> /elsewhere` would
296
+ * send the audit record out of the vault while apply still exits 0. Refuse a
297
+ * symlinked log the same way the target path refuses one: throwing here is the
298
+ * fail-loud the caller wants.
299
+ */
300
+ function appendApplyLog(hypoDir, entry) {
301
+ const logPath = join(proposalsDir(hypoDir), 'applied.log');
302
+ mkdirSync(dirname(logPath), { recursive: true });
303
+ try {
304
+ if (lstatSync(logPath).isSymbolicLink()) {
305
+ throw new Error(`audit log is a symlink, refusing to append: ${logPath}`);
306
+ }
307
+ } catch (e) {
308
+ if (!e || e.code !== 'ENOENT') throw e; // absent log is the normal first-apply case
309
+ }
310
+ appendFileSync(logPath, `${JSON.stringify(entry)}\n`);
311
+ }
312
+
313
+ // ── interactive prompt ────────────────────────────────────────────────────────
314
+
315
+ /**
316
+ * Default confirm prompt: readline over stdin, writing to stderr so stdout stays
317
+ * clean for the diff / --json. Returns the typed line with surrounding whitespace
318
+ * trimmed; the caller decides whether it matches the required `apply <id>` phrase.
319
+ * Injectable so the runner can drive apply without a real TTY.
320
+ */
321
+ async function defaultPrompt({ id, target }) {
322
+ const rl = (await import('node:readline/promises')).createInterface({
323
+ input: process.stdin,
324
+ output: process.stderr,
325
+ });
326
+ try {
327
+ process.stderr.write(
328
+ `\nApply proposal ${id} to ${target}?\n` +
329
+ ` This REPLACES the target with the proposed content shown above.\n` +
330
+ ` Type exactly \`apply ${id}\` to confirm; anything else aborts.\n`,
331
+ );
332
+ return (await rl.question('confirm> ')).trim();
333
+ } finally {
334
+ rl.close();
335
+ }
336
+ }
337
+
338
+ // ── actors (return { ok, code, ... }; main() maps code to exit) ────────────────
339
+
340
+ /**
341
+ * Apply one proposal: whole-file replace the target with the parked content,
342
+ * behind a fresh diff, a TTY gate, an explicit confirm, and a post-confirm
343
+ * re-read guard. Returns a result object; never calls process.exit (that is
344
+ * main()'s job, and the object is the test seam).
345
+ *
346
+ * @param {{hypoDir: string, id: string}} sel
347
+ * @param {{isTTY?: boolean, prompt?: Function, stdout?: {write: Function},
348
+ * stderr?: {write: Function}, now?: () => string}} [io]
349
+ */
350
+ export async function applyProposal({ hypoDir, id }, { isTTY, prompt, stdout, stderr, now } = {}) {
351
+ const out = stdout ?? process.stdout;
352
+ const err = stderr ?? process.stderr;
353
+ const clock = now ?? (() => new Date().toISOString());
354
+ const tty = isTTY ?? Boolean(process.stdin.isTTY && process.stdout.isTTY);
355
+ const ask = prompt ?? defaultPrompt;
356
+
357
+ // (1) validate + read the artifact: a bad id or missing artifact changes nothing
358
+ if (!isValidProposalId(id)) {
359
+ err.write(`✗ invalid proposal id: ${id}\n`);
360
+ return { ok: false, code: 2, reason: 'invalid-id' };
361
+ }
362
+ const proposal = readProposal(hypoDir, id);
363
+ if (!proposal) {
364
+ err.write(`✗ no such proposal: ${id}\n`);
365
+ return { ok: false, code: 2, reason: 'not-found' };
366
+ }
367
+
368
+ // (2) resolve + harden the target: null means unsafe, nothing is written
369
+ const full = resolveTargetPath(hypoDir, proposal.target);
370
+ if (!full) {
371
+ const shown = sanitizeForDisplay(proposal.target, { allowNewlines: false });
372
+ err.write(`✗ proposal target is unsafe or escapes the vault: ${shown}\n`);
373
+ return { ok: false, code: 2, reason: 'unsafe-target' };
374
+ }
375
+
376
+ // Everything the artifact contributes to the terminal passes through this.
377
+ const shownTarget = sanitizeForDisplay(proposal.target, { allowNewlines: false });
378
+
379
+ // (3) FRESH re-read of the current bytes (spec: diff against current, not base)
380
+ const current = readTarget(full);
381
+ const freshness = classifyFreshness({
382
+ current,
383
+ currentAtProposalHash: proposal.currentAtProposalHash,
384
+ });
385
+
386
+ // Cannot read what we would replace: refuse before prompting.
387
+ if (freshness === 'unreadable') {
388
+ err.write(`✗ target is unreadable, refusing to apply: ${shownTarget}\n`);
389
+ return { ok: false, code: 1, reason: 'target-unreadable' };
390
+ }
391
+
392
+ // (5) diff output: drift warning FIRST so the human re-reviews against fresh bytes.
393
+ if (freshness === 'drifted') {
394
+ out.write(`⚠️ target changed since this proposal was parked (${shownTarget}).\n`);
395
+ out.write(` The diff below is against the CURRENT on-disk bytes; applying REPLACES them.\n`);
396
+ }
397
+ if (current === null) {
398
+ out.write(`(target absent, will be created: ${shownTarget})\n`);
399
+ }
400
+ out.write(`--- diff (current to proposed) for ${shownTarget} ---\n`);
401
+ out.write(`${sanitizeForDisplay(renderDiff(current ?? '', proposal.proposedContent))}\n`);
402
+
403
+ // (6) TTY gate BEFORE the prompt, so a non-interactive run rejects without hanging
404
+ if (!tty) {
405
+ err.write(
406
+ `✗ apply requires an interactive terminal for confirmation. Aborted; target unchanged.\n`,
407
+ );
408
+ return { ok: false, code: 1, reason: 'not-tty' };
409
+ }
410
+
411
+ // (7) confirm: the human must type EXACTLY `apply <id>`: the sole write authority
412
+ const typed = await ask({ id, target: shownTarget });
413
+ const confirmed = typeof typed === 'string' && typed.trim() === `apply ${id}`;
414
+
415
+ // (8) decide: abort preserves BOTH the target and the proposal
416
+ const decision = planApplyAction({ confirmed, freshness });
417
+ if (decision.action === 'abort') {
418
+ err.write(`✗ apply aborted (${decision.reason}); target and proposal preserved.\n`);
419
+ return { ok: false, code: 1, reason: decision.reason };
420
+ }
421
+
422
+ // (9+) the write itself, shared with the transcript-approved path. The approval
423
+ // JUST established above is the only thing this path contributes; every byte that
424
+ // reaches disk goes through the one primitive.
425
+ return writeApprovedProposal(
426
+ {
427
+ hypoDir,
428
+ id,
429
+ proposal,
430
+ full,
431
+ displayedHash: current === null ? null : hashProposalContent(current),
432
+ },
433
+ {
434
+ approval: { via: 'tty', nonce: null },
435
+ warned: decision.warned,
436
+ stdout: out,
437
+ stderr: err,
438
+ now: clock,
439
+ },
440
+ );
441
+ }
442
+
443
+ /**
444
+ * Write ONE approved proposal to its target: the hardened write body, extracted so
445
+ * the TTY path and the transcript-approved path cannot drift apart.
446
+ *
447
+ * This function does NOT decide whether the write is authorized. Its callers do, by
448
+ * two different means (a typed `apply <id>` at a TTY; a typed, nonce-bound user turn
449
+ * in the transcript), and they pass the outcome in as `approval`. What lives here is
450
+ * everything that must hold no matter WHO approved: the post-approval re-validation,
451
+ * the ordered write → audit-log → delete, and the sibling warning.
452
+ *
453
+ * The seams (`stdout`/`stderr`/`now`) stay injectable for tests, as before. They are
454
+ * NOT an unattended-apply path: nothing here relaxes a check, and the callers are the
455
+ * only authorization sites.
456
+ *
457
+ * @param {{hypoDir: string, id: string, proposal: object, full: string,
458
+ * displayedHash: string|null}} sel `displayedHash` is the hash of the bytes
459
+ * whose diff the approver saw (null when the target was absent).
460
+ * @param {{approval: {via: 'tty'|'transcript', nonce: string|null},
461
+ * warned?: boolean, stdout?: object, stderr?: object, now?: () => string}} io
462
+ */
463
+ export function writeApprovedProposal(
464
+ { hypoDir, id, proposal, full, displayedHash },
465
+ { approval, warned, stdout, stderr, now } = {},
466
+ ) {
467
+ const out = stdout ?? process.stdout;
468
+ const err = stderr ?? process.stderr;
469
+ const clock = now ?? (() => new Date().toISOString());
470
+ const shownTarget = sanitizeForDisplay(proposal.target, { allowNewlines: false });
471
+
472
+ // (9) POST-APPROVAL RE-VALIDATION. Nothing is locked while the approver reads, so
473
+ // BOTH the path and the bytes can move between step 2/3 and the write. Checking
474
+ // only the bytes would leave the path check stale: an ancestor that becomes a
475
+ // symlink out of the vault during the prompt would still be walked by the write
476
+ // below. So re-resolve first, then re-read.
477
+ //
478
+ // A residual race remains between these checks and the rename below. Re-resolving
479
+ // NARROWS the window but does not close it: because every check operates on a
480
+ // pathname and the write does too, an ancestor swapped to an out-of-vault symlink
481
+ // in that final gap can still redirect the write. Closing it would need
482
+ // component-wise openat / a lockfile, both of which the spec's guarantee scope
483
+ // rules out (see the hostile-local-FS note there). An actor able to win that race
484
+ // already has write access to every target directly, so it grants no new reach.
485
+ const stillSafe = resolveTargetPath(hypoDir, proposal.target);
486
+ if (!stillSafe || stillSafe !== full) {
487
+ err.write(
488
+ `✗ target path stopped resolving safely while you were reviewing; nothing written.\n`,
489
+ );
490
+ return { ok: false, code: 1, reason: 'unsafe-target' };
491
+ }
492
+
493
+ const afterConfirm = readTarget(full);
494
+ const afterHash =
495
+ afterConfirm === undefined
496
+ ? undefined
497
+ : afterConfirm === null
498
+ ? null
499
+ : hashProposalContent(afterConfirm);
500
+ if (afterHash !== displayedHash) {
501
+ err.write(
502
+ `✗ target changed while you were reviewing; nothing written, proposal preserved. ` +
503
+ `Re-run apply to review the fresh bytes.\n`,
504
+ );
505
+ return { ok: false, code: 1, reason: 'concurrent-mutation' };
506
+ }
507
+
508
+ // (10) write: a failure here is fail-loud and keeps the proposal for retry.
509
+ // Creating the parent is its own hazard (`mkdirSync(recursive)` follows a symlink
510
+ // planted since the check above), so containment is re-proven once the parent
511
+ // exists and only then are the bytes written.
512
+ try {
513
+ mkdirSync(dirname(full), { recursive: true });
514
+ if (resolveTargetPath(hypoDir, proposal.target) !== full) {
515
+ throw new Error('target path escaped the vault while its parent was created');
516
+ }
517
+ atomicWrite(full, proposal.proposedContent);
518
+ } catch (e) {
519
+ // A filesystem error message embeds the path, so it can carry the artifact's
520
+ // control characters back to the terminal; sanitize it like any shown field.
521
+ const why = sanitizeForDisplay(e.message, { allowNewlines: false });
522
+ err.write(`✗ failed to write ${shownTarget}: ${why}. Nothing changed; proposal preserved.\n`);
523
+ return { ok: false, code: 1, reason: 'write-failed' };
524
+ }
525
+
526
+ // (11) audit log: FATAL on failure (it is the apply audit contract). Ordered
527
+ // write→log→delete: the log never lies (recorded only after a real write), and a
528
+ // log failure leaves the proposal alive so a re-apply self-heals the record.
529
+ try {
530
+ appendApplyLog(hypoDir, {
531
+ id,
532
+ target: proposal.target,
533
+ currentHash: displayedHash,
534
+ appliedAt: clock(),
535
+ sessionId: proposal.sessionId ?? null,
536
+ device: proposal.device ?? null,
537
+ // Which authority approved this write, and (for the transcript channel) the
538
+ // one-time nonce the user typed. The audit trail is the only place an
539
+ // unattended apply, if one ever slipped in, would become visible — so the
540
+ // channel is recorded on EVERY entry, not just the new one.
541
+ via: approval?.via ?? 'tty',
542
+ nonce: approval?.nonce ?? null,
543
+ });
544
+ } catch (e) {
545
+ const why = sanitizeForDisplay(e.message, { allowNewlines: false });
546
+ err.write(
547
+ `✗ applied to disk but FAILED to write the audit log: ${why}. ` +
548
+ `Proposal kept; re-run apply to complete the audit record.\n`,
549
+ );
550
+ return { ok: false, code: 1, reason: 'log-failed', applied: true };
551
+ }
552
+
553
+ // (12) remove the now-applied artifact: a leftover artifact is a non-zero exit
554
+ if (!deleteProposal(hypoDir, id)) {
555
+ err.write(`⚠️ applied and logged, but the proposal artifact was not removed: ${id}\n`);
556
+ return { ok: false, code: 1, reason: 'artifact-not-removed', applied: true };
557
+ }
558
+
559
+ out.write(`✓ applied proposal ${id} to ${shownTarget}\n`);
560
+
561
+ // (13) A target can carry one parked payload per session (plus any whose owner is
562
+ // unidentifiable), so applying this id may leave another proposal for the same
563
+ // file still pending. Say so, or the human reads "✓ applied" as done and meets the
564
+ // leftover only via the next pending count. Never auto-delete it: destroying a
565
+ // payload nobody reviewed is the clobber this store exists to prevent.
566
+ const siblings = listProposals(hypoDir).filter((p) => p.target === proposal.target);
567
+ if (siblings.length > 0) {
568
+ const ids = siblings.map((p) => p.id).join(', ');
569
+ err.write(
570
+ `⚠️ ${siblings.length} other proposal(s) still target ${shownTarget}: ${ids}\n` +
571
+ ` The write above changed that file, so each one now needs a fresh look. ` +
572
+ `\`hypomnema proposal apply <id>\` re-reads the file and diffs against it; discard the ones you do not want.\n`,
573
+ );
574
+ }
575
+
576
+ return {
577
+ ok: true,
578
+ code: 0,
579
+ id,
580
+ target: proposal.target,
581
+ warned: Boolean(warned),
582
+ siblingsPending: siblings.length,
583
+ };
584
+ }
585
+
586
+ // ── transcript-approved batch (challenge → user types → resolve) ──────────────
587
+
588
+ /**
589
+ * Mint an approval challenge for a batch of parked proposals: show the diffs the
590
+ * user is being asked to approve, and record exactly what an approval would buy.
591
+ *
592
+ * The ids come from the CALLER (the close result), never from a scan of the store
593
+ * by session. `writeProposal` reuses a byte-identical artifact across sessions and
594
+ * the body's `sessionId` may then name a different session, so "the proposals of
595
+ * this close" is not something the store can be asked for — only the close knows.
596
+ *
597
+ * The record binds, per item, the id AND the target path AND the proposed bytes AND
598
+ * the target's current freshness. Binding fewer of those leaves a hole: the artifact
599
+ * body is hand-editable and apply writes to whatever `target` says AT APPLY TIME, so
600
+ * id+content alone would let the approved bytes land on a path the user never saw.
601
+ *
602
+ * An unreadable target refuses the whole batch rather than mint a challenge for a
603
+ * diff nobody can be shown.
604
+ *
605
+ * @param {{hypoDir: string, sessionId: string, ids: string[]}} sel
606
+ */
607
+ export function challengeProposals(
608
+ { hypoDir, sessionId, ids },
609
+ { stdout, stderr, now, nonce } = {},
610
+ ) {
611
+ const out = stdout ?? process.stdout;
612
+ const err = stderr ?? process.stderr;
613
+ const clock = now ?? (() => new Date().toISOString());
614
+
615
+ if (!isValidSessionId(sessionId)) {
616
+ err.write(`✗ invalid --session-id\n`);
617
+ return { ok: false, code: 2, reason: 'invalid-session-id' };
618
+ }
619
+ if (!Array.isArray(ids) || ids.length === 0) {
620
+ err.write(`✗ challenge needs --ids=<id,...> (the proposal ids the close reported)\n`);
621
+ return { ok: false, code: 2, reason: 'no-ids' };
622
+ }
623
+
624
+ const items = [];
625
+ for (const id of ids) {
626
+ if (!isValidProposalId(id)) {
627
+ err.write(`✗ invalid proposal id: ${id}\n`);
628
+ return { ok: false, code: 2, reason: 'invalid-id' };
629
+ }
630
+ const proposal = readProposal(hypoDir, id);
631
+ if (!proposal) {
632
+ err.write(`✗ no such proposal: ${id}\n`);
633
+ return { ok: false, code: 2, reason: 'not-found' };
634
+ }
635
+ const full = resolveTargetPath(hypoDir, proposal.target);
636
+ if (!full) {
637
+ const shown = sanitizeForDisplay(proposal.target, { allowNewlines: false });
638
+ err.write(`✗ proposal target is unsafe or escapes the vault: ${shown}\n`);
639
+ return { ok: false, code: 2, reason: 'unsafe-target' };
640
+ }
641
+ const current = readTarget(full);
642
+ if (current === undefined) {
643
+ const shown = sanitizeForDisplay(proposal.target, { allowNewlines: false });
644
+ err.write(`✗ target is unreadable, refusing to mint a challenge for it: ${shown}\n`);
645
+ return { ok: false, code: 1, reason: 'target-unreadable' };
646
+ }
647
+
648
+ const shownTarget = sanitizeForDisplay(proposal.target, { allowNewlines: false });
649
+ if (current === null) out.write(`(target absent, will be created: ${shownTarget})\n`);
650
+ out.write(`--- diff (current to proposed) for ${shownTarget} ---\n`);
651
+ out.write(`${sanitizeForDisplay(renderDiff(current ?? '', proposal.proposedContent))}\n`);
652
+
653
+ items.push({
654
+ id,
655
+ target: proposal.target,
656
+ proposedHash: hashProposalContent(proposal.proposedContent),
657
+ freshness:
658
+ current === null
659
+ ? { state: 'absent', hash: null }
660
+ : { state: 'hash', hash: hashProposalContent(current) },
661
+ });
662
+ }
663
+
664
+ // crypto-random, never Math.random: an approval token a hook could PREDICT would
665
+ // let it pre-plant the phrase and spend an approval the user never gave.
666
+ const minted = nonce ?? randomBytes(16).toString('hex');
667
+ const record = { nonce: minted, sessionId, mintedAt: clock(), items };
668
+ if (!writeChallenge(hypoDir, record)) {
669
+ err.write(
670
+ `✗ failed to store the approval challenge; nothing to approve.\n` +
671
+ ` A stale challenge for this session may be undeletable. Check and clear:\n` +
672
+ ` ${join(proposalsDir(hypoDir), 'challenges')}/${sessionId}.*.json\n`,
673
+ );
674
+ return { ok: false, code: 1, reason: 'challenge-store-failed' };
675
+ }
676
+
677
+ out.write(
678
+ `\nTo approve the ${items.length} overwrite(s) above, the USER must type this line in the conversation:\n\n` +
679
+ ` ${APPROVAL_PHRASE} ${minted}\n\n` +
680
+ `Then run: hypomnema proposal resolve --session-id=${sessionId}\n`,
681
+ );
682
+ return {
683
+ ok: true,
684
+ code: 0,
685
+ nonce: minted,
686
+ items: items.map(({ id, target }) => ({ id, target })),
687
+ };
688
+ }
689
+
690
+ /**
691
+ * Apply a batch of parked proposals that the user approved by typing the challenge
692
+ * phrase in the conversation.
693
+ *
694
+ * The authorization is the transcript, not this process's stdin: `hasTypedUserApproval`
695
+ * requires a genuine user-typed turn carrying the nonce minted for THIS challenge. A
696
+ * hook cannot forge a user turn, and the model's own output is role:assistant and is
697
+ * never counted. That is the whole of the human gate; it is not weakened here, it is
698
+ * relocated.
699
+ *
700
+ * Everything is preflighted before ANY byte is written, so a refusal is total. The
701
+ * one partial case left is a failure part-way through the writes, and that is
702
+ * reported per target rather than swallowed: a re-close idempotent-skips whatever
703
+ * landed and re-parks the rest, so silence would be the only unrecoverable part.
704
+ *
705
+ * @param {{hypoDir: string, sessionId: string}} sel
706
+ */
707
+ export function resolveProposals(
708
+ { hypoDir, sessionId },
709
+ { stdout, stderr, now, transcriptPath, hasApproval } = {},
710
+ ) {
711
+ const out = stdout ?? process.stdout;
712
+ const err = stderr ?? process.stderr;
713
+ const clock = now ?? (() => new Date().toISOString());
714
+ const approves = hasApproval ?? hasTypedUserApproval;
715
+
716
+ if (!isValidSessionId(sessionId)) {
717
+ err.write(`✗ invalid --session-id\n`);
718
+ return { ok: false, code: 2, reason: 'invalid-session-id' };
719
+ }
720
+
721
+ // (1) the challenge. Absent / corrupt / not-owned all mean "no approval exists",
722
+ // which is a REMINT, never a bypass.
723
+ const challenge = readChallenge(hypoDir, sessionId);
724
+ if (!challenge) {
725
+ err.write(
726
+ `✗ no valid approval challenge for this session. Run \`hypomnema proposal challenge\` first.\n`,
727
+ );
728
+ return { ok: false, code: 1, reason: 'no-challenge' };
729
+ }
730
+
731
+ // (2) the transcript approval. Resolve the transcript from the session id (fail-closed
732
+ // on zero or multiple matches), then require the user's typed, nonce-bearing turn.
733
+ const tPath = transcriptPath ?? resolveTranscriptBySessionId(sessionId);
734
+ if (!tPath) {
735
+ err.write(`✗ cannot resolve a transcript for session ${sessionId}; approval unverifiable.\n`);
736
+ return { ok: false, code: 1, reason: 'transcript-unresolved' };
737
+ }
738
+ if (!approves(tPath, challenge.nonce)) {
739
+ err.write(
740
+ `✗ no user approval in this session's transcript.\n` +
741
+ ` The user must TYPE this line in the conversation (a click does not count):\n` +
742
+ ` ${APPROVAL_PHRASE} ${challenge.nonce}\n` +
743
+ ` Nothing written; the proposals are preserved.\n`,
744
+ );
745
+ return { ok: false, code: 1, reason: 'not-approved' };
746
+ }
747
+
748
+ // (3) PREFLIGHT every item against what was approved. Any drift refuses the WHOLE
749
+ // batch: the user approved a set, not a subset, and a partial write of a set they
750
+ // reviewed as a unit is not what they said yes to.
751
+ const plan = [];
752
+ for (const item of challenge.items) {
753
+ const proposal = readProposal(hypoDir, item.id);
754
+ if (!proposal) {
755
+ err.write(`✗ approved proposal ${item.id} is gone; re-run challenge. Nothing written.\n`);
756
+ return { ok: false, code: 1, reason: 'proposal-missing' };
757
+ }
758
+ // The artifact body is untrusted and hand-editable. The user approved THESE bytes
759
+ // going to THIS path; a body that changed either since the diff is a different
760
+ // write than the one that was approved.
761
+ if (proposal.target !== item.target) {
762
+ err.write(
763
+ `✗ proposal ${item.id} now targets a different path than the one approved; nothing written.\n`,
764
+ );
765
+ return { ok: false, code: 1, reason: 'target-changed' };
766
+ }
767
+ if (hashProposalContent(proposal.proposedContent) !== item.proposedHash) {
768
+ err.write(`✗ proposal ${item.id} content changed since it was approved; nothing written.\n`);
769
+ return { ok: false, code: 1, reason: 'content-changed' };
770
+ }
771
+ const full = resolveTargetPath(hypoDir, proposal.target);
772
+ if (!full) {
773
+ err.write(`✗ proposal ${item.id} target is unsafe or escapes the vault; nothing written.\n`);
774
+ return { ok: false, code: 1, reason: 'unsafe-target' };
775
+ }
776
+ // Freshness: the bytes on disk must still be the bytes whose diff was shown.
777
+ // absent and empty are DIFFERENT states and are not collapsed.
778
+ const current = readTarget(full);
779
+ const state = current === undefined ? 'unreadable' : current === null ? 'absent' : 'hash';
780
+ const hash = state === 'hash' ? hashProposalContent(current) : null;
781
+ if (state !== item.freshness.state || hash !== (item.freshness.hash ?? null)) {
782
+ const shown = sanitizeForDisplay(proposal.target, { allowNewlines: false });
783
+ err.write(
784
+ `✗ ${shown} changed since you were shown its diff; nothing written.\n` +
785
+ ` Re-run \`hypomnema proposal challenge\` to review the fresh bytes.\n`,
786
+ );
787
+ return { ok: false, code: 1, reason: 'stale-approval' };
788
+ }
789
+ plan.push({ item, proposal, full, displayedHash: hash });
790
+ }
791
+
792
+ // (4) SPEND THE CHALLENGE, THEN WRITE. The order is the security property, not a
793
+ // style choice. The approval lives in the transcript, which is append-only: the
794
+ // user's typed line never expires, so `hasTypedUserApproval` keeps returning true
795
+ // for the rest of the session. Deleting the challenge is therefore the ONLY thing
796
+ // that makes the approval single-use — and a delete that runs AFTER the writes has
797
+ // already lost. If it failed there, the nonce stayed spendable and the model (whom
798
+ // this whole gate exists not to trust) could re-run `resolve` on a later turn and
799
+ // write again with no fresh human turn. Exiting non-zero announced that hole; it
800
+ // did not close it.
801
+ //
802
+ // Spending first closes it: on any later failure the challenge is already gone, so
803
+ // a re-run hits `no-challenge`, remints, and asks the user again. The cost is that
804
+ // a transient write failure now costs a re-approval instead of a silent retry. For
805
+ // a gate that guards someone's file, that is the right direction to fail.
806
+ //
807
+ // Nothing downstream reads the challenge: `plan` is fully resolved in memory above.
808
+ //
809
+ // consumeChallenge answers "did I spend THIS nonce", not "is the session's challenge
810
+ // file gone" — the unlink is what makes concurrent resolvers mutually exclusive, and
811
+ // only the one whose unlink SUCCEEDED may write. It is handed the nonce this run
812
+ // actually read and verified, so it can never consume a fresher record that a
813
+ // concurrent `challenge` minted in its place.
814
+ if (!consumeChallenge(hypoDir, sessionId, challenge.nonce)) {
815
+ err.write(
816
+ `✗ could not consume the approval challenge for ${sessionId}; nothing written.\n` +
817
+ ` Either it was already spent (or superseded by a newer challenge), or it could\n` +
818
+ ` not be removed and would stay replayable. Either way this run has no approval\n` +
819
+ ` to write against. If the file is still there, remove it by hand and re-run the\n` +
820
+ ` close:\n` +
821
+ ` ${join(proposalsDir(hypoDir), 'challenges', `${sessionId}.${challenge.nonce}.json`)}\n`,
822
+ );
823
+ return {
824
+ ok: false,
825
+ code: 1,
826
+ reason: 'challenge-not-consumed',
827
+ written: [],
828
+ challengeSpent: false,
829
+ };
830
+ }
831
+
832
+ // (5) write. Preflight passed for every item, so the only failure left is a write
833
+ // that breaks mid-batch. Report exactly what landed: a re-close idempotent-skips
834
+ // the written targets and re-parks the rest, so a reported partial recovers and a
835
+ // SILENT one does not.
836
+ const written = [];
837
+ const failed = [];
838
+ for (const { item, proposal, full, displayedHash } of plan) {
839
+ const res = writeApprovedProposal(
840
+ { hypoDir, id: item.id, proposal, full, displayedHash },
841
+ {
842
+ approval: { via: 'transcript', nonce: challenge.nonce },
843
+ warned: false,
844
+ stdout: out,
845
+ stderr: err,
846
+ now: clock,
847
+ },
848
+ );
849
+ if (res.ok) written.push(item.target);
850
+ else failed.push({ target: item.target, reason: res.reason, applied: Boolean(res.applied) });
851
+ }
852
+
853
+ // The challenge is already spent, so a partial batch cannot be finished by re-running
854
+ // `resolve` — and it should not be. Re-running the CLOSE is the recovery path: it
855
+ // idempotent-skips whatever landed and re-parks the rest for a fresh approval.
856
+ if (failed.length > 0) {
857
+ // Report by what HIT THE PAGE, not by what returned ok. A write can land and then
858
+ // have its audit-log append or its artifact removal fail (`applied: true, ok: false`),
859
+ // and reporting that item as unwritten would tell the user their file is untouched
860
+ // when it has already been overwritten. This tool must never understate a write.
861
+ const landed = [...written, ...failed.filter((f) => f.applied).map((f) => f.target)];
862
+ err.write(
863
+ `\n✗ batch partially applied.\n` +
864
+ ` Landed on the page: ${landed.length ? landed.join(', ') : '(none)'}\n` +
865
+ ` Failed: ${failed.map((f) => `${f.target} (${f.reason})`).join(', ')}\n` +
866
+ ` A page can appear in both: its bytes landed, then a post-write step failed.\n` +
867
+ ` The approval is spent. Re-run the session close: it skips what already landed\n` +
868
+ ` and re-parks the rest, which mints a fresh challenge to approve.\n`,
869
+ );
870
+ return {
871
+ ok: false,
872
+ code: 1,
873
+ reason: 'partial-apply',
874
+ written,
875
+ landed,
876
+ failed,
877
+ challengeSpent: true,
878
+ };
879
+ }
880
+
881
+ out.write(`✓ applied ${written.length} approved proposal(s).\n`);
882
+ out.write(` The session is NOT closed yet: re-run the close and check markerWritten.\n`);
883
+ return { ok: true, code: 0, written, challengeSpent: true };
884
+ }
885
+
886
+ /**
887
+ * Discard one proposal: remove the artifact, leave the target untouched. No
888
+ * confirm gate (the spec defines discard as a plain removal, and it writes nothing
889
+ * to any page).
890
+ */
891
+ export function discardProposal({ hypoDir, id }, { stdout, stderr } = {}) {
892
+ const out = stdout ?? process.stdout;
893
+ const err = stderr ?? process.stderr;
894
+ if (!isValidProposalId(id)) {
895
+ err.write(`✗ invalid proposal id: ${id}\n`);
896
+ return { ok: false, code: 2, reason: 'invalid-id' };
897
+ }
898
+ const proposal = readProposal(hypoDir, id);
899
+ if (!proposal) {
900
+ err.write(`✗ no such proposal: ${id}\n`);
901
+ return { ok: false, code: 2, reason: 'not-found' };
902
+ }
903
+ if (!deleteProposal(hypoDir, id)) {
904
+ err.write(`✗ failed to remove proposal ${id}\n`);
905
+ return { ok: false, code: 1, reason: 'delete-failed' };
906
+ }
907
+ const shown = sanitizeForDisplay(proposal.target, { allowNewlines: false });
908
+ out.write(`✓ discarded proposal ${id} (target ${shown} left unchanged)\n`);
909
+ return { ok: true, code: 0, id, target: proposal.target };
910
+ }
911
+
912
+ /**
913
+ * List pending proposals (id, target, createdAt, plus session/device), oldest
914
+ * first. `--json` emits the array; the human form prints one line each, or a
915
+ * "no pending proposals" notice.
916
+ */
917
+ export function listPending({ hypoDir }, { stdout, json } = {}) {
918
+ const out = stdout ?? process.stdout;
919
+ const items = listProposals(hypoDir)
920
+ .map((p) => ({
921
+ id: p.id,
922
+ target: p.target,
923
+ createdAt: p.createdAt ?? null,
924
+ sessionId: p.sessionId ?? null,
925
+ device: p.device ?? null,
926
+ }))
927
+ .sort((a, b) => String(a.createdAt).localeCompare(String(b.createdAt)));
928
+
929
+ if (json) {
930
+ out.write(`${JSON.stringify(items, null, 2)}\n`);
931
+ return { ok: true, code: 0, count: items.length };
932
+ }
933
+ if (items.length === 0) {
934
+ out.write('no pending proposals\n');
935
+ return { ok: true, code: 0, count: 0 };
936
+ }
937
+ // Every field but `id` (validated by listProposals) comes from the artifact body
938
+ // and is shown one line each, so all of them pass through the display sanitizer.
939
+ const clean = (v) => sanitizeForDisplay(String(v), { allowNewlines: false });
940
+ for (const it of items) {
941
+ const who = it.sessionId
942
+ ? ` (session ${clean(it.sessionId)}, ${clean(it.device ?? '?')})`
943
+ : '';
944
+ out.write(`${it.id} ${clean(it.target)} ${clean(it.createdAt)}${who}\n`);
945
+ }
946
+ return { ok: true, code: 0, count: items.length };
947
+ }
948
+
949
+ // ── CLI ───────────────────────────────────────────────────────────────────────
950
+
951
+ function parseArgs(argv) {
952
+ const args = { hypoDir: null, cmd: null, id: null, json: false, sessionId: null, ids: [] };
953
+ const positionals = [];
954
+ const rest = argv.slice(2);
955
+ for (let i = 0; i < rest.length; i++) {
956
+ const a = rest[i];
957
+ if (a === '--hypo-dir') args.hypoDir = expandHome(rest[++i] ?? '');
958
+ else if (a.startsWith('--hypo-dir=')) args.hypoDir = expandHome(a.slice('--hypo-dir='.length));
959
+ else if (a === '--json') args.json = true;
960
+ else if (a === '--session-id') args.sessionId = rest[++i] ?? null;
961
+ else if (a.startsWith('--session-id=')) args.sessionId = a.slice('--session-id='.length);
962
+ else if (a === '--ids') args.ids = splitIds(rest[++i] ?? '');
963
+ else if (a.startsWith('--ids=')) args.ids = splitIds(a.slice('--ids='.length));
964
+ else positionals.push(a);
965
+ }
966
+ args.cmd = positionals[0] ?? null;
967
+ args.id = positionals[1] ?? null;
968
+ if (!args.hypoDir) args.hypoDir = resolveHypoRoot();
969
+ return args;
970
+ }
971
+
972
+ /** `--ids=a,b,c` → ['a','b','c']; blanks dropped so a trailing comma is not an id. */
973
+ function splitIds(raw) {
974
+ return String(raw)
975
+ .split(',')
976
+ .map((s) => s.trim())
977
+ .filter(Boolean);
978
+ }
979
+
980
+ async function main() {
981
+ const args = parseArgs(process.argv);
982
+ let result;
983
+ switch (args.cmd) {
984
+ case 'list':
985
+ result = listPending({ hypoDir: args.hypoDir }, { stdout: process.stdout, json: args.json });
986
+ break;
987
+ case 'apply':
988
+ if (!args.id) {
989
+ process.stderr.write('✗ usage: hypomnema proposal apply <id>\n');
990
+ process.exit(2);
991
+ }
992
+ result = await applyProposal({ hypoDir: args.hypoDir, id: args.id }, {});
993
+ break;
994
+ case 'challenge':
995
+ result = challengeProposals(
996
+ { hypoDir: args.hypoDir, sessionId: args.sessionId, ids: args.ids },
997
+ {},
998
+ );
999
+ break;
1000
+ case 'resolve':
1001
+ result = resolveProposals({ hypoDir: args.hypoDir, sessionId: args.sessionId }, {});
1002
+ break;
1003
+ case 'discard':
1004
+ if (!args.id) {
1005
+ process.stderr.write('✗ usage: hypomnema proposal discard <id>\n');
1006
+ process.exit(2);
1007
+ }
1008
+ result = discardProposal({ hypoDir: args.hypoDir, id: args.id }, {});
1009
+ break;
1010
+ default:
1011
+ process.stderr.write(
1012
+ 'usage: hypomnema proposal <list|apply|discard> [id] [--json] [--hypo-dir <path>]\n' +
1013
+ ' hypomnema proposal challenge --session-id <id> --ids <id,...>\n' +
1014
+ ' hypomnema proposal resolve --session-id <id>\n',
1015
+ );
1016
+ process.exit(2);
1017
+ }
1018
+ process.exit(result.code ?? (result.ok ? 0 : 1));
1019
+ }
1020
+
1021
+ function isMain() {
1022
+ try {
1023
+ if (!process.argv[1]) return false;
1024
+ return pathToFileURL(realpathSync(process.argv[1])).href === import.meta.url;
1025
+ } catch {
1026
+ return false;
1027
+ }
1028
+ }
1029
+
1030
+ if (isMain()) {
1031
+ main();
1032
+ }