hypomnema 1.8.4 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +36 -0
- package/README.ko.md +16 -15
- package/README.md +14 -13
- package/commands/crystallize.md +84 -30
- package/commands/doctor.md +3 -1
- package/docs/ARCHITECTURE.md +32 -9
- package/docs/CONTRIBUTING.md +5 -1
- package/hooks/atomic-write.mjs +44 -0
- package/hooks/base-store.mjs +409 -90
- package/hooks/close-gate-store.mjs +131 -16
- package/hooks/close-journal.mjs +141 -19
- package/hooks/close-receipt.mjs +559 -0
- package/hooks/hooks.json +2 -29
- package/hooks/hypo-auto-commit.mjs +80 -10
- package/hooks/hypo-auto-minimal-crystallize.mjs +129 -25
- package/hooks/hypo-auto-stage.mjs +13 -1
- package/hooks/hypo-close-guard.mjs +2 -1
- package/hooks/hypo-cwd-change.mjs +45 -20
- package/hooks/hypo-file-watch.mjs +44 -6
- package/hooks/hypo-first-prompt.mjs +15 -9
- package/hooks/hypo-hot-rebuild.mjs +169 -112
- package/hooks/hypo-personal-check.mjs +9 -1
- package/hooks/hypo-session-record.mjs +1 -1
- package/hooks/hypo-session-start.mjs +381 -18
- package/hooks/hypo-shared.mjs +2151 -185
- package/hooks/hypo-stop.mjs +207 -0
- package/hooks/proposal-store.mjs +166 -34
- package/hooks/shared.json +7 -1
- package/hooks/version-check.mjs +9 -7
- package/package.json +2 -1
- package/scripts/crystallize.mjs +3 -4
- package/scripts/doctor.mjs +506 -32
- package/scripts/init.mjs +32 -3
- package/scripts/lib/crystallize-close-apply.mjs +1949 -230
- package/scripts/lib/crystallize-close-check.mjs +43 -23
- package/scripts/lib/design-history-stale.mjs +36 -9
- package/scripts/lib/frontmatter.mjs +39 -0
- package/scripts/lib/hook-inventory.mjs +31 -1
- package/scripts/lib/page-usage.mjs +1 -1
- package/scripts/lib/pkg-provenance.mjs +7 -1
- package/scripts/lib/project-create.mjs +40 -44
- package/scripts/lib/schema-vocab.mjs +22 -11
- package/scripts/lib/template-schema-version.mjs +1 -0
- package/scripts/lib/time.mjs +46 -0
- package/scripts/lint.mjs +66 -18
- package/scripts/proposal.mjs +423 -18
- package/scripts/resume.mjs +3 -2
- package/scripts/session-audit.mjs +2 -1
- package/scripts/uninstall.mjs +15 -1
- package/scripts/upgrade.mjs +281 -90
- package/scripts/verify.mjs +2 -1
- package/scripts/weekly-report.mjs +2 -1
- package/templates/SCHEMA.md +27 -7
- package/templates/gitignore +11 -0
- package/templates/hypo-automation.md +10 -13
- package/templates/hypo-config.md +1 -1
- package/templates/hypo-guide.md +53 -25
- package/templates/hot.md +0 -24
|
@@ -32,9 +32,11 @@
|
|
|
32
32
|
// own doc comment for the caller contract.
|
|
33
33
|
|
|
34
34
|
import { createHash } from 'node:crypto';
|
|
35
|
-
import { existsSync,
|
|
36
|
-
import {
|
|
37
|
-
import { walkCloseGate } from './hypo-shared.mjs';
|
|
35
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
36
|
+
import { join } from 'node:path';
|
|
37
|
+
import { sessionClosedMarkerPath, walkCloseGate } from './hypo-shared.mjs';
|
|
38
|
+
import { receiptPath } from './close-receipt.mjs';
|
|
39
|
+
import { atomicWrite } from './atomic-write.mjs';
|
|
38
40
|
|
|
39
41
|
/** `<hypoDir>/.cache/close-gate/<session-id>.json`. */
|
|
40
42
|
export function closeGatePath(hypoDir, sessionId) {
|
|
@@ -121,14 +123,6 @@ export function resolutionStamp(rawTranscript, upToIndex = Infinity) {
|
|
|
121
123
|
return { index, prefixSha };
|
|
122
124
|
}
|
|
123
125
|
|
|
124
|
-
/** Atomic overwrite via tmp+rename, mirroring base-store's atomicWrite. */
|
|
125
|
-
function atomicWrite(path, content) {
|
|
126
|
-
mkdirSync(dirname(path), { recursive: true });
|
|
127
|
-
const tmp = `${path}.${process.pid}.${Math.random().toString(36).slice(2, 10)}.tmp`;
|
|
128
|
-
writeFileSync(tmp, content);
|
|
129
|
-
renameSync(tmp, path);
|
|
130
|
-
}
|
|
131
|
-
|
|
132
126
|
/**
|
|
133
127
|
* Record that this session's close gate is resolved, at the point `stamp`
|
|
134
128
|
* describes. Best-effort: a hook must never fail a close over a cache write,
|
|
@@ -382,20 +376,62 @@ export function readResolution(hypoDir, sessionId, rawTranscript) {
|
|
|
382
376
|
* doing so.
|
|
383
377
|
*
|
|
384
378
|
* @param {{transcriptPath: string|null, hypoDir: string, sessionId: string|null}} args
|
|
385
|
-
* @returns {{ok: boolean, open: boolean, reason: string|null}}
|
|
379
|
+
* @returns {{ok: boolean, open: boolean, reason: string|null, hostTagWarning?: string}}
|
|
380
|
+
* `hostTagWarning` is present on any result whose gate reads OPEN and whose
|
|
381
|
+
* `walkCloseGate` reported a `neutralizedHostTagName`: the close in effect
|
|
382
|
+
* survived a HOST_TAG_NAMES-shaped queue item reading as neutral rather
|
|
383
|
+
* than a retraction. Not gated on `ok`, because the two `ok: false` returns
|
|
384
|
+
* below that still read open (transcript-rewrite-detected,
|
|
385
|
+
* no-new-open-since-resolution) reach a caller which keeps going on the raw
|
|
386
|
+
* `isCloseGateOpen` boolean (`runMarkSessionClosed`), and that caller needs
|
|
387
|
+
* the same residual named. See the field's own doc comment in
|
|
388
|
+
* hypo-shared.mjs for why that reading is accepted rather than closed on.
|
|
389
|
+
* It states WHAT happened and nothing about undoing it: the undo differs per
|
|
390
|
+
* entry point, so a caller pairs this with `hostTagWarningWithUndo` below
|
|
391
|
+
* and surfaces the pair exactly once per run.
|
|
386
392
|
*/
|
|
387
393
|
export function closeGateStatus({ transcriptPath, hypoDir, sessionId }) {
|
|
388
|
-
const { open, openedAtIndex } = walkCloseGate(
|
|
394
|
+
const { open, openedAtIndex, retractedByUnknownTagName, neutralizedHostTagName } = walkCloseGate(
|
|
395
|
+
transcriptPath ?? null,
|
|
396
|
+
);
|
|
389
397
|
if (!open) {
|
|
390
398
|
return {
|
|
391
399
|
ok: false,
|
|
392
400
|
open: false,
|
|
393
401
|
reason:
|
|
394
402
|
'no-open: this session carries no close signal in its transcript yet — ' +
|
|
395
|
-
'ask the user whether they actually want to close before treating this as one.'
|
|
403
|
+
'ask the user whether they actually want to close before treating this as one.' +
|
|
404
|
+
// Names the retraction's shape when walkCloseGate found one. A tag
|
|
405
|
+
// shaped queue item only ever retracts here when it is NOT one of
|
|
406
|
+
// HOST_TAG_NAMES (a known host tag reads as neutral instead, see the
|
|
407
|
+
// enqueue-branch reversion note in hypo-shared.mjs), so this is
|
|
408
|
+
// always the "which tag to add" diagnostic, never a known tag
|
|
409
|
+
// retracting a close that was already judged expected.
|
|
410
|
+
(retractedByUnknownTagName
|
|
411
|
+
? ` A queued item shaped like a host tag ("<${retractedByUnknownTagName}...>") retracted ` +
|
|
412
|
+
'the last close BECAUSE that tag is not in HOST_TAG_NAMES (hooks/hypo-shared.mjs). ' +
|
|
413
|
+
'That is fail-closed and applies even when the item itself reads like a close phrase: ' +
|
|
414
|
+
'an unregistered tag cannot be attributed to the host, so it counts as the user taking ' +
|
|
415
|
+
'the close back. If it is a genuine host notification rather than something the user ' +
|
|
416
|
+
'typed, add it to HOST_TAG_NAMES; until then the user has to confirm the close again.'
|
|
417
|
+
: ''),
|
|
396
418
|
};
|
|
397
419
|
}
|
|
398
420
|
|
|
421
|
+
// Computed once here, past the `!open` return above, so every return that
|
|
422
|
+
// still reads the gate as open can carry it without repeating the message.
|
|
423
|
+
// Not itself a reason to refuse anything: walkCloseGate already chose to
|
|
424
|
+
// read this shape as neutral, not a retraction (see its own doc comment).
|
|
425
|
+
// This is the one-time surfacing of the residual that reading accepts, and
|
|
426
|
+
// it stops at WHAT happened; see hostTagWarningWithUndo below for why the
|
|
427
|
+
// undo instructions are not baked in here.
|
|
428
|
+
const hostTagWarning = neutralizedHostTagName
|
|
429
|
+
? `this close was granted while a queued item shaped like a known host tag ` +
|
|
430
|
+
`("<${neutralizedHostTagName}...>") stayed neutral instead of retracting it. If a person ` +
|
|
431
|
+
'typed or pasted that tag verbatim, rather than the host sending it, this close may not ' +
|
|
432
|
+
'reflect a decision the user actually made.'
|
|
433
|
+
: null;
|
|
434
|
+
|
|
399
435
|
let rawTranscript = null;
|
|
400
436
|
try {
|
|
401
437
|
rawTranscript = transcriptPath ? readFileSync(transcriptPath) : null;
|
|
@@ -407,7 +443,7 @@ export function closeGateStatus({ transcriptPath, hypoDir, sessionId }) {
|
|
|
407
443
|
if (closedAtIndex === null) {
|
|
408
444
|
// NO_CONSTRAINT: no valid resolution record exists for this session, so
|
|
409
445
|
// the open found above is unconstrained.
|
|
410
|
-
return { ok: true, open: true, reason: null };
|
|
446
|
+
return { ok: true, open: true, reason: null, ...(hostTagWarning ? { hostTagWarning } : {}) };
|
|
411
447
|
}
|
|
412
448
|
if (prefixMatches === false) {
|
|
413
449
|
// Checked ahead of the index comparison on purpose, even though
|
|
@@ -421,10 +457,11 @@ export function closeGateStatus({ transcriptPath, hypoDir, sessionId }) {
|
|
|
421
457
|
'transcript-rewrite-detected: the recorded resolution no longer matches this ' +
|
|
422
458
|
"transcript's history — treat this session's prior resolution as untrustworthy " +
|
|
423
459
|
'and confirm the close with the user again.',
|
|
460
|
+
...(hostTagWarning ? { hostTagWarning } : {}),
|
|
424
461
|
};
|
|
425
462
|
}
|
|
426
463
|
if (openedAtIndex >= closedAtIndex) {
|
|
427
|
-
return { ok: true, open: true, reason: null };
|
|
464
|
+
return { ok: true, open: true, reason: null, ...(hostTagWarning ? { hostTagWarning } : {}) };
|
|
428
465
|
}
|
|
429
466
|
return {
|
|
430
467
|
ok: false,
|
|
@@ -432,5 +469,83 @@ export function closeGateStatus({ transcriptPath, hypoDir, sessionId }) {
|
|
|
432
469
|
reason:
|
|
433
470
|
'no-new-open-since-resolution: this session already resolved its last close signal — ' +
|
|
434
471
|
'a fresh close phrase from the user is needed before this can pass again.',
|
|
472
|
+
...(hostTagWarning ? { hostTagWarning } : {}),
|
|
435
473
|
};
|
|
436
474
|
}
|
|
475
|
+
|
|
476
|
+
// The undo half of a `hostTagWarning`, which closeGateStatus deliberately
|
|
477
|
+
// leaves out: what a run can actually take back differs by entry point, and a
|
|
478
|
+
// message naming a commit the run never made sends the reader after nothing.
|
|
479
|
+
// `kind` says what THIS run did:
|
|
480
|
+
// 'commit-and-marker' a close apply that committed and landed its marker
|
|
481
|
+
// 'commit-only' a close apply that committed, marker never landed
|
|
482
|
+
// 'marker-only' --mark-session-closed: a marker, no commit at all
|
|
483
|
+
// 'uncommitted-writes' bytes on disk, no commit and no marker
|
|
484
|
+
// A null or unrecognized `kind` means the run left nothing to undo, and so
|
|
485
|
+
// nothing to warn about either: the answer is null and the caller stays
|
|
486
|
+
// quiet. `commitSha` is the commit hash for the two committing kinds
|
|
487
|
+
// (commitWikiChanges returns it as `sha`); see the comment inside for what
|
|
488
|
+
// each of its three states produces. The marker path is derived from
|
|
489
|
+
// sessionClosedMarkerPath rather than
|
|
490
|
+
// written out here, so it cannot drift from the file the writer actually
|
|
491
|
+
// creates; an earlier version of this text hardcoded a `.cache/
|
|
492
|
+
// session-closed-<id>.json` path that no writer has ever produced (the
|
|
493
|
+
// `.json` under `.cache/close-gate/` is this file's own resolution record, a
|
|
494
|
+
// different file), and the model relaying it sent users to delete something
|
|
495
|
+
// that was not there.
|
|
496
|
+
//
|
|
497
|
+
// A close now also files a receipt (`hooks/close-receipt.mjs`) alongside the
|
|
498
|
+
// compat marker, and deleting the marker alone would leave that receipt
|
|
499
|
+
// standing: readReceiptStrict still reads it as valid, so a new Stop would
|
|
500
|
+
// keep treating the session as closed even after the marker it was told to
|
|
501
|
+
// delete is gone. Undo has to name both files or it does not actually undo
|
|
502
|
+
// anything a receipt-aware reader honors. `commit-and-marker` and
|
|
503
|
+
// `marker-only` are the two kinds whose write path always lands the receipt
|
|
504
|
+
// BEFORE the marker, so by the time either kind's text
|
|
505
|
+
// is composed the receipt is guaranteed to exist and the message just names
|
|
506
|
+
// it. `commit-only` is the one kind where the receipt write and the marker
|
|
507
|
+
// write can fail independently, so its wording checks the receipt path on
|
|
508
|
+
// disk rather than assume either way.
|
|
509
|
+
export function hostTagWarningWithUndo(warning, kind, hypoDir, sessionId, commitSha) {
|
|
510
|
+
if (!warning || !kind) return null;
|
|
511
|
+
const marker = sessionClosedMarkerPath(hypoDir, sessionId);
|
|
512
|
+
const receipt = receiptPath(hypoDir, sessionId);
|
|
513
|
+
// MAJOR fix: "git revert" with no target was an instruction a reader could
|
|
514
|
+
// only carry out by guessing, and the obvious guess (HEAD) is wrong the
|
|
515
|
+
// moment a concurrent session on the same vault commits after this one:
|
|
516
|
+
// reverting HEAD then undoes that session's work instead. `commitSha` is
|
|
517
|
+
// commitWikiChanges's own `sha` for the commit THIS close made, so the
|
|
518
|
+
// instruction names it. Three states, deliberately distinguished:
|
|
519
|
+
// a string that commit exists; name it in a runnable command
|
|
520
|
+
// null the commit was a no-op (`scoped: 0`), nothing to revert
|
|
521
|
+
// undefined the caller did not say, so say what is known and no more,
|
|
522
|
+
// rather than inventing a target
|
|
523
|
+
const revert =
|
|
524
|
+
typeof commitSha === 'string' && commitSha
|
|
525
|
+
? `revert the commit this close just made (\`git -C ${hypoDir} revert ${commitSha}\`)`
|
|
526
|
+
: commitSha === null
|
|
527
|
+
? 'nothing needs reverting (this close changed no tracked file, so git created no commit)'
|
|
528
|
+
: `revert the commit this close just made in the wiki repo (find it with \`git -C ${hypoDir} log -1\` and check it is this close's own commit before reverting: on a shared vault another session may have committed after it)`;
|
|
529
|
+
// Named only when there is a valid receipt path to name at all (a bad
|
|
530
|
+
// session id has no receipt directory to file one under, same edge case
|
|
531
|
+
// `receiptPath` itself refuses). `both` reads naturally whether or not
|
|
532
|
+
// that path is reachable, so the two kinds below stay correct either way.
|
|
533
|
+
const both = receipt ? `${marker} and ${receipt}` : marker;
|
|
534
|
+
const undo = {
|
|
535
|
+
'commit-and-marker':
|
|
536
|
+
`To undo it: ${revert}, delete ` + `${both}, and ask the user whether they meant to close.`,
|
|
537
|
+
'commit-only':
|
|
538
|
+
`To undo it: ${revert} and ask the user whether they meant to close. No marker was ` +
|
|
539
|
+
'written, so there is none to delete.' +
|
|
540
|
+
(receipt && existsSync(receipt)
|
|
541
|
+
? ` A close receipt landed at ${receipt} and should be deleted too.`
|
|
542
|
+
: ''),
|
|
543
|
+
'marker-only':
|
|
544
|
+
`To undo it: delete ${both} and ask the user whether they meant to close. This run wrote ` +
|
|
545
|
+
'no commit, so there is nothing to revert.',
|
|
546
|
+
'uncommitted-writes':
|
|
547
|
+
'Nothing to revert: this run made no commit and wrote no marker. The files it did write are ' +
|
|
548
|
+
'still uncommitted in the wiki working tree, so check git status there before closing again.',
|
|
549
|
+
}[kind];
|
|
550
|
+
return undo ? `${warning} ${undo}` : null;
|
|
551
|
+
}
|
package/hooks/close-journal.mjs
CHANGED
|
@@ -28,22 +28,34 @@
|
|
|
28
28
|
// NARROW what a retry is allowed to restage, never widen it — the same
|
|
29
29
|
// direction close-gate-store's NO_CONSTRAINT keeps for its own file.
|
|
30
30
|
|
|
31
|
-
import { existsSync,
|
|
32
|
-
import {
|
|
31
|
+
import { existsSync, readFileSync, rmSync } from 'node:fs';
|
|
32
|
+
import { join } from 'node:path';
|
|
33
|
+
import { atomicWrite } from './atomic-write.mjs';
|
|
34
|
+
import { isValidSessionId } from './proposal-store.mjs';
|
|
35
|
+
import { withFileLock } from './hypo-shared.mjs';
|
|
33
36
|
|
|
34
|
-
/**
|
|
37
|
+
/**
|
|
38
|
+
* `<hypoDir>/.cache/close-journal/<sessionId>.json`, or null when the id is not
|
|
39
|
+
* a shape that is safe to use as a filename.
|
|
40
|
+
*
|
|
41
|
+
* The check lives HERE, at the sink, not only at the CLI entry points that
|
|
42
|
+
* happen to call this today. Every entry point did validate, and the hole
|
|
43
|
+
* opened anyway the moment a new caller (reconcile) fed this an id read back
|
|
44
|
+
* out of `.cache/proposals/applied.log` rather than typed by a person: a
|
|
45
|
+
* `closeSessionId` of `../../../../../Users/<you>/.claude/settings` resolves
|
|
46
|
+
* clean out of the vault, and the caller then atomically overwrites whatever
|
|
47
|
+
* is there. Guarding each caller is what produced that gap; guarding the one
|
|
48
|
+
* place the string becomes a path is what closes it for callers not written yet.
|
|
49
|
+
*
|
|
50
|
+
* Returns null rather than throwing so the read path can keep treating an
|
|
51
|
+
* unusable journal as "nothing recorded", which is the direction it already
|
|
52
|
+
* fails in for a corrupt or absent file.
|
|
53
|
+
*/
|
|
35
54
|
export function closeJournalPath(hypoDir, sessionId) {
|
|
55
|
+
if (!isValidSessionId(sessionId)) return null;
|
|
36
56
|
return join(hypoDir, '.cache', 'close-journal', `${sessionId}.json`);
|
|
37
57
|
}
|
|
38
58
|
|
|
39
|
-
/** Atomic overwrite via tmp+rename, mirroring close-gate-store's atomicWrite. */
|
|
40
|
-
function atomicWrite(path, content) {
|
|
41
|
-
mkdirSync(dirname(path), { recursive: true });
|
|
42
|
-
const tmp = `${path}.${process.pid}.${Math.random().toString(36).slice(2, 10)}.tmp`;
|
|
43
|
-
writeFileSync(tmp, content);
|
|
44
|
-
renameSync(tmp, path);
|
|
45
|
-
}
|
|
46
|
-
|
|
47
59
|
/**
|
|
48
60
|
* Read this session's journal. Absent, corrupt, or wrong-shaped reads as
|
|
49
61
|
* `{}` — never partially trusted. A caller that widened a retry off a
|
|
@@ -56,7 +68,9 @@ function atomicWrite(path, content) {
|
|
|
56
68
|
export function readJournal(hypoDir, sessionId) {
|
|
57
69
|
if (!sessionId) return {};
|
|
58
70
|
const path = closeJournalPath(hypoDir, sessionId);
|
|
59
|
-
|
|
71
|
+
// An id that cannot be a filename has no journal, which reads the same as an
|
|
72
|
+
// absent one. Same direction this function already fails in.
|
|
73
|
+
if (path === null || !existsSync(path)) return {};
|
|
60
74
|
try {
|
|
61
75
|
const parsed = JSON.parse(readFileSync(path, 'utf-8'));
|
|
62
76
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
|
|
@@ -92,20 +106,119 @@ export function readJournal(hypoDir, sessionId) {
|
|
|
92
106
|
* `appliedPaths` / `commitWikiChanges`
|
|
93
107
|
* @param {string} hash sha256 hex of the bytes just written (hashContent)
|
|
94
108
|
*/
|
|
109
|
+
/**
|
|
110
|
+
* The read half of a read-modify-write, for callers that already hold the
|
|
111
|
+
* journal's lock. Public readJournal takes no lock: atomicWrite's rename means
|
|
112
|
+
* a reader sees one whole version or another, never a torn one. Re-entering the
|
|
113
|
+
* lock from inside it would deadlock the writers below.
|
|
114
|
+
*/
|
|
115
|
+
function readJournalAt(path) {
|
|
116
|
+
if (!existsSync(path)) return {};
|
|
117
|
+
try {
|
|
118
|
+
const parsed = JSON.parse(readFileSync(path, 'utf-8'));
|
|
119
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
|
|
120
|
+
if (parsed.v !== 1 || !parsed.paths || typeof parsed.paths !== 'object') return {};
|
|
121
|
+
const out = {};
|
|
122
|
+
for (const [k, v] of Object.entries(parsed.paths)) {
|
|
123
|
+
if (typeof k === 'string' && k && typeof v === 'string' && v) out[k] = v;
|
|
124
|
+
}
|
|
125
|
+
return out;
|
|
126
|
+
} catch {
|
|
127
|
+
return {};
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
95
131
|
export function recordJournalEntry(hypoDir, sessionId, relPath, hash) {
|
|
96
132
|
if (!sessionId) return;
|
|
133
|
+
const path = closeJournalPath(hypoDir, sessionId);
|
|
134
|
+
// Unusable id: nothing to record against, and nowhere safe to put it. This
|
|
135
|
+
// one is best-effort by contract, so it declines the same way it declines a
|
|
136
|
+
// failed write.
|
|
137
|
+
if (path === null) return;
|
|
97
138
|
try {
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
)
|
|
139
|
+
// Read-modify-write under the journal's own lock. atomicWrite already stops
|
|
140
|
+
// a reader seeing a torn file; what it cannot stop is two writers each
|
|
141
|
+
// reading the same `paths` and the second saving a copy that never had the
|
|
142
|
+
// first one's entry. Every mutator here takes the same lock, so they
|
|
143
|
+
// serialise against each other and against clearJournal.
|
|
144
|
+
withFileLock(path, () => {
|
|
145
|
+
const paths = readJournalAt(path);
|
|
146
|
+
paths[relPath] = hash;
|
|
147
|
+
atomicWrite(path, JSON.stringify({ v: 1, sessionId: String(sessionId), paths }, null, 2));
|
|
148
|
+
});
|
|
104
149
|
} catch {
|
|
105
150
|
// best-effort — see doc comment above
|
|
106
151
|
}
|
|
107
152
|
}
|
|
108
153
|
|
|
154
|
+
/**
|
|
155
|
+
* Record ONE close handoff receipt: a target that a DIFFERENT session's
|
|
156
|
+
* approved `proposal resolve` just wrote, credited back to the close whose
|
|
157
|
+
* park produced it.
|
|
158
|
+
*
|
|
159
|
+
* Same on-disk shape as recordJournalEntry (readJournal reads either one the
|
|
160
|
+
* same way), but not best-effort. recordJournalEntry swallows a write
|
|
161
|
+
* failure on purpose: the bytes it describes were ALSO written by this same
|
|
162
|
+
* close's own overwrite step moments earlier, so a lost entry only costs a
|
|
163
|
+
* future retry its fast restage and falls back to the ordinary gate block,
|
|
164
|
+
* the safe direction to fail toward. A handoff receipt has no such fallback.
|
|
165
|
+
* The bytes it describes were written by a HUMAN approval in a possibly
|
|
166
|
+
* unrelated session, and the proposal artifact that once pointed at them is
|
|
167
|
+
* deleted the moment that write succeeds (the T7 CLI's writeApprovedProposal,
|
|
168
|
+
* step 12), so if this call fails silently, the owning close's retry can
|
|
169
|
+
* never find those bytes again by any means. The T7 CLI must see the failure
|
|
170
|
+
* and hold the artifact and audit entry instead of deleting them
|
|
171
|
+
* (`close-receipt-failed`), or the failure disappears along with the
|
|
172
|
+
* artifact.
|
|
173
|
+
*
|
|
174
|
+
* @param {string} hypoDir
|
|
175
|
+
* @param {string} closeSessionId the ORIGINATING close's session id, not the
|
|
176
|
+
* session that approved the resolve. Legacy challenges/artifacts that
|
|
177
|
+
* predate this field are read as this being the SAME session that resolved
|
|
178
|
+
* them (see the T7 CLI), so this parameter is never called with
|
|
179
|
+
* an empty value from that path.
|
|
180
|
+
* @param {string} relPath vault-relative path, exactly as it appears in the
|
|
181
|
+
* proposal artifact's `target`
|
|
182
|
+
* @param {string} hash sha256 hex of the bytes just written, the SAME
|
|
183
|
+
* hashContent() crystallize's own overwrite step hashes with (base-store.mjs),
|
|
184
|
+
* so the retry's `journalHash === hashContent(disk)` comparison in
|
|
185
|
+
* crystallize-close-apply.mjs trusts a receipt exactly as much as its own
|
|
186
|
+
* same-session entries.
|
|
187
|
+
* @returns {{ok: true} | {ok: false, error: string}}
|
|
188
|
+
*/
|
|
189
|
+
export function recordHandoffReceipt(hypoDir, closeSessionId, relPath, hash) {
|
|
190
|
+
if (!closeSessionId) return { ok: false, error: 'no closeSessionId to journal against' };
|
|
191
|
+
const path = closeJournalPath(hypoDir, closeSessionId);
|
|
192
|
+
// Strict by contract, so an unusable id is a reported failure rather than a
|
|
193
|
+
// quiet decline. This is the call reconcile makes with an id it read out of
|
|
194
|
+
// the audit log, so it is the one that must not treat a hostile string as
|
|
195
|
+
// merely unlucky.
|
|
196
|
+
if (path === null) {
|
|
197
|
+
return {
|
|
198
|
+
ok: false,
|
|
199
|
+
error: `closeSessionId is not a usable session id: ${JSON.stringify(closeSessionId)}`,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
try {
|
|
203
|
+
// Same lock as every other mutator. Without it, two resolves handing off to
|
|
204
|
+
// the same close, or a resolve racing that close's own clearJournal, could
|
|
205
|
+
// each return ok:true while one receipt silently vanished. The artifact is
|
|
206
|
+
// deleted the moment this returns ok, and reconcile only ever considers
|
|
207
|
+
// artifacts, so a lost receipt would have no way back.
|
|
208
|
+
withFileLock(path, () => {
|
|
209
|
+
const paths = readJournalAt(path);
|
|
210
|
+
paths[relPath] = hash;
|
|
211
|
+
atomicWrite(
|
|
212
|
+
path,
|
|
213
|
+
JSON.stringify({ v: 1, sessionId: String(closeSessionId), paths }, null, 2),
|
|
214
|
+
);
|
|
215
|
+
});
|
|
216
|
+
return { ok: true };
|
|
217
|
+
} catch (e) {
|
|
218
|
+
return { ok: false, error: e && e.message ? e.message : String(e) };
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
109
222
|
/**
|
|
110
223
|
* Clear this session's journal once its close has actually committed. A
|
|
111
224
|
* journal entry that outlives a successful commit would keep offering to
|
|
@@ -120,8 +233,17 @@ export function recordJournalEntry(hypoDir, sessionId, relPath, hash) {
|
|
|
120
233
|
*/
|
|
121
234
|
export function clearJournal(hypoDir, sessionId) {
|
|
122
235
|
if (!sessionId) return;
|
|
236
|
+
const path = closeJournalPath(hypoDir, sessionId);
|
|
237
|
+
// An id that never produced a path never produced a file to remove, and an
|
|
238
|
+
// rmSync on a traversed path would be the destructive half of the same hole.
|
|
239
|
+
if (path === null) return;
|
|
123
240
|
try {
|
|
124
|
-
|
|
241
|
+
// Under the same lock: a clear landing between another writer's read and
|
|
242
|
+
// its save would be undone by that save, resurrecting a journal the close
|
|
243
|
+
// had already consumed.
|
|
244
|
+
withFileLock(path, () => {
|
|
245
|
+
rmSync(path, { force: true });
|
|
246
|
+
});
|
|
125
247
|
} catch {
|
|
126
248
|
// best-effort — see doc comment above
|
|
127
249
|
}
|