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
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
* MISS → inject global hot.md pointer only (no fan-out to all projects)
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { readFileSync, writeFileSync, existsSync, realpathSync
|
|
10
|
+
import { readFileSync, writeFileSync, existsSync, realpathSync } from 'fs';
|
|
11
|
+
import { atomicWrite } from './atomic-write.mjs';
|
|
11
12
|
import { homedir } from 'os';
|
|
12
13
|
import { join, dirname } from 'path';
|
|
13
14
|
import { fileURLToPath } from 'url';
|
|
@@ -40,6 +41,11 @@ import {
|
|
|
40
41
|
pkgRootDriftStatus,
|
|
41
42
|
PKG_ROOT,
|
|
42
43
|
withFileLock,
|
|
44
|
+
vaultCommitLockTarget,
|
|
45
|
+
claimAndWriteRootHotProjection,
|
|
46
|
+
recordTouchedPaths,
|
|
47
|
+
consumeRootHotHealthNotice,
|
|
48
|
+
rootHotBackupRecoveryNotice,
|
|
43
49
|
} from './hypo-shared.mjs';
|
|
44
50
|
import {
|
|
45
51
|
defaultCachePath,
|
|
@@ -70,7 +76,7 @@ import {
|
|
|
70
76
|
recordObserved,
|
|
71
77
|
hashContent,
|
|
72
78
|
} from './base-store.mjs';
|
|
73
|
-
import {
|
|
79
|
+
import { listProposalsChecked } from './proposal-store.mjs';
|
|
74
80
|
|
|
75
81
|
// Privacy guard: refuse to read+inject .hypoignore-matched
|
|
76
82
|
// wiki files into additionalContext. Without this, a user who lists
|
|
@@ -375,13 +381,12 @@ function selfHealPkgRoot(status) {
|
|
|
375
381
|
// temp + rename in the SAME directory: a crash mid-write leaves only a
|
|
376
382
|
// throwaway temp file behind, never a torn hypo-pkg.json that the next
|
|
377
383
|
// session's every hook read of this file would then choke on.
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
renameSync(tmp, HYPO_PKG_JSON_PATH);
|
|
384
|
+
// Shared writer rather than a local temp+rename: this one had no
|
|
385
|
+
// cleanup, so a failed rename left its temp behind for good. The temp
|
|
386
|
+
// name loses its leading dot in the trade, so a leaked one would now be
|
|
387
|
+
// visible in a directory the user reads; that is the point, since the
|
|
388
|
+
// shared writer is the one that does not leak.
|
|
389
|
+
atomicWrite(HYPO_PKG_JSON_PATH, `${JSON.stringify(updated, null, 2)}\n`);
|
|
385
390
|
return { healed: true, oldVersion, newVersion };
|
|
386
391
|
},
|
|
387
392
|
{ timeoutMs: 150, staleMs: 30_000, pollMs: 20 },
|
|
@@ -654,15 +659,30 @@ function syncStateNotice(pullOk) {
|
|
|
654
659
|
* exactly like syncStateNotice: the line joins the `notices` array (→
|
|
655
660
|
* additionalContext) and is also written to stderr, so both the model and the
|
|
656
661
|
* user's transcript see it. NOT a systemMessage banner (that channel is
|
|
657
|
-
* reserved for the update/sibling notices). Pure read (
|
|
658
|
-
* mutates); best-effort so a store read failure never breaks
|
|
659
|
-
* there are no pending proposals
|
|
662
|
+
* reserved for the update/sibling notices). Pure read (listProposalsChecked
|
|
663
|
+
* never mutates); best-effort so a store read failure never breaks
|
|
664
|
+
* SessionStart. '' when there are no pending proposals AND nothing is wrong
|
|
665
|
+
* with the store, so nothing surfaces on the ordinary empty path.
|
|
666
|
+
*
|
|
667
|
+
* Uses listProposalsChecked rather than the plain listProposals this used to
|
|
668
|
+
* call: a store that could not be listed at all (`ok: false`, permissions or
|
|
669
|
+
* a file sitting where the directory should be) and a store with an
|
|
670
|
+
* unparseable `.json` candidate both used to look identical to zero here,
|
|
671
|
+
* silently, at the start of every session. Both get a short clause appended
|
|
672
|
+
* to the SAME line rather than a line of their own, so a clean vault's
|
|
673
|
+
* prompt budget does not grow by a byte.
|
|
660
674
|
*/
|
|
661
675
|
function pendingProposalNotice() {
|
|
662
676
|
try {
|
|
663
|
-
const
|
|
664
|
-
if (
|
|
665
|
-
|
|
677
|
+
const inventory = listProposalsChecked(HYPO_DIR);
|
|
678
|
+
if (!inventory.ok) return '[WIKI: 파킹 proposal 측정 불가 (hypomnema doctor 확인)]';
|
|
679
|
+
const n = inventory.proposals.length;
|
|
680
|
+
const u = inventory.unreadable.length;
|
|
681
|
+
if (n === 0 && u === 0) return '';
|
|
682
|
+
const parts = [];
|
|
683
|
+
if (n > 0) parts.push(`대기 proposal ${n}건 (검토: hypomnema proposal list)`);
|
|
684
|
+
if (u > 0) parts.push(`안 읽히는 후보 ${u}건 (hypomnema doctor 확인)`);
|
|
685
|
+
return `[WIKI: ${parts.join(', ')}]`;
|
|
666
686
|
} catch {
|
|
667
687
|
return '';
|
|
668
688
|
}
|
|
@@ -904,7 +924,32 @@ process.stdin.on('end', () => {
|
|
|
904
924
|
data = JSON.parse(raw);
|
|
905
925
|
} catch {}
|
|
906
926
|
|
|
907
|
-
|
|
927
|
+
// BLOCKER fix: serialized against every other writer of this vault
|
|
928
|
+
// (hypo-auto-commit.mjs's stage+commit+sync, crystallize.mjs's apply, and
|
|
929
|
+
// writeRootHotProjection below) on the SAME lock. A fast-forward pull
|
|
930
|
+
// rewrites hot.md as surely as the projection writer does, so locking the
|
|
931
|
+
// writer alone would just move the unguarded overwrite onto this line.
|
|
932
|
+
// Not reentrant: this hold is released before the projection call below
|
|
933
|
+
// takes the same lock for itself, and nothing between them holds it.
|
|
934
|
+
//
|
|
935
|
+
// A lock timeout leaves pullOk false, which is the same state a failed
|
|
936
|
+
// pull produces: the sync-state notice stays unresolved for one more
|
|
937
|
+
// session rather than being cleared on a pull that never ran.
|
|
938
|
+
//
|
|
939
|
+
// The `.git` check is gitPull's own precondition, hoisted out so the lock
|
|
940
|
+
// is never taken for a call that cannot pull: withFileLock creates the
|
|
941
|
+
// lock's parent directory, which on an install with no vault would mint
|
|
942
|
+
// `.cache/` under a wiki root that does not exist.
|
|
943
|
+
let pullOk = false;
|
|
944
|
+
if (existsSync(join(HYPO_DIR, '.git'))) {
|
|
945
|
+
try {
|
|
946
|
+
pullOk = withFileLock(vaultCommitLockTarget(HYPO_DIR), () => gitPull(HYPO_DIR), {
|
|
947
|
+
timeoutMs: Number(process.env.HYPO_VAULT_LOCK_TIMEOUT_MS) || 5000,
|
|
948
|
+
});
|
|
949
|
+
} catch {
|
|
950
|
+
pullOk = false;
|
|
951
|
+
}
|
|
952
|
+
}
|
|
908
953
|
const syncLine = syncStateNotice(pullOk);
|
|
909
954
|
const proposalLine = pendingProposalNotice();
|
|
910
955
|
const growthLine = readLastGrowthLine();
|
|
@@ -971,6 +1016,197 @@ process.stdin.on('end', () => {
|
|
|
971
1016
|
process.stderr.write(`\n\x1b[33m${foreignNotice}\x1b[0m\n`);
|
|
972
1017
|
}
|
|
973
1018
|
|
|
1019
|
+
// Root hot.md projection. Must run AFTER gitPull (so it
|
|
1020
|
+
// reflects the merged tree) and BEFORE snapshotBase below (so this
|
|
1021
|
+
// session's base for 'hot.md' is the freshly generated bytes, not
|
|
1022
|
+
// whatever was on disk before this write). Also deliberately AFTER
|
|
1023
|
+
// foreignUncommittedNotice above: that check reads `git status` on the
|
|
1024
|
+
// wiki tree, and this session's OWN regeneration of hot.md is not a
|
|
1025
|
+
// foreign or unattributed change: running the write first would make the
|
|
1026
|
+
// projection's own necessary write show up in that scan and misreport as
|
|
1027
|
+
// one. resolveActiveProject, closeCandidateSlugs, and the HIT/MISS
|
|
1028
|
+
// injection later in this hook all read hot.md, so the write still has to
|
|
1029
|
+
// land before any of those.
|
|
1030
|
+
// recordTouchedPaths here is the ONLY thing that gets this write into a
|
|
1031
|
+
// scoped hypo-auto-commit at Stop. Without it: SessionStart writes fresh
|
|
1032
|
+
// bytes, Stop's own rebuild sees disk already equals the projection
|
|
1033
|
+
// (writeRootHotProjection's no-op branch) and records nothing touched, and
|
|
1034
|
+
// hypo-auto-commit only ever commits its touched-paths set, so the write
|
|
1035
|
+
// this hook just made would never be staged or committed by ANY path, and
|
|
1036
|
+
// would sit as an unattributable dirty file (closeFileTargetsGlobal scopes
|
|
1037
|
+
// hot.md as close-owned; classifyForeignOnlyDirty cannot demote it to
|
|
1038
|
+
// foreign) blocking both the close gate and PreCompact. TODO(wave 3): this
|
|
1039
|
+
// whole call disappears the moment hot.md comes out of overwriteTargets /
|
|
1040
|
+
// the commit path, since a projection target no longer needs a commit.
|
|
1041
|
+
//
|
|
1042
|
+
// The touched-paths record is claimed BEFORE the write, not after: it is
|
|
1043
|
+
// itself best-effort (a lock timeout can fail it), and recording it after
|
|
1044
|
+
// the fact means a failure there leaves a real disk write with nothing in
|
|
1045
|
+
// any session's touched-paths set, the exact orphan this comment already
|
|
1046
|
+
// described, just moved from "Stop's rebuild is a no-op" to "the record
|
|
1047
|
+
// call itself failed." Claiming it first and skipping the write on a
|
|
1048
|
+
// failed claim means hot.md is never rewritten without something able to
|
|
1049
|
+
// account for that rewrite at close time.
|
|
1050
|
+
//
|
|
1051
|
+
// The claim and the write share one hold of the vault lock
|
|
1052
|
+
// (claimAndWriteRootHotProjection). Claimed first and locked second, a
|
|
1053
|
+
// Stop for the same session_id could take the lock in between, retire
|
|
1054
|
+
// the still-unwritten claim, and leave the write that followed with no
|
|
1055
|
+
// claim at all.
|
|
1056
|
+
if (existsSync(HYPO_DIR)) {
|
|
1057
|
+
// A missing session_id reads as "not claimed" here (claimProjectionWrite
|
|
1058
|
+
// underneath), unlike every other best-effort accumulate call, or this
|
|
1059
|
+
// write proceeds with nothing able to ever bring it into a commit.
|
|
1060
|
+
let outcome;
|
|
1061
|
+
let writeError = null;
|
|
1062
|
+
try {
|
|
1063
|
+
// n1 fix: session_id goes along so this session's own digest lands in
|
|
1064
|
+
// ITS OWN receipt, not just the shared global ownership record (see
|
|
1065
|
+
// writeRootHotProjection's doc comment).
|
|
1066
|
+
outcome = claimAndWriteRootHotProjection(HYPO_DIR, data.session_id);
|
|
1067
|
+
} catch (err) {
|
|
1068
|
+
writeError = err;
|
|
1069
|
+
}
|
|
1070
|
+
if (writeError) {
|
|
1071
|
+
// BLOCKER fix: writeRootHotProjection now throws instead of
|
|
1072
|
+
// silently treating an unreadable existing hot.md as absent. The
|
|
1073
|
+
// claim this call added was already dropped inside the lock (see
|
|
1074
|
+
// claimAndWriteRootHotProjection), so no stale 'hot.md' claim is
|
|
1075
|
+
// left to sweep in whatever another session or a person writes next.
|
|
1076
|
+
// catch-message-accuracy fix (r5-w1.md): the write can throw from
|
|
1077
|
+
// several different steps (reading the existing file, writing the
|
|
1078
|
+
// migration backup, updating .gitignore, or the final atomic write
|
|
1079
|
+
// itself), not only a read failure: wording this as "읽을 수 없어"
|
|
1080
|
+
// (could not read) misnamed a disk-full or backup-write failure as
|
|
1081
|
+
// something it was not. What stays true regardless of which step
|
|
1082
|
+
// failed is that the atomic write never ran, so the old file is
|
|
1083
|
+
// untouched.
|
|
1084
|
+
const err = writeError;
|
|
1085
|
+
const msg = `루트 hot.md 갱신에 실패해 이전 파일을 그대로 두었습니다: ${err?.message ?? String(err)}`;
|
|
1086
|
+
notices.push(msg);
|
|
1087
|
+
noticePrefix = notices.length ? `${notices.join('\n\n')}\n\n` : '';
|
|
1088
|
+
outExtra = {
|
|
1089
|
+
...outExtra,
|
|
1090
|
+
systemMessage: [outExtra.systemMessage, msg].filter(Boolean).join('\n\n'),
|
|
1091
|
+
};
|
|
1092
|
+
process.stderr.write(`\n\x1b[33m${msg}\x1b[0m\n`);
|
|
1093
|
+
} else if (outcome.claimFailed) {
|
|
1094
|
+
const msg =
|
|
1095
|
+
'루트 hot.md 갱신을 건너뛰었습니다: 이번 세션의 커밋 범위에 기록하지 못해 갱신 후 미커밋 파일로 남을 수 있었습니다.';
|
|
1096
|
+
notices.push(msg);
|
|
1097
|
+
noticePrefix = notices.length ? `${notices.join('\n\n')}\n\n` : '';
|
|
1098
|
+
outExtra = {
|
|
1099
|
+
...outExtra,
|
|
1100
|
+
systemMessage: [outExtra.systemMessage, msg].filter(Boolean).join('\n\n'),
|
|
1101
|
+
};
|
|
1102
|
+
process.stderr.write(`\n\x1b[33m${msg}\x1b[0m\n`);
|
|
1103
|
+
} else {
|
|
1104
|
+
const { result } = outcome;
|
|
1105
|
+
if (result.lockTimeout) {
|
|
1106
|
+
// BLOCKER fix, the other half: the projection writer holds the
|
|
1107
|
+
// vault lock, so a vault busy with another session's commit can
|
|
1108
|
+
// leave this session on the previous pointer table. Say so instead
|
|
1109
|
+
// of starting the session on a stale table with no explanation.
|
|
1110
|
+
// No claim to drop: it is taken inside the same lock, so a lock
|
|
1111
|
+
// timeout means this run never claimed anything, and a resumed
|
|
1112
|
+
// session_id's earlier, still-unresolved 'hot.md' claim is left
|
|
1113
|
+
// exactly as it was.
|
|
1114
|
+
const msg =
|
|
1115
|
+
'루트 hot.md 를 갱신하지 못했습니다: 다른 세션이 이 저장소를 쓰는 중이라 잠금을 얻지 못했습니다. 이번 세션은 이전 포인터 표를 그대로 읽습니다 (다음 세션 시작이나 종료 때 다시 시도합니다).';
|
|
1116
|
+
notices.push(msg);
|
|
1117
|
+
noticePrefix = notices.length ? `${notices.join('\n\n')}\n\n` : '';
|
|
1118
|
+
outExtra = {
|
|
1119
|
+
...outExtra,
|
|
1120
|
+
systemMessage: [outExtra.systemMessage, msg].filter(Boolean).join('\n\n'),
|
|
1121
|
+
};
|
|
1122
|
+
process.stderr.write(`\n\x1b[33m${msg}\x1b[0m\n`);
|
|
1123
|
+
} else if (result.scanError) {
|
|
1124
|
+
const msg =
|
|
1125
|
+
'루트 hot.md 갱신 실패: projects/ 디렉터리를 읽을 수 없어 이전 파일을 그대로 두었습니다.' +
|
|
1126
|
+
` 권한과 경로를 확인하고(\`ls -ld ${join(HYPO_DIR, 'projects')}\`), 고치면 다음 세션 시작이나 종료 때 자동으로 다시 시도합니다.`;
|
|
1127
|
+
notices.push(msg);
|
|
1128
|
+
noticePrefix = notices.length ? `${notices.join('\n\n')}\n\n` : '';
|
|
1129
|
+
outExtra = {
|
|
1130
|
+
...outExtra,
|
|
1131
|
+
systemMessage: [outExtra.systemMessage, msg].filter(Boolean).join('\n\n'),
|
|
1132
|
+
};
|
|
1133
|
+
process.stderr.write(`\n\x1b[33m${msg}\x1b[0m\n`);
|
|
1134
|
+
} else if (result.warnings.length > 0) {
|
|
1135
|
+
// major-1/3: a per-project read failure no longer disappears
|
|
1136
|
+
// silently: it kept its row (blank date) in the table already
|
|
1137
|
+
// written above, but the reason must still reach a human.
|
|
1138
|
+
const msg =
|
|
1139
|
+
`루트 hot.md 갱신 중 일부 프로젝트를 읽지 못했습니다:\n${result.warnings.join('\n')}\n` +
|
|
1140
|
+
'해당 파일의 권한과 경로를 확인하면 다음 세션 시작이나 종료 때 자동으로 다시 읽습니다.';
|
|
1141
|
+
notices.push(msg);
|
|
1142
|
+
noticePrefix = notices.length ? `${notices.join('\n\n')}\n\n` : '';
|
|
1143
|
+
outExtra = {
|
|
1144
|
+
...outExtra,
|
|
1145
|
+
systemMessage: [outExtra.systemMessage, msg].filter(Boolean).join('\n\n'),
|
|
1146
|
+
};
|
|
1147
|
+
process.stderr.write(`\n\x1b[33m${msg}\x1b[0m\n`);
|
|
1148
|
+
}
|
|
1149
|
+
if (result.gitignoreUpdated) {
|
|
1150
|
+
// Best-effort: the gitignore change itself already landed on
|
|
1151
|
+
// disk (writeRootHotProjection would have thrown otherwise); a
|
|
1152
|
+
// failure to also claim it only risks THIS one infra file
|
|
1153
|
+
// showing up as an unattributed dirty file at close, not the
|
|
1154
|
+
// backup it protects (which is gitignored either way).
|
|
1155
|
+
const gClaimed = recordTouchedPaths(HYPO_DIR, data.session_id, ['.gitignore']);
|
|
1156
|
+
if (!gClaimed) {
|
|
1157
|
+
process.stderr.write(
|
|
1158
|
+
'[hypo-session-start] .gitignore 갱신(백업 유출 방지 패턴)을 커밋 범위에 기록하지 못했습니다\n',
|
|
1159
|
+
);
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
if (result.backedUp && result.backupPath) {
|
|
1163
|
+
// MAJOR fix: the backup itself was never in question (it lands
|
|
1164
|
+
// on disk and is gitignored either way), only whether a human
|
|
1165
|
+
// ever learns it exists. Before this, `backedUp`/`backupPath`
|
|
1166
|
+
// reached neither `notices` nor `systemMessage`: the backup file
|
|
1167
|
+
// is gitignored, so it does not even show up in `git status`, and
|
|
1168
|
+
// a person who had just hand-edited root hot.md would see their
|
|
1169
|
+
// edit silently replaced with no trace of where it went. Naming
|
|
1170
|
+
// only the file (basename), not its full path: the vault root is
|
|
1171
|
+
// already implied by the session, and nothing else here leaks a
|
|
1172
|
+
// full filesystem path into a notice. This branch should be rare
|
|
1173
|
+
// once rootHot leaves the close payload/overwrite targets: with
|
|
1174
|
+
// no scripted writer left that targets this file, a backup only
|
|
1175
|
+
// happens when a person edits it by hand, so this notice does
|
|
1176
|
+
// not become noise on every session.
|
|
1177
|
+
// Naming the file is not a recovery path on its own: copying the
|
|
1178
|
+
// backup back onto root hot.md just gets it replaced again on the
|
|
1179
|
+
// next rebuild. rootHotBackupRecoveryNotice (hooks/hypo-shared.mjs)
|
|
1180
|
+
// is the one sentence that says what actually recovers the
|
|
1181
|
+
// content, shared with the Stop-hook rebuild and project-create so
|
|
1182
|
+
// the three writers cannot drift into different wordings.
|
|
1183
|
+
const msg = rootHotBackupRecoveryNotice(result.backupPath);
|
|
1184
|
+
notices.push(msg);
|
|
1185
|
+
noticePrefix = notices.length ? `${notices.join('\n\n')}\n\n` : '';
|
|
1186
|
+
outExtra = {
|
|
1187
|
+
...outExtra,
|
|
1188
|
+
systemMessage: [outExtra.systemMessage, msg].filter(Boolean).join('\n\n'),
|
|
1189
|
+
};
|
|
1190
|
+
process.stderr.write(`\n\x1b[33m${msg}\x1b[0m\n`);
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
// A prior Stop's rebuild may have failed invisibly (its own stdout is
|
|
1196
|
+
// suppressed) and left a note for the next SessionStart to show. One-shot:
|
|
1197
|
+
// consumed (and deleted) the moment it is read, so it surfaces exactly
|
|
1198
|
+
// once instead of repeating on every future session.
|
|
1199
|
+
const staleProjectionNotice = consumeRootHotHealthNotice(HYPO_DIR);
|
|
1200
|
+
if (staleProjectionNotice) {
|
|
1201
|
+
notices.push(staleProjectionNotice);
|
|
1202
|
+
noticePrefix = notices.length ? `${notices.join('\n\n')}\n\n` : '';
|
|
1203
|
+
outExtra = {
|
|
1204
|
+
...outExtra,
|
|
1205
|
+
systemMessage: [outExtra.systemMessage, staleProjectionNotice].filter(Boolean).join('\n\n'),
|
|
1206
|
+
};
|
|
1207
|
+
process.stderr.write(`\n\x1b[33m${staleProjectionNotice}\x1b[0m\n`);
|
|
1208
|
+
}
|
|
1209
|
+
|
|
974
1210
|
// Observed-base snapshot for the write=proposal gate. Deliberately AFTER gitPull: the base must
|
|
975
1211
|
// describe the tree this session actually starts from, remote merges
|
|
976
1212
|
// included, or the first close would raise a proposal against content the
|
|
@@ -979,8 +1215,24 @@ process.stdin.on('end', () => {
|
|
|
979
1215
|
// alone. `data.session_id` is used raw rather than the 'default' fallback
|
|
980
1216
|
// above. A session with no id has no base and closes down the legacy
|
|
981
1217
|
// direct-write path.
|
|
1218
|
+
//
|
|
1219
|
+
// Only the root-only targets (hot.md, open-questions.md) are snapshotted
|
|
1220
|
+
// HERE, unconditionally. They carry no .hypoignore/visibility gate of
|
|
1221
|
+
// their own, so hashing them before any injection decision costs
|
|
1222
|
+
// nothing a later read would have refused anyway. The current project's
|
|
1223
|
+
// OWN two targets (hit.proj's hot.md / session-state.md) used to be
|
|
1224
|
+
// snapshotted right here too, blind to whether the HIT branch below was
|
|
1225
|
+
// about to actually show them (review r4-w4 major 3): that let a
|
|
1226
|
+
// .hypoignore'd or machine-scoped-out project target mint a "clean" base
|
|
1227
|
+
// from bytes this session was never shown a single line of, and for a
|
|
1228
|
+
// LATE HIT (this session's cwd only resolves to a project on a later
|
|
1229
|
+
// resume/compact, not its first SessionStart), that base came from
|
|
1230
|
+
// whatever was on disk at that arbitrary later moment, with no relation
|
|
1231
|
+
// to what injection actually revealed. Enrollment for those two moved
|
|
1232
|
+
// down to right after the HIT branch's own reads decide that question
|
|
1233
|
+
// (see the call there).
|
|
982
1234
|
if (data.session_id) {
|
|
983
|
-
snapshotBase(HYPO_DIR, data.session_id, overwriteTargets(
|
|
1235
|
+
snapshotBase(HYPO_DIR, data.session_id, overwriteTargets(null));
|
|
984
1236
|
// Bumps the observed generation on EVERY SessionStart (first run and
|
|
985
1237
|
// resume/compact alike), before the injections below record into it. A
|
|
986
1238
|
// resume that ends up injecting nothing (ignored, scoped out, absent)
|
|
@@ -1006,6 +1258,109 @@ process.stdin.on('end', () => {
|
|
|
1006
1258
|
const TODAY = new Date().toISOString().slice(0, 10);
|
|
1007
1259
|
const hotRead = readIfNotIgnored(hit.hotPath, HOT_CHARS, ignorePatterns);
|
|
1008
1260
|
const stateRead = readIfNotIgnored(hit.statePath, STATE_CHARS, ignorePatterns);
|
|
1261
|
+
// Enroll THIS project's two overwrite targets now, right next to the
|
|
1262
|
+
// reads that just decided whether this session is actually about to be
|
|
1263
|
+
// shown them (review r4-w4 major 3: see the top-of-hook snapshotBase
|
|
1264
|
+
// call's comment for why this moved here instead of running blind
|
|
1265
|
+
// before this decision existed). A target `readIfNotIgnored` refused
|
|
1266
|
+
// (.hypoignore, machine-scoped-out) but that still exists on disk is
|
|
1267
|
+
// left OUT of `relPaths` on purpose: `snapshotBase` would otherwise
|
|
1268
|
+
// hash it anyway (it re-reads disk itself, not these `Read`s) and mint
|
|
1269
|
+
// a base from bytes this session was never shown, so the key stays
|
|
1270
|
+
// 'unknown' and this session's closes keep failing safe on it, same as
|
|
1271
|
+
// before late enrollment existed. A target genuinely absent on disk
|
|
1272
|
+
// (no file yet) is still safe to enroll as `null` (nothing was hidden
|
|
1273
|
+
// from anyone, there is simply nothing there yet), so `existsSync`
|
|
1274
|
+
// lets that case through even though `readIfNotIgnored` also returns
|
|
1275
|
+
// null for it.
|
|
1276
|
+
//
|
|
1277
|
+
// `knownHashes` (review r5-w3 blocker 1): the root-only snapshotBase
|
|
1278
|
+
// call above (line ~999) always creates base.json before this HIT
|
|
1279
|
+
// branch runs, so this call would otherwise ALWAYS take snapshotBase's
|
|
1280
|
+
// "enroll" branch and hash `hit.hotPath`/`hit.statePath` off disk again,
|
|
1281
|
+
// a SEPARATE read from `hotRead`/`stateRead` above. A writer landing in
|
|
1282
|
+
// that window mints a base from bytes this session was never shown, and
|
|
1283
|
+
// a later close built from `hotRead.raw`/`stateRead.raw` reads
|
|
1284
|
+
// disk-equals-base and overwrites that writer's change with no park.
|
|
1285
|
+
// Passing the hash of the bytes `hotRead`/`stateRead` ALREADY read pins
|
|
1286
|
+
// the base to what this session was actually shown, not to whatever a
|
|
1287
|
+
// second, independent read happens to find.
|
|
1288
|
+
if (data.session_id) {
|
|
1289
|
+
// Test-only barrier, the other half of the enroll probe below. The
|
|
1290
|
+
// dump alone catches a call that drops `knownHashes`, but not one
|
|
1291
|
+
// that re-reads disk here and hashes that: with nothing changing the
|
|
1292
|
+
// file, the re-read and the shown bytes agree. When
|
|
1293
|
+
// HYPO_TEST_ENROLL_BARRIER is a non-empty string, the hook says so on
|
|
1294
|
+
// stderr and waits until that path exists (10s at most), which gives
|
|
1295
|
+
// a test the gap between the reads above and the hashing below to
|
|
1296
|
+
// change the file in. It only waits and reads: it writes no file,
|
|
1297
|
+
// inside the vault or out, for the reason the dump comment gives.
|
|
1298
|
+
const barrier = process.env.HYPO_TEST_ENROLL_BARRIER;
|
|
1299
|
+
if (typeof barrier === 'string' && barrier.length > 0) {
|
|
1300
|
+
process.stderr.write('[hypo-test] enroll-barrier-ready\n');
|
|
1301
|
+
const deadline = Date.now() + 10000;
|
|
1302
|
+
while (!existsSync(barrier) && Date.now() < deadline) {
|
|
1303
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 20);
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
const relPaths = [];
|
|
1307
|
+
const knownHashes = {};
|
|
1308
|
+
const hotRelPath = join('projects', hit.proj, 'hot.md');
|
|
1309
|
+
const stateRelPath = join('projects', hit.proj, 'session-state.md');
|
|
1310
|
+
if (hotRead) {
|
|
1311
|
+
relPaths.push(hotRelPath);
|
|
1312
|
+
knownHashes[hotRelPath] = hashContent(hotRead.raw);
|
|
1313
|
+
} else if (!existsSync(hit.hotPath)) {
|
|
1314
|
+
relPaths.push(hotRelPath);
|
|
1315
|
+
knownHashes[hotRelPath] = null; // observed-absent, not unread
|
|
1316
|
+
}
|
|
1317
|
+
if (stateRead) {
|
|
1318
|
+
relPaths.push(stateRelPath);
|
|
1319
|
+
knownHashes[stateRelPath] = hashContent(stateRead.raw);
|
|
1320
|
+
} else if (!existsSync(hit.statePath)) {
|
|
1321
|
+
relPaths.push(stateRelPath);
|
|
1322
|
+
knownHashes[stateRelPath] = null;
|
|
1323
|
+
}
|
|
1324
|
+
// Test-only wiring probe (review r5-w4 blocker 1,
|
|
1325
|
+
// tests/proposal-base.test.mjs). It replaced a seam that wrote
|
|
1326
|
+
// `process.env.HYPO_TEST_RACE_HOT_WRITE` straight into `hit.hotPath`
|
|
1327
|
+
// to fake a concurrent writer: every file under hooks/ is copied into
|
|
1328
|
+
// every install, that path is a real person's project hot.md, and the
|
|
1329
|
+
// guard was `!== undefined`, so an empty value truncated the file to
|
|
1330
|
+
// zero bytes. Nothing here writes inside the vault any more.
|
|
1331
|
+
//
|
|
1332
|
+
// What is left is the half the seam was actually needed for. Whether
|
|
1333
|
+
// `snapshotBase` honors a hash it is told is a property of
|
|
1334
|
+
// `snapshotBase`, and a direct unit test covers it; whether THIS call
|
|
1335
|
+
// site hands it the hashes of the bytes it just showed is only
|
|
1336
|
+
// observable here, and dropping the argument leaves every other
|
|
1337
|
+
// real-hook test green (with no writer in the gap, a fresh disk read
|
|
1338
|
+
// and the shown bytes agree). So when HYPO_TEST_ENROLL_DUMP names a
|
|
1339
|
+
// path, the arguments this call actually passes are written there as
|
|
1340
|
+
// JSON, and a test reads them back. `args`, not the local
|
|
1341
|
+
// `knownHashes`: reading the local would report the map as passed even
|
|
1342
|
+
// when the call below stopped passing it, which is precisely the
|
|
1343
|
+
// regression this exists to catch. An argument list of three shows up
|
|
1344
|
+
// as `argc: 3` with no `knownHashes` key at all.
|
|
1345
|
+
//
|
|
1346
|
+
// Best-effort, like every other cache write in these hooks: a bad
|
|
1347
|
+
// dump path must not take down a SessionStart.
|
|
1348
|
+
const enroll = (...args) => {
|
|
1349
|
+
const dumpPath = process.env.HYPO_TEST_ENROLL_DUMP;
|
|
1350
|
+
if (dumpPath) {
|
|
1351
|
+
try {
|
|
1352
|
+
writeFileSync(
|
|
1353
|
+
dumpPath,
|
|
1354
|
+
JSON.stringify({ argc: args.length, relPaths: args[2], knownHashes: args[3] }),
|
|
1355
|
+
);
|
|
1356
|
+
} catch {
|
|
1357
|
+
// ignore: the probe is diagnostics, never a precondition
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
return snapshotBase(...args);
|
|
1361
|
+
};
|
|
1362
|
+
if (relPaths.length) enroll(HYPO_DIR, data.session_id, relPaths, knownHashes);
|
|
1363
|
+
}
|
|
1009
1364
|
let hotContent = hotRead ? hotRead.shown : null;
|
|
1010
1365
|
let stateContent = stateRead ? stateRead.shown : null;
|
|
1011
1366
|
const hotMarker = staleMarkerForPath(hit.hotPath, ignorePatterns, TODAY);
|
|
@@ -1152,9 +1507,17 @@ process.stdin.on('end', () => {
|
|
|
1152
1507
|
);
|
|
1153
1508
|
// Observed-set record: the MISS branch's root hot.md injection is the one
|
|
1154
1509
|
// place a project-less session observes anything at all. Recorded AFTER
|
|
1155
|
-
// the console.log above
|
|
1510
|
+
// the console.log above. See the HIT branch's comment for why (fail-open
|
|
1156
1511
|
// ordering). Hashes `.raw`, not a fresh disk read, for the same reason
|
|
1157
1512
|
// given there.
|
|
1513
|
+
//
|
|
1514
|
+
// This call records nothing any more. overwriteTargets() stopped returning
|
|
1515
|
+
// 'hot.md' when the root pointer table became a hook-generated projection,
|
|
1516
|
+
// and recordObserved only writes for keys the base snapshot is tracking, so
|
|
1517
|
+
// the guard rejects this one every time. It is kept here, rather than
|
|
1518
|
+
// deleted, because this file is being rewritten in another lane at the same
|
|
1519
|
+
// time and a deletion would widen that merge. Delete it when the two lanes
|
|
1520
|
+
// land together.
|
|
1158
1521
|
if (data.session_id) {
|
|
1159
1522
|
recordObserved(
|
|
1160
1523
|
HYPO_DIR,
|