hypomnema 1.8.2 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +141 -51
- package/README.ko.md +18 -17
- package/README.md +16 -15
- package/commands/crystallize.md +89 -24
- package/commands/doctor.md +3 -1
- package/docs/ARCHITECTURE.md +45 -14
- package/docs/CONTRIBUTING.md +26 -8
- 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 +250 -0
- package/hooks/close-receipt.mjs +559 -0
- package/hooks/hooks.json +3 -38
- 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 +566 -20
- package/hooks/hypo-shared.mjs +2250 -206
- package/hooks/hypo-stop.mjs +207 -0
- package/hooks/proposal-store.mjs +201 -35
- package/hooks/shared.json +15 -0
- package/hooks/version-check.mjs +9 -7
- package/package.json +3 -1
- package/scripts/crystallize.mjs +3 -4
- package/scripts/doctor.mjs +549 -123
- package/scripts/init.mjs +86 -109
- package/scripts/lib/core-hooks.mjs +48 -22
- package/scripts/lib/crystallize-close-apply.mjs +2397 -188
- 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/git-hooks-dir.mjs +427 -52
- package/scripts/lib/hook-inventory.mjs +180 -0
- package/scripts/lib/page-usage.mjs +1 -1
- package/scripts/lib/pkg-provenance.mjs +18 -1
- package/scripts/lib/plugin-detect.mjs +43 -9
- package/scripts/lib/project-create.mjs +40 -44
- package/scripts/lib/schema-vocab.mjs +22 -11
- package/scripts/lib/template-schema-version.mjs +48 -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 +144 -66
- package/scripts/upgrade.mjs +523 -234
- 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
package/scripts/proposal.mjs
CHANGED
|
@@ -55,6 +55,7 @@ import { join, dirname, resolve, isAbsolute, sep } from 'node:path';
|
|
|
55
55
|
import { randomBytes } from 'node:crypto';
|
|
56
56
|
import { pathToFileURL } from 'node:url';
|
|
57
57
|
import { resolveHypoRoot, expandHome } from './lib/hypo-root.mjs';
|
|
58
|
+
import { DAY_MS } from './lib/time.mjs';
|
|
58
59
|
import {
|
|
59
60
|
listProposals,
|
|
60
61
|
readProposal,
|
|
@@ -72,6 +73,14 @@ import {
|
|
|
72
73
|
hasTypedUserApproval,
|
|
73
74
|
resolveTranscriptBySessionId,
|
|
74
75
|
} from '../hooks/hypo-shared.mjs';
|
|
76
|
+
// hashContent, not hashProposalContent: the close handoff receipt below has to
|
|
77
|
+
// agree with crystallize's own retry check (`journalHash === hashContent(disk)`
|
|
78
|
+
// in scripts/lib/crystallize-close-apply.mjs), so it is hashed with the exact
|
|
79
|
+
// function that check uses, not this module's own hashProposalContent (same
|
|
80
|
+
// algorithm, different function identity; the spec calls for reusing the one
|
|
81
|
+
// the retry actually compares against, not a second implementation of it).
|
|
82
|
+
import { hashContent } from '../hooks/base-store.mjs';
|
|
83
|
+
import { recordHandoffReceipt } from '../hooks/close-journal.mjs';
|
|
75
84
|
|
|
76
85
|
// ── target-path hardening ─────────────────────────────────────────────────────
|
|
77
86
|
|
|
@@ -168,8 +177,14 @@ export function resolveTargetPath(hypoDir, target) {
|
|
|
168
177
|
*/
|
|
169
178
|
function atomicWrite(path, content) {
|
|
170
179
|
const tmp = `${path}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`;
|
|
171
|
-
|
|
180
|
+
// Write inside the try for the same reason the rename is: a write that fails
|
|
181
|
+
// after creating the file leaves the temp behind for good. Deliberately NOT
|
|
182
|
+
// delegated to the shared hooks/atomic-write.mjs — this one opens with `wx`
|
|
183
|
+
// and names the temp from crypto randomness, which is a stronger defense
|
|
184
|
+
// against a symlink planted at a guessable path than the shared writer has.
|
|
185
|
+
// Replacing it would trade a leak fix for weaker containment.
|
|
172
186
|
try {
|
|
187
|
+
writeFileSync(tmp, content, { flag: 'wx' });
|
|
173
188
|
renameSync(tmp, path);
|
|
174
189
|
} catch (e) {
|
|
175
190
|
try {
|
|
@@ -455,13 +470,18 @@ export async function applyProposal({ hypoDir, id }, { isTTY, prompt, stdout, st
|
|
|
455
470
|
* only authorization sites.
|
|
456
471
|
*
|
|
457
472
|
* @param {{hypoDir: string, id: string, proposal: object, full: string,
|
|
458
|
-
* displayedHash: string|null}} sel
|
|
459
|
-
*
|
|
473
|
+
* displayedHash: string|null, closeSessionId?: string|null}} sel
|
|
474
|
+
* `displayedHash` is the hash of the bytes whose diff the approver saw
|
|
475
|
+
* (null when the target was absent). `closeSessionId` is the ORIGINATING
|
|
476
|
+
* close's session id, present only on the transcript-approved batch path
|
|
477
|
+
* (resolveProposals passes challenge.closeSessionId); the TTY `apply <id>`
|
|
478
|
+
* path has no such concept and passes none, which is what keeps the
|
|
479
|
+
* receipt step below a no-op for that channel.
|
|
460
480
|
* @param {{approval: {via: 'tty'|'transcript', nonce: string|null},
|
|
461
481
|
* warned?: boolean, stdout?: object, stderr?: object, now?: () => string}} io
|
|
462
482
|
*/
|
|
463
483
|
export function writeApprovedProposal(
|
|
464
|
-
{ hypoDir, id, proposal, full, displayedHash },
|
|
484
|
+
{ hypoDir, id, proposal, full, displayedHash, closeSessionId },
|
|
465
485
|
{ approval, warned, stdout, stderr, now } = {},
|
|
466
486
|
) {
|
|
467
487
|
const out = stdout ?? process.stdout;
|
|
@@ -526,13 +546,24 @@ export function writeApprovedProposal(
|
|
|
526
546
|
// (11) audit log: FATAL on failure (it is the apply audit contract). Ordered
|
|
527
547
|
// write→log→delete: the log never lies (recorded only after a real write), and a
|
|
528
548
|
// log failure leaves the proposal alive so a re-apply self-heals the record.
|
|
549
|
+
const appliedHash = hashContent(proposal.proposedContent);
|
|
529
550
|
try {
|
|
530
551
|
appendApplyLog(hypoDir, {
|
|
531
552
|
id,
|
|
532
553
|
target: proposal.target,
|
|
533
554
|
currentHash: displayedHash,
|
|
555
|
+
// The hash of the bytes THIS write just put down: reconcileProposals'
|
|
556
|
+
// only evidence, once the artifact and page are the sole other witnesses
|
|
557
|
+
// left (see reconcileProposals below), for proving a later disk read is
|
|
558
|
+
// still the same write this entry describes.
|
|
559
|
+
appliedHash,
|
|
534
560
|
appliedAt: clock(),
|
|
535
561
|
sessionId: proposal.sessionId ?? null,
|
|
562
|
+
// The close this write hands its bytes back to, so a reconcile run (or a
|
|
563
|
+
// human reading the log) can tell which close's journal should have
|
|
564
|
+
// received a receipt for this entry. `null` on the TTY channel, which has
|
|
565
|
+
// no close-session concept at all.
|
|
566
|
+
closeSessionId: closeSessionId ?? null,
|
|
536
567
|
device: proposal.device ?? null,
|
|
537
568
|
// Which authority approved this write, and (for the transcript channel) the
|
|
538
569
|
// one-time nonce the user typed. The audit trail is the only place an
|
|
@@ -550,6 +581,31 @@ export function writeApprovedProposal(
|
|
|
550
581
|
return { ok: false, code: 1, reason: 'log-failed', applied: true };
|
|
551
582
|
}
|
|
552
583
|
|
|
584
|
+
// (11.5) close handoff receipt: credit this write back to the ORIGINATING
|
|
585
|
+
// close's journal, not the approving session's. Only the transcript-approved
|
|
586
|
+
// batch path supplies `closeSessionId` (the TTY `apply <id>` path passes
|
|
587
|
+
// none, so this step is a no-op there: same write, same audit entry, no new
|
|
588
|
+
// failure mode for that channel). NOT best-effort: unlike
|
|
589
|
+
// recordJournalEntry's own same-session, same-write record, a lost receipt
|
|
590
|
+
// here has no fallback. The proposal artifact that is the only other
|
|
591
|
+
// pointer to this write is deleted in step (12) below the moment this
|
|
592
|
+
// succeeds, so a swallowed failure would make the bytes unfindable by the
|
|
593
|
+
// owning close's retry forever.
|
|
594
|
+
if (closeSessionId) {
|
|
595
|
+
const receipt = recordHandoffReceipt(hypoDir, closeSessionId, proposal.target, appliedHash);
|
|
596
|
+
if (!receipt.ok) {
|
|
597
|
+
err.write(
|
|
598
|
+
`✗ applied to disk and logged, but FAILED to record the close handoff receipt ` +
|
|
599
|
+
`(${sanitizeForDisplay(receipt.error, { allowNewlines: false })}).\n` +
|
|
600
|
+
` ${shownTarget} now holds the approved bytes, but close ${closeSessionId} cannot\n` +
|
|
601
|
+
` see them yet. Do NOT re-run that close: it would only re-park the same\n` +
|
|
602
|
+
` conflict. Fix whatever is blocking .cache/close-journal/ and run\n` +
|
|
603
|
+
` \`hypomnema proposal reconcile\` to recover the receipt from the audit log.\n`,
|
|
604
|
+
);
|
|
605
|
+
return { ok: false, code: 1, reason: 'close-receipt-failed', applied: true };
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
553
609
|
// (12) remove the now-applied artifact: a leftover artifact is a non-zero exit
|
|
554
610
|
if (!deleteProposal(hypoDir, id)) {
|
|
555
611
|
err.write(`⚠️ applied and logged, but the proposal artifact was not removed: ${id}\n`);
|
|
@@ -602,10 +658,16 @@ export function writeApprovedProposal(
|
|
|
602
658
|
* An unreadable target refuses the whole batch rather than mint a challenge for a
|
|
603
659
|
* diff nobody can be shown.
|
|
604
660
|
*
|
|
605
|
-
*
|
|
661
|
+
* `closeSessionId` names the close this approval will resume, which the caller
|
|
662
|
+
* only needs to pass when it differs from `sessionId`: a later session (a
|
|
663
|
+
* fresh one the original close's session is gone from) finishing a park an
|
|
664
|
+
* earlier session left behind. Defaults to `sessionId`, the common case where
|
|
665
|
+
* the same session parks, challenges, and resolves its own close.
|
|
666
|
+
*
|
|
667
|
+
* @param {{hypoDir: string, sessionId: string, ids: string[], closeSessionId?: string}} sel
|
|
606
668
|
*/
|
|
607
669
|
export function challengeProposals(
|
|
608
|
-
{ hypoDir, sessionId, ids },
|
|
670
|
+
{ hypoDir, sessionId, ids, closeSessionId },
|
|
609
671
|
{ stdout, stderr, now, nonce } = {},
|
|
610
672
|
) {
|
|
611
673
|
const out = stdout ?? process.stdout;
|
|
@@ -616,6 +678,11 @@ export function challengeProposals(
|
|
|
616
678
|
err.write(`✗ invalid --session-id\n`);
|
|
617
679
|
return { ok: false, code: 2, reason: 'invalid-session-id' };
|
|
618
680
|
}
|
|
681
|
+
const resumesClose = closeSessionId != null ? String(closeSessionId) : sessionId;
|
|
682
|
+
if (!isValidSessionId(resumesClose)) {
|
|
683
|
+
err.write(`✗ invalid --close-session-id\n`);
|
|
684
|
+
return { ok: false, code: 2, reason: 'invalid-close-session-id' };
|
|
685
|
+
}
|
|
619
686
|
if (!Array.isArray(ids) || ids.length === 0) {
|
|
620
687
|
err.write(`✗ challenge needs --ids=<id,...> (the proposal ids the close reported)\n`);
|
|
621
688
|
return { ok: false, code: 2, reason: 'no-ids' };
|
|
@@ -632,6 +699,24 @@ export function challengeProposals(
|
|
|
632
699
|
err.write(`✗ no such proposal: ${id}\n`);
|
|
633
700
|
return { ok: false, code: 2, reason: 'not-found' };
|
|
634
701
|
}
|
|
702
|
+
// The close being resumed must be the close that parked this. Without this
|
|
703
|
+
// check `--close-session-id` is just a label the caller picks, and the
|
|
704
|
+
// receipt it produces credits one close's approved bytes to a different
|
|
705
|
+
// close, which then commits them as its own work. writeProposal always
|
|
706
|
+
// stamps the parking session onto the artifact, so the owner is knowable
|
|
707
|
+
// here; an artifact with no owner (a legacy one, written before that field)
|
|
708
|
+
// is only allowed to resume itself, which is exactly what it did before
|
|
709
|
+
// --close-session-id existed.
|
|
710
|
+
const owner = typeof proposal.sessionId === 'string' ? proposal.sessionId : null;
|
|
711
|
+
const ownerForCompare = owner === null ? sessionId : owner;
|
|
712
|
+
if (resumesClose !== ownerForCompare) {
|
|
713
|
+
err.write(
|
|
714
|
+
`✗ proposal ${id} was parked by close ${owner === null ? '(unrecorded)' : owner}, ` +
|
|
715
|
+
`not ${resumesClose}. Approving it can only resume the close that parked it; ` +
|
|
716
|
+
`pass --close-session-id=${ownerForCompare} or challenge it from that close.\n`,
|
|
717
|
+
);
|
|
718
|
+
return { ok: false, code: 2, reason: 'close-session-not-owner' };
|
|
719
|
+
}
|
|
635
720
|
const full = resolveTargetPath(hypoDir, proposal.target);
|
|
636
721
|
if (!full) {
|
|
637
722
|
const shown = sanitizeForDisplay(proposal.target, { allowNewlines: false });
|
|
@@ -664,7 +749,13 @@ export function challengeProposals(
|
|
|
664
749
|
// crypto-random, never Math.random: an approval token a hook could PREDICT would
|
|
665
750
|
// let it pre-plant the phrase and spend an approval the user never gave.
|
|
666
751
|
const minted = nonce ?? randomBytes(16).toString('hex');
|
|
667
|
-
const record = {
|
|
752
|
+
const record = {
|
|
753
|
+
nonce: minted,
|
|
754
|
+
sessionId,
|
|
755
|
+
closeSessionId: resumesClose,
|
|
756
|
+
mintedAt: clock(),
|
|
757
|
+
items,
|
|
758
|
+
};
|
|
668
759
|
if (!writeChallenge(hypoDir, record)) {
|
|
669
760
|
err.write(
|
|
670
761
|
`✗ failed to store the approval challenge; nothing to approve.\n` +
|
|
@@ -675,7 +766,8 @@ export function challengeProposals(
|
|
|
675
766
|
}
|
|
676
767
|
|
|
677
768
|
out.write(
|
|
678
|
-
`\
|
|
769
|
+
`\nThis approval resumes close ${resumesClose}.\n` +
|
|
770
|
+
`To approve the ${items.length} overwrite(s) above, the USER must type this line in the conversation:\n\n` +
|
|
679
771
|
` ${APPROVAL_PHRASE} ${minted}\n\n` +
|
|
680
772
|
`Then run: hypomnema proposal resolve --session-id=${sessionId}\n`,
|
|
681
773
|
);
|
|
@@ -683,6 +775,7 @@ export function challengeProposals(
|
|
|
683
775
|
ok: true,
|
|
684
776
|
code: 0,
|
|
685
777
|
nonce: minted,
|
|
778
|
+
closeSessionId: resumesClose,
|
|
686
779
|
items: items.map(({ id, target }) => ({ id, target })),
|
|
687
780
|
};
|
|
688
781
|
}
|
|
@@ -837,7 +930,17 @@ export function resolveProposals(
|
|
|
837
930
|
const failed = [];
|
|
838
931
|
for (const { item, proposal, full, displayedHash } of plan) {
|
|
839
932
|
const res = writeApprovedProposal(
|
|
840
|
-
|
|
933
|
+
// readChallenge normalizes closeSessionId to `sessionId` when the record
|
|
934
|
+
// predates the field (legacy = same session), so this is always a valid
|
|
935
|
+
// id here, never undefined.
|
|
936
|
+
{
|
|
937
|
+
hypoDir,
|
|
938
|
+
id: item.id,
|
|
939
|
+
proposal,
|
|
940
|
+
full,
|
|
941
|
+
displayedHash,
|
|
942
|
+
closeSessionId: challenge.closeSessionId,
|
|
943
|
+
},
|
|
841
944
|
{
|
|
842
945
|
approval: { via: 'transcript', nonce: challenge.nonce },
|
|
843
946
|
warned: false,
|
|
@@ -883,6 +986,233 @@ export function resolveProposals(
|
|
|
883
986
|
return { ok: true, code: 0, written, challengeSpent: true };
|
|
884
987
|
}
|
|
885
988
|
|
|
989
|
+
/**
|
|
990
|
+
* Recover a close handoff receipt that `close-receipt-failed` above left
|
|
991
|
+
* undone, without writing to any page.
|
|
992
|
+
*
|
|
993
|
+
* A candidate is a still-pending proposal artifact whose id also has an
|
|
994
|
+
* `applied.log` entry carrying `appliedHash` and `closeSessionId`, the exact
|
|
995
|
+
* pair `writeApprovedProposal` leaves behind on that failure (audit written,
|
|
996
|
+
* artifact kept, per the doc comment there). Nothing else produces this
|
|
997
|
+
* combination: an ordinary successful write deletes the artifact, and an
|
|
998
|
+
* ordinary NEVER-applied artifact has no audit entry naming it at all. Three
|
|
999
|
+
* pieces of evidence must all agree before a receipt is recovered: the
|
|
1000
|
+
* artifact's own `target` must match what the audit entry recorded (defense
|
|
1001
|
+
* against a hand-edited artifact pointing the recovered receipt somewhere
|
|
1002
|
+
* else), and the CURRENT bytes on disk at that target must hash to the exact
|
|
1003
|
+
* `appliedHash` the audit entry recorded (defense against a page that moved
|
|
1004
|
+
* since: reconcile must never certify a receipt for bytes it did not
|
|
1005
|
+
* actually verify are still there). Either mismatch refuses that one
|
|
1006
|
+
* candidate rather than guess; it never writes a page either way.
|
|
1007
|
+
*
|
|
1008
|
+
* @param {{hypoDir: string}} sel
|
|
1009
|
+
*/
|
|
1010
|
+
/**
|
|
1011
|
+
* What each parked artifact IS, judged from evidence alone. Writes nothing.
|
|
1012
|
+
*
|
|
1013
|
+
* Two callers need this answer and they must not disagree: reconcile, which
|
|
1014
|
+
* acts on it, and doctor, which reports it. A `close-receipt-failed` artifact
|
|
1015
|
+
* looks exactly like an ordinary pending proposal from the outside, so a doctor
|
|
1016
|
+
* that counted artifacts told the user to review a write that a human had
|
|
1017
|
+
* already approved and that is already on the page. Deriving the judgment twice
|
|
1018
|
+
* is how the two drift, so it is derived here once.
|
|
1019
|
+
*
|
|
1020
|
+
* `kind` is one of:
|
|
1021
|
+
* pending an ordinary parked proposal awaiting review. No apply yet.
|
|
1022
|
+
* recoverable approved and written, but its close handoff receipt never
|
|
1023
|
+
* landed. `proposal reconcile` can restore it from evidence.
|
|
1024
|
+
* evidence-broken an audit entry claims the write, but the page no longer
|
|
1025
|
+
* hashes to it, or the target will not resolve. Neither
|
|
1026
|
+
* reconcile nor a re-close can fix this without a human.
|
|
1027
|
+
*
|
|
1028
|
+
* @returns {{kind: string, id: string, target: string, closeSessionId?: string,
|
|
1029
|
+
* appliedHash?: string, reason?: string}[]}
|
|
1030
|
+
*/
|
|
1031
|
+
export function classifyProposals(hypoDir) {
|
|
1032
|
+
const logPath = join(proposalsDir(hypoDir), 'applied.log');
|
|
1033
|
+
const latestById = new Map();
|
|
1034
|
+
if (existsSync(logPath)) {
|
|
1035
|
+
try {
|
|
1036
|
+
for (const line of readFileSync(logPath, 'utf-8').split('\n').filter(Boolean)) {
|
|
1037
|
+
try {
|
|
1038
|
+
const entry = JSON.parse(line);
|
|
1039
|
+
if (entry && typeof entry.id === 'string') latestById.set(entry.id, entry);
|
|
1040
|
+
} catch {
|
|
1041
|
+
/* a corrupt audit line is skipped, never fatal */
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
} catch {
|
|
1045
|
+
// Unreadable audit log: every artifact reads as ordinary pending, which is
|
|
1046
|
+
// what they were before this evidence existed. reconcile reports the read
|
|
1047
|
+
// failure itself; doctor must not turn it into a different diagnosis.
|
|
1048
|
+
latestById.clear();
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
const out = [];
|
|
1053
|
+
for (const proposal of listProposals(hypoDir)) {
|
|
1054
|
+
const entry = latestById.get(proposal.id);
|
|
1055
|
+
if (!entry || typeof entry.appliedHash !== 'string' || !entry.closeSessionId) {
|
|
1056
|
+
// No audit entry, or one from a TTY apply which carries no close session:
|
|
1057
|
+
// never handed off, so nothing to recover. An ordinary pending proposal.
|
|
1058
|
+
out.push({ kind: 'pending', id: proposal.id, target: proposal.target });
|
|
1059
|
+
continue;
|
|
1060
|
+
}
|
|
1061
|
+
if (entry.target !== proposal.target) {
|
|
1062
|
+
out.push({ kind: 'pending', id: proposal.id, target: proposal.target });
|
|
1063
|
+
continue;
|
|
1064
|
+
}
|
|
1065
|
+
const full = resolveTargetPath(hypoDir, proposal.target);
|
|
1066
|
+
if (!full) {
|
|
1067
|
+
out.push({
|
|
1068
|
+
kind: 'evidence-broken',
|
|
1069
|
+
id: proposal.id,
|
|
1070
|
+
target: proposal.target,
|
|
1071
|
+
reason: 'unsafe-target',
|
|
1072
|
+
});
|
|
1073
|
+
continue;
|
|
1074
|
+
}
|
|
1075
|
+
const disk = readTarget(full);
|
|
1076
|
+
if (typeof disk !== 'string' || hashContent(disk) !== entry.appliedHash) {
|
|
1077
|
+
out.push({
|
|
1078
|
+
kind: 'evidence-broken',
|
|
1079
|
+
id: proposal.id,
|
|
1080
|
+
target: proposal.target,
|
|
1081
|
+
reason: 'evidence-mismatch',
|
|
1082
|
+
});
|
|
1083
|
+
continue;
|
|
1084
|
+
}
|
|
1085
|
+
out.push({
|
|
1086
|
+
kind: 'recoverable',
|
|
1087
|
+
id: proposal.id,
|
|
1088
|
+
target: proposal.target,
|
|
1089
|
+
closeSessionId: entry.closeSessionId,
|
|
1090
|
+
appliedHash: entry.appliedHash,
|
|
1091
|
+
});
|
|
1092
|
+
}
|
|
1093
|
+
return out;
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
// There is deliberately no auto-cleanup judge here. One was written: it removed a
|
|
1097
|
+
// park whose target already held byte-identical content, on the reasoning that
|
|
1098
|
+
// applying it would change nothing, so nothing could be lost.
|
|
1099
|
+
//
|
|
1100
|
+
// That reasoning is wrong, and the counterexample is a path this file itself
|
|
1101
|
+
// creates. `applyProposal` writes the target, THEN appends the audit log. When the
|
|
1102
|
+
// append fails it keeps the artifact and prints "re-run apply to complete the audit
|
|
1103
|
+
// record" (see its `log-failed` branch). The disk is then exactly: target equals
|
|
1104
|
+
// payload, artifact alive, no entry in applied.log. `classifyProposals` reads that
|
|
1105
|
+
// as 'pending' and a byte-equality judge reads it as redundant, so the cleanup
|
|
1106
|
+
// deletes the very artifact the retry needs. `deleteProposal` is a bare unlink.
|
|
1107
|
+
//
|
|
1108
|
+
// No stronger predicate rescues it. Separating "audit write failed" from "another
|
|
1109
|
+
// session happened to write the same bytes" needs a signal about WHO wrote the
|
|
1110
|
+
// target, and nothing on disk carries one: `createdAt` is the park time, mtime does
|
|
1111
|
+
// not attribute a writer (and is already rejected below for that reason), and the
|
|
1112
|
+
// failed case by definition has no applied.log entry to compare against. Closing
|
|
1113
|
+
// this would take a durable write-intent recorded BEFORE the target write, which is
|
|
1114
|
+
// a lifecycle change, not a better predicate. Until then `list`'s age and activity
|
|
1115
|
+
// columns are the drain, and every removal stays a human's `discard`.
|
|
1116
|
+
|
|
1117
|
+
export function reconcileProposals({ hypoDir }, { stdout, stderr } = {}) {
|
|
1118
|
+
const out = stdout ?? process.stdout;
|
|
1119
|
+
const err = stderr ?? process.stderr;
|
|
1120
|
+
|
|
1121
|
+
const logPath = join(proposalsDir(hypoDir), 'applied.log');
|
|
1122
|
+
let lines = [];
|
|
1123
|
+
if (existsSync(logPath)) {
|
|
1124
|
+
try {
|
|
1125
|
+
lines = readFileSync(logPath, 'utf-8').split('\n').filter(Boolean);
|
|
1126
|
+
} catch (e) {
|
|
1127
|
+
err.write(
|
|
1128
|
+
`✗ could not read the apply audit log: ${sanitizeForDisplay(e.message, { allowNewlines: false })}\n`,
|
|
1129
|
+
);
|
|
1130
|
+
return { ok: false, code: 1, reason: 'audit-unreadable' };
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
// Latest entry per proposal id: applied.log is append-only JSONL, and a
|
|
1134
|
+
// proposal can only be reconciled from the write that is STILL what is on
|
|
1135
|
+
// disk. An earlier entry for the same id (superseded, or from a prior
|
|
1136
|
+
// successful cycle) is not that write. A malformed line is skipped, never
|
|
1137
|
+
// fatal: the audit log is written by this same tool but is still a file on
|
|
1138
|
+
// disk, and one bad line must not stop reconcile from looking at the rest.
|
|
1139
|
+
const latestById = new Map();
|
|
1140
|
+
for (const line of lines) {
|
|
1141
|
+
try {
|
|
1142
|
+
const entry = JSON.parse(line);
|
|
1143
|
+
if (entry && typeof entry.id === 'string') latestById.set(entry.id, entry);
|
|
1144
|
+
} catch {
|
|
1145
|
+
/* a corrupt audit line is skipped, not fatal to reconcile */
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
const reconciled = [];
|
|
1150
|
+
const skipped = [];
|
|
1151
|
+
const failed = [];
|
|
1152
|
+
|
|
1153
|
+
// The judgment itself is classifyProposals'. This loop only acts on it, so
|
|
1154
|
+
// doctor's report and reconcile's action can never disagree about what an
|
|
1155
|
+
// artifact is. Nothing here deletes an artifact it did not first recover a
|
|
1156
|
+
// receipt for; see the note above reconcileProposals for why a 'pending' one
|
|
1157
|
+
// is never removed on its bytes alone.
|
|
1158
|
+
for (const c of classifyProposals(hypoDir)) {
|
|
1159
|
+
if (c.kind === 'pending') {
|
|
1160
|
+
skipped.push({ id: c.id, reason: 'no-handoff-audit-entry' });
|
|
1161
|
+
continue;
|
|
1162
|
+
}
|
|
1163
|
+
if (c.kind === 'evidence-broken') {
|
|
1164
|
+
failed.push({ id: c.id, target: c.target, reason: c.reason });
|
|
1165
|
+
continue;
|
|
1166
|
+
}
|
|
1167
|
+
const receipt = recordHandoffReceipt(hypoDir, c.closeSessionId, c.target, c.appliedHash);
|
|
1168
|
+
if (!receipt.ok) {
|
|
1169
|
+
failed.push({
|
|
1170
|
+
id: c.id,
|
|
1171
|
+
target: c.target,
|
|
1172
|
+
reason: 'close-receipt-failed',
|
|
1173
|
+
error: receipt.error,
|
|
1174
|
+
});
|
|
1175
|
+
continue;
|
|
1176
|
+
}
|
|
1177
|
+
// Only now is the artifact's job done: the receipt it was standing in
|
|
1178
|
+
// evidence for has landed, so the leftover it represents can finally go,
|
|
1179
|
+
// the same order writeApprovedProposal itself uses (receipt, then delete).
|
|
1180
|
+
if (!deleteProposal(hypoDir, c.id)) {
|
|
1181
|
+
failed.push({ id: c.id, target: c.target, reason: 'artifact-not-removed' });
|
|
1182
|
+
continue;
|
|
1183
|
+
}
|
|
1184
|
+
reconciled.push({ id: c.id, target: c.target, closeSessionId: c.closeSessionId });
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
for (const r of reconciled) {
|
|
1188
|
+
out.write(
|
|
1189
|
+
`✓ reconciled ${r.id} (${sanitizeForDisplay(r.target, { allowNewlines: false })}) into close ${r.closeSessionId}\n`,
|
|
1190
|
+
);
|
|
1191
|
+
}
|
|
1192
|
+
if (failed.length > 0) {
|
|
1193
|
+
err.write(
|
|
1194
|
+
`✗ ${failed.length} proposal(s) could not be reconciled:\n` +
|
|
1195
|
+
failed
|
|
1196
|
+
.map(
|
|
1197
|
+
(f) =>
|
|
1198
|
+
` ${f.id} (${sanitizeForDisplay(f.target ?? '?', { allowNewlines: false })}): ${f.reason}`,
|
|
1199
|
+
)
|
|
1200
|
+
.join('\n') +
|
|
1201
|
+
'\n',
|
|
1202
|
+
);
|
|
1203
|
+
}
|
|
1204
|
+
if (reconciled.length === 0 && failed.length === 0) {
|
|
1205
|
+
out.write('no receipts needed reconciling\n');
|
|
1206
|
+
}
|
|
1207
|
+
return {
|
|
1208
|
+
ok: failed.length === 0,
|
|
1209
|
+
code: failed.length === 0 ? 0 : 1,
|
|
1210
|
+
reconciled,
|
|
1211
|
+
skipped,
|
|
1212
|
+
failed,
|
|
1213
|
+
};
|
|
1214
|
+
}
|
|
1215
|
+
|
|
886
1216
|
/**
|
|
887
1217
|
* Discard one proposal: remove the artifact, leave the target untouched. No
|
|
888
1218
|
* confirm gate (the spec defines discard as a plain removal, and it writes nothing
|
|
@@ -910,12 +1240,61 @@ export function discardProposal({ hypoDir, id }, { stdout, stderr } = {}) {
|
|
|
910
1240
|
}
|
|
911
1241
|
|
|
912
1242
|
/**
|
|
913
|
-
*
|
|
914
|
-
*
|
|
915
|
-
|
|
1243
|
+
* Days between an ISO `createdAt` and `nowMs`, floored at 0, or `null` when
|
|
1244
|
+
* `createdAt` does not parse. Pure so a table test can pin it without a clock.
|
|
1245
|
+
*/
|
|
1246
|
+
function ageDaysSince(createdAt, nowMs) {
|
|
1247
|
+
const t = Date.parse(createdAt);
|
|
1248
|
+
if (!Number.isFinite(t)) return null;
|
|
1249
|
+
return Math.max(0, Math.floor((nowMs - t) / DAY_MS));
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
/**
|
|
1253
|
+
* Whether the target moved since this was parked, answered by the hash the
|
|
1254
|
+
* artifact already carries rather than by a second, weaker signal.
|
|
1255
|
+
*
|
|
1256
|
+
* mtime was the first thing tried here and it is wrong for this vault: a git
|
|
1257
|
+
* backend with multi-device sync is the design premise (every artifact carries
|
|
1258
|
+
* a `device` field), and clone, pull, checkout and rsync all bump mtime to now
|
|
1259
|
+
* without touching a byte. On the second machine every target would read as
|
|
1260
|
+
* `'changed'`, and a human reading that discards a park that was still
|
|
1261
|
+
* applicable. A discard is an unlink with no way back, so the cheaper signal
|
|
1262
|
+
* fails in the direction that loses bytes.
|
|
1263
|
+
*
|
|
1264
|
+
* `classifyFreshness` already answers this question from
|
|
1265
|
+
* `currentAtProposalHash`, which `listProposals` returns on every body. Reusing
|
|
1266
|
+
* it costs one file read per pending artifact instead of one `stat()`, and it
|
|
1267
|
+
* removes a second implementation of the same judgment from this file.
|
|
1268
|
+
*
|
|
1269
|
+
* Three states: `'gone'` (target absent now, which the apply shell treats as a
|
|
1270
|
+
* create), `'changed'`, `'unchanged'`. An unsafe or unresolvable target, or one
|
|
1271
|
+
* that cannot be read, returns `null` (unknown): guessing would be worse.
|
|
1272
|
+
*/
|
|
1273
|
+
export function targetActivitySincePark(hypoDir, target, currentAtProposalHash) {
|
|
1274
|
+
const full = resolveTargetPath(hypoDir, target);
|
|
1275
|
+
if (!full) return null;
|
|
1276
|
+
const current = readTarget(full);
|
|
1277
|
+
if (current === undefined) return null;
|
|
1278
|
+
if (current === null) return 'gone';
|
|
1279
|
+
return classifyFreshness({ current, currentAtProposalHash }) === 'fresh'
|
|
1280
|
+
? 'unchanged'
|
|
1281
|
+
: 'changed';
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
const ACTIVITY_LABEL = {
|
|
1285
|
+
changed: 'target changed since park',
|
|
1286
|
+
unchanged: 'target unchanged since park',
|
|
1287
|
+
gone: 'target absent, applying would create it',
|
|
1288
|
+
};
|
|
1289
|
+
|
|
1290
|
+
/**
|
|
1291
|
+
* List pending proposals (id, target, createdAt, age in days, target activity
|
|
1292
|
+
* since park, plus session/device), oldest first. `--json` emits the array;
|
|
1293
|
+
* the human form prints one line each, or a "no pending proposals" notice.
|
|
916
1294
|
*/
|
|
917
|
-
export function listPending({ hypoDir }, { stdout, json } = {}) {
|
|
1295
|
+
export function listPending({ hypoDir }, { stdout, json, now } = {}) {
|
|
918
1296
|
const out = stdout ?? process.stdout;
|
|
1297
|
+
const nowMs = now ? now() : Date.now();
|
|
919
1298
|
const items = listProposals(hypoDir)
|
|
920
1299
|
.map((p) => ({
|
|
921
1300
|
id: p.id,
|
|
@@ -923,6 +1302,8 @@ export function listPending({ hypoDir }, { stdout, json } = {}) {
|
|
|
923
1302
|
createdAt: p.createdAt ?? null,
|
|
924
1303
|
sessionId: p.sessionId ?? null,
|
|
925
1304
|
device: p.device ?? null,
|
|
1305
|
+
ageDays: p.createdAt ? ageDaysSince(p.createdAt, nowMs) : null,
|
|
1306
|
+
targetActivity: targetActivitySincePark(hypoDir, p.target, p.currentAtProposalHash ?? null),
|
|
926
1307
|
}))
|
|
927
1308
|
.sort((a, b) => String(a.createdAt).localeCompare(String(b.createdAt)));
|
|
928
1309
|
|
|
@@ -941,7 +1322,11 @@ export function listPending({ hypoDir }, { stdout, json } = {}) {
|
|
|
941
1322
|
const who = it.sessionId
|
|
942
1323
|
? ` (session ${clean(it.sessionId)}, ${clean(it.device ?? '?')})`
|
|
943
1324
|
: '';
|
|
944
|
-
|
|
1325
|
+
const age = it.ageDays === null ? '' : `, ${it.ageDays}d old`;
|
|
1326
|
+
const activity = ACTIVITY_LABEL[it.targetActivity]
|
|
1327
|
+
? `, ${ACTIVITY_LABEL[it.targetActivity]}`
|
|
1328
|
+
: '';
|
|
1329
|
+
out.write(`${it.id} ${clean(it.target)} ${clean(it.createdAt)}${age}${activity}${who}\n`);
|
|
945
1330
|
}
|
|
946
1331
|
return { ok: true, code: 0, count: items.length };
|
|
947
1332
|
}
|
|
@@ -949,7 +1334,15 @@ export function listPending({ hypoDir }, { stdout, json } = {}) {
|
|
|
949
1334
|
// ── CLI ───────────────────────────────────────────────────────────────────────
|
|
950
1335
|
|
|
951
1336
|
function parseArgs(argv) {
|
|
952
|
-
const args = {
|
|
1337
|
+
const args = {
|
|
1338
|
+
hypoDir: null,
|
|
1339
|
+
cmd: null,
|
|
1340
|
+
id: null,
|
|
1341
|
+
json: false,
|
|
1342
|
+
sessionId: null,
|
|
1343
|
+
closeSessionId: null,
|
|
1344
|
+
ids: [],
|
|
1345
|
+
};
|
|
953
1346
|
const positionals = [];
|
|
954
1347
|
const rest = argv.slice(2);
|
|
955
1348
|
for (let i = 0; i < rest.length; i++) {
|
|
@@ -959,6 +1352,9 @@ function parseArgs(argv) {
|
|
|
959
1352
|
else if (a === '--json') args.json = true;
|
|
960
1353
|
else if (a === '--session-id') args.sessionId = rest[++i] ?? null;
|
|
961
1354
|
else if (a.startsWith('--session-id=')) args.sessionId = a.slice('--session-id='.length);
|
|
1355
|
+
else if (a === '--close-session-id') args.closeSessionId = rest[++i] ?? null;
|
|
1356
|
+
else if (a.startsWith('--close-session-id='))
|
|
1357
|
+
args.closeSessionId = a.slice('--close-session-id='.length);
|
|
962
1358
|
else if (a === '--ids') args.ids = splitIds(rest[++i] ?? '');
|
|
963
1359
|
else if (a.startsWith('--ids=')) args.ids = splitIds(a.slice('--ids='.length));
|
|
964
1360
|
else positionals.push(a);
|
|
@@ -993,13 +1389,21 @@ async function main() {
|
|
|
993
1389
|
break;
|
|
994
1390
|
case 'challenge':
|
|
995
1391
|
result = challengeProposals(
|
|
996
|
-
{
|
|
1392
|
+
{
|
|
1393
|
+
hypoDir: args.hypoDir,
|
|
1394
|
+
sessionId: args.sessionId,
|
|
1395
|
+
ids: args.ids,
|
|
1396
|
+
closeSessionId: args.closeSessionId,
|
|
1397
|
+
},
|
|
997
1398
|
{},
|
|
998
1399
|
);
|
|
999
1400
|
break;
|
|
1000
1401
|
case 'resolve':
|
|
1001
1402
|
result = resolveProposals({ hypoDir: args.hypoDir, sessionId: args.sessionId }, {});
|
|
1002
1403
|
break;
|
|
1404
|
+
case 'reconcile':
|
|
1405
|
+
result = reconcileProposals({ hypoDir: args.hypoDir }, {});
|
|
1406
|
+
break;
|
|
1003
1407
|
case 'discard':
|
|
1004
1408
|
if (!args.id) {
|
|
1005
1409
|
process.stderr.write('✗ usage: hypomnema proposal discard <id>\n');
|
|
@@ -1010,8 +1414,9 @@ async function main() {
|
|
|
1010
1414
|
default:
|
|
1011
1415
|
process.stderr.write(
|
|
1012
1416
|
'usage: hypomnema proposal <list|apply|discard> [id] [--json] [--hypo-dir <path>]\n' +
|
|
1013
|
-
' hypomnema proposal challenge --session-id <id> --ids <id
|
|
1014
|
-
' hypomnema proposal resolve --session-id <id>\n'
|
|
1417
|
+
' hypomnema proposal challenge --session-id <id> --ids <id,...> [--close-session-id <id>]\n' +
|
|
1418
|
+
' hypomnema proposal resolve --session-id <id>\n' +
|
|
1419
|
+
' hypomnema proposal reconcile\n',
|
|
1015
1420
|
);
|
|
1016
1421
|
process.exit(2);
|
|
1017
1422
|
}
|
package/scripts/resume.mjs
CHANGED
|
@@ -119,8 +119,9 @@ function resolveActiveProject(hypoDir, cwd = null) {
|
|
|
119
119
|
const hotPath = join(hypoDir, 'hot.md');
|
|
120
120
|
if (!existsSync(hotPath)) return null;
|
|
121
121
|
|
|
122
|
-
// Strip HTML comments before parsing so
|
|
123
|
-
//
|
|
122
|
+
// Strip HTML comments before parsing so a commented example row, which
|
|
123
|
+
// vaults created before the root table became generated still carry
|
|
124
|
+
// (`<!-- Row format: ... -->`), is not picked up as data.
|
|
124
125
|
const content = readFileSync(hotPath, 'utf-8').replace(/<!--[\s\S]*?-->/g, '');
|
|
125
126
|
// Canonical hot.md uses wikilinks: | name | date | [[projects/slug/hot]] |
|
|
126
127
|
// Pick the most recent row by the date column when present.
|
|
@@ -28,6 +28,7 @@ import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
|
|
|
28
28
|
import { join } from 'path';
|
|
29
29
|
import { homedir } from 'os';
|
|
30
30
|
import { resolveHypoRoot, expandHome } from './lib/hypo-root.mjs';
|
|
31
|
+
import { DAY_MS } from './lib/time.mjs';
|
|
31
32
|
|
|
32
33
|
const HOME = homedir();
|
|
33
34
|
|
|
@@ -220,7 +221,7 @@ export function auditEntries(entries, { maxAgeDays = 30, limit = 50, now = Date.
|
|
|
220
221
|
const lines = readTranscriptLines(entry.transcript_path);
|
|
221
222
|
const metrics = computeMetrics(lines);
|
|
222
223
|
const recordedAt = entry.recorded_at ? Date.parse(entry.recorded_at) : NaN;
|
|
223
|
-
const ageDays = Number.isFinite(recordedAt) ? (now - recordedAt) /
|
|
224
|
+
const ageDays = Number.isFinite(recordedAt) ? (now - recordedAt) / DAY_MS : NaN;
|
|
224
225
|
const classification = classify(metrics, ageDays, maxAgeDays);
|
|
225
226
|
results.push({
|
|
226
227
|
session_id: entry.session_id,
|