hypomnema 1.8.2 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +141 -51
  4. package/README.ko.md +18 -17
  5. package/README.md +16 -15
  6. package/commands/crystallize.md +89 -24
  7. package/commands/doctor.md +3 -1
  8. package/docs/ARCHITECTURE.md +45 -14
  9. package/docs/CONTRIBUTING.md +26 -8
  10. package/hooks/atomic-write.mjs +44 -0
  11. package/hooks/base-store.mjs +409 -90
  12. package/hooks/close-gate-store.mjs +131 -16
  13. package/hooks/close-journal.mjs +250 -0
  14. package/hooks/close-receipt.mjs +559 -0
  15. package/hooks/hooks.json +3 -38
  16. package/hooks/hypo-auto-commit.mjs +80 -10
  17. package/hooks/hypo-auto-minimal-crystallize.mjs +129 -25
  18. package/hooks/hypo-auto-stage.mjs +13 -1
  19. package/hooks/hypo-close-guard.mjs +2 -1
  20. package/hooks/hypo-cwd-change.mjs +45 -20
  21. package/hooks/hypo-file-watch.mjs +44 -6
  22. package/hooks/hypo-first-prompt.mjs +15 -9
  23. package/hooks/hypo-hot-rebuild.mjs +169 -112
  24. package/hooks/hypo-personal-check.mjs +9 -1
  25. package/hooks/hypo-session-record.mjs +1 -1
  26. package/hooks/hypo-session-start.mjs +566 -20
  27. package/hooks/hypo-shared.mjs +2250 -206
  28. package/hooks/hypo-stop.mjs +207 -0
  29. package/hooks/proposal-store.mjs +201 -35
  30. package/hooks/shared.json +15 -0
  31. package/hooks/version-check.mjs +9 -7
  32. package/package.json +3 -1
  33. package/scripts/crystallize.mjs +3 -4
  34. package/scripts/doctor.mjs +549 -123
  35. package/scripts/init.mjs +86 -109
  36. package/scripts/lib/core-hooks.mjs +48 -22
  37. package/scripts/lib/crystallize-close-apply.mjs +2397 -188
  38. package/scripts/lib/crystallize-close-check.mjs +43 -23
  39. package/scripts/lib/design-history-stale.mjs +36 -9
  40. package/scripts/lib/frontmatter.mjs +39 -0
  41. package/scripts/lib/git-hooks-dir.mjs +427 -52
  42. package/scripts/lib/hook-inventory.mjs +180 -0
  43. package/scripts/lib/page-usage.mjs +1 -1
  44. package/scripts/lib/pkg-provenance.mjs +18 -1
  45. package/scripts/lib/plugin-detect.mjs +43 -9
  46. package/scripts/lib/project-create.mjs +40 -44
  47. package/scripts/lib/schema-vocab.mjs +22 -11
  48. package/scripts/lib/template-schema-version.mjs +48 -0
  49. package/scripts/lib/time.mjs +46 -0
  50. package/scripts/lint.mjs +66 -18
  51. package/scripts/proposal.mjs +423 -18
  52. package/scripts/resume.mjs +3 -2
  53. package/scripts/session-audit.mjs +2 -1
  54. package/scripts/uninstall.mjs +144 -66
  55. package/scripts/upgrade.mjs +523 -234
  56. package/scripts/verify.mjs +2 -1
  57. package/scripts/weekly-report.mjs +2 -1
  58. package/templates/SCHEMA.md +27 -7
  59. package/templates/gitignore +11 -0
  60. package/templates/hypo-automation.md +10 -13
  61. package/templates/hypo-config.md +1 -1
  62. package/templates/hypo-guide.md +53 -25
  63. package/templates/hot.md +0 -24
@@ -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
- writeFileSync(tmp, content, { flag: 'wx' });
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 `displayedHash` is the hash of the bytes
459
- * whose diff the approver saw (null when the target was absent).
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
- * @param {{hypoDir: string, sessionId: string, ids: string[]}} sel
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 = { nonce: minted, sessionId, mintedAt: clock(), items };
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
- `\nTo approve the ${items.length} overwrite(s) above, the USER must type this line in the conversation:\n\n` +
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
- { hypoDir, id: item.id, proposal, full, displayedHash },
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
- * 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.
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
- out.write(`${it.id} ${clean(it.target)} ${clean(it.createdAt)}${who}\n`);
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 = { hypoDir: null, cmd: null, id: null, json: false, sessionId: null, ids: [] };
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
- { hypoDir: args.hypoDir, sessionId: args.sessionId, ids: args.ids },
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,...>\n' +
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
  }
@@ -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 the canonical-format example row
123
- // in templates/hot.md (`<!-- Row format: ... -->`) is not picked up as data.
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) / (24 * 60 * 60 * 1000) : NaN;
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,