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,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
|
+
}
|