hypomnema 1.7.0 → 1.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.ko.md +79 -50
- package/README.md +63 -34
- package/hooks/hooks.json +11 -0
- package/hooks/hypo-auto-commit.mjs +92 -15
- package/hooks/hypo-auto-stage.mjs +28 -18
- package/hooks/hypo-close-guard.mjs +246 -0
- package/hooks/hypo-hot-rebuild.mjs +43 -5
- package/hooks/hypo-session-start.mjs +166 -13
- package/hooks/hypo-shared.mjs +1358 -130
- package/hooks/version-check.mjs +92 -0
- package/package.json +4 -1
- package/scripts/crystallize.mjs +112 -2
- package/scripts/doctor.mjs +627 -17
- package/scripts/graph.mjs +27 -12
- package/scripts/init.mjs +66 -7
- package/scripts/lib/git-hooks-dir.mjs +229 -0
- package/scripts/lib/pkg-provenance.mjs +166 -0
- package/scripts/lib/project-create.mjs +5 -1
- package/scripts/lib/rename-marker.mjs +39 -0
- package/scripts/lint.mjs +84 -2
- package/scripts/rename.mjs +223 -18
- package/scripts/stats.mjs +14 -2
- package/scripts/uninstall.mjs +12 -0
- package/scripts/upgrade.mjs +8 -0
- package/templates/gitignore +4 -0
- package/templates/hypo-config.md +1 -1
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* hypo-close-guard.mjs — PreToolUse hook
|
|
4
|
+
*
|
|
5
|
+
* SCOPE: this guard closes the direct Write/Edit/MultiEdit bypass only — it is
|
|
6
|
+
* not a general unauthorized-close catcher. A write executed via Bash (shell
|
|
7
|
+
* redirection, sed, a script) never reaches PreToolUse's tool_input inspection
|
|
8
|
+
* and is out of scope. The regular `/hypo:crystallize --apply-session-close`
|
|
9
|
+
* path runs via Bash and already validates the transcript's close signal
|
|
10
|
+
* before it writes, so it neither trips this guard nor needs to.
|
|
11
|
+
*
|
|
12
|
+
* Intercepts a Write/Edit/MultiEdit BEFORE it lands, when it targets one of the
|
|
13
|
+
* two close-artifact files (session-state.md, hot.md). doctor's
|
|
14
|
+
* detectSessionCloseArtifact (hypo-shared.mjs, post-hoc) only ever sees a file
|
|
15
|
+
* AFTER the write, and only fires on 마감/종료 vocabulary — a wordless full
|
|
16
|
+
* rewrite (the 2026-07-28 hot.md incident) reads clean to it, because there is
|
|
17
|
+
* no prior version to diff against.
|
|
18
|
+
*
|
|
19
|
+
* Here there is no such blind spot: the PRIMARY signal is structural, not
|
|
20
|
+
* lexical. recordTouchedPaths (populated by hypo-auto-stage's PostToolUse,
|
|
21
|
+
* which has already run for every earlier write this session) already tracks
|
|
22
|
+
* which close-artifact file(s) this session wrote. If the write in front of us
|
|
23
|
+
* targets one of a project's pair (projects/<slug>/session-state.md,
|
|
24
|
+
* projects/<slug>/hot.md) and the OTHER one is already in that set, this
|
|
25
|
+
* session is rewriting both — regardless of what either file's text says. The
|
|
26
|
+
* pair is scoped to the SAME project directory on purpose: pairing by basename
|
|
27
|
+
* alone would fire on the root hot.md (which every session's Stop-chain
|
|
28
|
+
* hypo-hot-rebuild.mjs legitimately rewrites) against an unrelated project's
|
|
29
|
+
* session-state.md — a false positive, not a close. Root hot.md is therefore
|
|
30
|
+
* never a structural pair member; it can still trip the lexical signal below
|
|
31
|
+
* if it is literally rewritten with 마감/종료 wording.
|
|
32
|
+
*
|
|
33
|
+
* KNOWN WINDOW: the structural signal is only alive for one turn. Stop's
|
|
34
|
+
* auto-commit chain (hypo-auto-commit.mjs → commitTouchedPaths) commits and
|
|
35
|
+
* then CLEARS a session's touched-paths set every time Stop runs. Write
|
|
36
|
+
* session-state.md in turn 1 and hot.md in turn 2 (Stop runs in between) and
|
|
37
|
+
* the touched-paths file no longer has the first path — structuralHit reads
|
|
38
|
+
* false. This is accepted, not fixed: a real close writes both files in the
|
|
39
|
+
* same turn (see the JSDoc coverage note in hypo-shared.mjs's
|
|
40
|
+
* detectSessionCloseArtifact), so the main path is still caught; a fresh
|
|
41
|
+
* cross-turn persistence store is out of this guard's scope. Once the window
|
|
42
|
+
* closes, only the lexical signal (detectSessionCloseArtifact on the write's
|
|
43
|
+
* own new text) can still catch a close. See the test that pins this window
|
|
44
|
+
* (using the real commitTouchedPaths path, not a bare drain) in
|
|
45
|
+
* tests/close-hooks-gate.test.mjs.
|
|
46
|
+
*
|
|
47
|
+
* CASE FOLDING: the basename gate and the structural pairing comparison below
|
|
48
|
+
* are lowercase-folded. macOS's default volume is case-insensitive, so a write
|
|
49
|
+
* to `projects/foo/HOT.md` targets the same file `hot.md` does, and comparing
|
|
50
|
+
* basenames verbatim would read it as "not a close-artifact file" and skip the
|
|
51
|
+
* structural check entirely. This folding covers only OUR OWN comparisons;
|
|
52
|
+
* detectSessionCloseArtifact (hypo-shared.mjs, untouched here) does its own
|
|
53
|
+
* case-sensitive basename check internally, so a case-varied write can still
|
|
54
|
+
* dodge the LEXICAL signal — but the structural signal does not depend on file
|
|
55
|
+
* content at all, so it still catches it.
|
|
56
|
+
*
|
|
57
|
+
* detectSessionCloseArtifact runs as a SECONDARY trigger regardless of the
|
|
58
|
+
* structural outcome, so a lone wordy close is caught before its pair even
|
|
59
|
+
* lands, and the two defenses share one definition of "close" instead of
|
|
60
|
+
* drifting apart.
|
|
61
|
+
*
|
|
62
|
+
* UNDECIDABLE vs BROKEN: the structural signal reads the session's
|
|
63
|
+
* touched-paths cache directly (readTouchedPathsOrUndecidable below), not via
|
|
64
|
+
* hypo-shared's peekTouchedPaths, because peekTouchedPaths collapses a lock
|
|
65
|
+
* timeout, a corrupt cache file, AND a genuinely-empty session into the exact
|
|
66
|
+
* same `[]` — indistinguishable from the caller's side. Folding all three into
|
|
67
|
+
* "no structural signal, allow" would let a wordless close slip through
|
|
68
|
+
* exactly when this guard's own bookkeeping is unreliable, which is the worst
|
|
69
|
+
* moment for it to go quiet. So an undecidable read (lock timeout / corrupt
|
|
70
|
+
* cache / no session_id) is instead treated as a HIT — it folds into the same
|
|
71
|
+
* `ask` branch as a genuine structural match. This is deliberately distinct
|
|
72
|
+
* from the hook ITSELF breaking (unparseable stdin, an unexpected exception):
|
|
73
|
+
* that still exits silently below, because a broken guard must never block
|
|
74
|
+
* the user's actual work. readTouchedPathsOrUndecidable is built from the same
|
|
75
|
+
* exported primitives peekTouchedPaths itself uses (withFileLock,
|
|
76
|
+
* touchedPathsPath) — no new read-only API was added to hypo-shared.mjs for
|
|
77
|
+
* this.
|
|
78
|
+
*
|
|
79
|
+
* NO EXPLICIT ALLOW: `permissionDecision: "allow"` is not "stay quiet" — it
|
|
80
|
+
* tells Claude Code to bypass the user's NORMAL permission prompt for this
|
|
81
|
+
* tool call outright. This hook has no matcher (see NO MATCHER below), so it
|
|
82
|
+
* runs in front of every tool call, Bash included; printing an explicit allow
|
|
83
|
+
* anywhere would auto-approve permission prompts this guard has no business
|
|
84
|
+
* touching, which is the opposite of what a "confirm before a close" guard is
|
|
85
|
+
* for. So every pass-through path below prints NOTHING and exits 0, leaving
|
|
86
|
+
* Claude Code's normal permission policy exactly as it was. Only a genuine
|
|
87
|
+
* `ask` hit ever writes to stdout.
|
|
88
|
+
*
|
|
89
|
+
* A hit is never a deny. The hook only ASKS
|
|
90
|
+
* (hookSpecificOutput.permissionDecision = "ask") — the harness turns that into
|
|
91
|
+
* a confirmation in front of the write; approval stays with the human.
|
|
92
|
+
*
|
|
93
|
+
* NO MATCHER: this hook is registered under PreToolUse with no matcher (this
|
|
94
|
+
* repo's installer does not carry matchers through to settings — see
|
|
95
|
+
* scripts/init.mjs's `_extractFileNames` — and no other hook here uses one
|
|
96
|
+
* either), so it runs on every tool call. The early-return order below exists
|
|
97
|
+
* for exactly that: a non-write tool, or a write outside HYPO_DIR, returns
|
|
98
|
+
* before anything else runs.
|
|
99
|
+
*/
|
|
100
|
+
|
|
101
|
+
import { existsSync, readFileSync } from 'fs';
|
|
102
|
+
import { relative } from 'path';
|
|
103
|
+
import {
|
|
104
|
+
HYPO_DIR,
|
|
105
|
+
detectSessionCloseArtifact,
|
|
106
|
+
hasUserCloseSignal,
|
|
107
|
+
isGateSkipped,
|
|
108
|
+
touchedPathsPath,
|
|
109
|
+
withFileLock,
|
|
110
|
+
} from './hypo-shared.mjs';
|
|
111
|
+
|
|
112
|
+
const CLOSE_ARTIFACT_BASENAMES = new Set(['session-state.md', 'hot.md']);
|
|
113
|
+
// Mirrors hypo-auto-stage.mjs's WRITE_TOOLS: the tools that replace file bytes.
|
|
114
|
+
const WRITE_TOOLS = new Set(['Write', 'Edit', 'MultiEdit']);
|
|
115
|
+
|
|
116
|
+
// The write's own new text. Write carries the whole file; Edit/MultiEdit carry
|
|
117
|
+
// only the replaced snippet(s) — good enough for detectSessionCloseArtifact,
|
|
118
|
+
// which matches a single bold heading line, not the whole document.
|
|
119
|
+
function newTextOf(toolName, toolInput) {
|
|
120
|
+
if (toolName === 'Write') {
|
|
121
|
+
return typeof toolInput?.content === 'string' ? toolInput.content : '';
|
|
122
|
+
}
|
|
123
|
+
if (toolName === 'Edit') {
|
|
124
|
+
return typeof toolInput?.new_string === 'string' ? toolInput.new_string : '';
|
|
125
|
+
}
|
|
126
|
+
if (toolName === 'MultiEdit' && Array.isArray(toolInput?.edits)) {
|
|
127
|
+
return toolInput.edits
|
|
128
|
+
.map((e) => (typeof e?.new_string === 'string' ? e.new_string : ''))
|
|
129
|
+
.join('\n');
|
|
130
|
+
}
|
|
131
|
+
return '';
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// See "UNDECIDABLE vs BROKEN" above. `{ok: true, paths}` on a clean read
|
|
135
|
+
// (including a genuinely absent file — never touched this session, not an
|
|
136
|
+
// error); `{ok: false}` when the read cannot be trusted (no session_id, a
|
|
137
|
+
// corrupt/non-array cache file, or a lock timeout).
|
|
138
|
+
function readTouchedPathsOrUndecidable(hypoDir, sessionId) {
|
|
139
|
+
if (!sessionId) return { ok: false };
|
|
140
|
+
const path = touchedPathsPath(hypoDir, sessionId);
|
|
141
|
+
try {
|
|
142
|
+
return withFileLock(path, () => {
|
|
143
|
+
if (!existsSync(path)) return { ok: true, paths: [] };
|
|
144
|
+
try {
|
|
145
|
+
const parsed = JSON.parse(readFileSync(path, 'utf-8'));
|
|
146
|
+
if (!Array.isArray(parsed)) return { ok: false }; // corrupt shape
|
|
147
|
+
return { ok: true, paths: parsed.filter((p) => typeof p === 'string' && p) };
|
|
148
|
+
} catch {
|
|
149
|
+
return { ok: false }; // corrupt/unreadable JSON
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
} catch {
|
|
153
|
+
return { ok: false }; // lock timeout
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
let input = {};
|
|
158
|
+
try {
|
|
159
|
+
const raw = await new Promise((r) => {
|
|
160
|
+
let d = '';
|
|
161
|
+
process.stdin.on('data', (c) => (d += c));
|
|
162
|
+
process.stdin.on('end', () => r(d));
|
|
163
|
+
});
|
|
164
|
+
input = JSON.parse(raw);
|
|
165
|
+
} catch (err) {
|
|
166
|
+
// The hook ITSELF failed to read its own input — stay silent (see NO
|
|
167
|
+
// EXPLICIT ALLOW above); never write a permission decision over garbage.
|
|
168
|
+
process.stderr.write(`[hypo-close-guard] error: ${err?.message ?? String(err)}\n`);
|
|
169
|
+
process.exit(0);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
try {
|
|
173
|
+
if (isGateSkipped() || !WRITE_TOOLS.has(input.tool_name)) {
|
|
174
|
+
process.exit(0);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const filePath = input.tool_input?.file_path ?? '';
|
|
178
|
+
if (!filePath || !(filePath === HYPO_DIR || filePath.startsWith(HYPO_DIR + '/'))) {
|
|
179
|
+
process.exit(0);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const rel = relative(HYPO_DIR, filePath);
|
|
183
|
+
const relParts = rel.split(/[\\/]/);
|
|
184
|
+
const base = relParts[relParts.length - 1];
|
|
185
|
+
const baseLower = base.toLowerCase();
|
|
186
|
+
if (!CLOSE_ARTIFACT_BASENAMES.has(baseLower)) {
|
|
187
|
+
process.exit(0);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Structural signal (primary): the OTHER close-artifact file of the SAME
|
|
191
|
+
// project already written this session — projects/<slug>/session-state.md
|
|
192
|
+
// paired with projects/<slug>/hot.md ONLY (case-folded). A root hot.md
|
|
193
|
+
// (relParts.length !== 3, or not under "projects/") is never a pair member.
|
|
194
|
+
const otherBaseLower = baseLower === 'hot.md' ? 'session-state.md' : 'hot.md';
|
|
195
|
+
let structuralHit = false;
|
|
196
|
+
let structuralUndecidable = false;
|
|
197
|
+
if (relParts.length === 3 && relParts[0].toLowerCase() === 'projects') {
|
|
198
|
+
const otherPathLower = `${relParts[0].toLowerCase()}/${relParts[1].toLowerCase()}/${otherBaseLower}`;
|
|
199
|
+
const touchedResult = readTouchedPathsOrUndecidable(HYPO_DIR, input.session_id);
|
|
200
|
+
if (!touchedResult.ok) {
|
|
201
|
+
structuralUndecidable = true; // see "UNDECIDABLE vs BROKEN" above
|
|
202
|
+
} else {
|
|
203
|
+
structuralHit = touchedResult.paths.some((p) => p.toLowerCase() === otherPathLower);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Lexical signal (secondary): same predicate doctor uses post-hoc, run here
|
|
208
|
+
// on the write's own new text.
|
|
209
|
+
const lexicalHit = detectSessionCloseArtifact({
|
|
210
|
+
path: filePath,
|
|
211
|
+
content: newTextOf(input.tool_name, input.tool_input),
|
|
212
|
+
}).matched;
|
|
213
|
+
|
|
214
|
+
if (!structuralHit && !structuralUndecidable && !lexicalHit) {
|
|
215
|
+
process.exit(0);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (hasUserCloseSignal(input.transcript_path ?? null)) {
|
|
219
|
+
process.exit(0);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const why = structuralUndecidable
|
|
223
|
+
? `whether session-state.md and hot.md are both being rewritten this session could not be determined (touched-paths cache unreadable or no session_id)`
|
|
224
|
+
: structuralHit
|
|
225
|
+
? `both session-state.md and hot.md are being rewritten this session`
|
|
226
|
+
: `this write reads as a close announcement (마감/종료 wording)`;
|
|
227
|
+
|
|
228
|
+
console.log(
|
|
229
|
+
JSON.stringify({
|
|
230
|
+
continue: true,
|
|
231
|
+
hookSpecificOutput: {
|
|
232
|
+
hookEventName: 'PreToolUse',
|
|
233
|
+
permissionDecision: 'ask',
|
|
234
|
+
permissionDecisionReason:
|
|
235
|
+
`[WIKI CLOSE GUARD] ${rel} — ${why}, but no user close signal was seen ` +
|
|
236
|
+
`in this session. Confirm with the user before writing: did they actually ` +
|
|
237
|
+
`ask to close the session?\n` +
|
|
238
|
+
`To bypass: set HYPO_SKIP_GATE=1`,
|
|
239
|
+
},
|
|
240
|
+
}),
|
|
241
|
+
);
|
|
242
|
+
} catch (err) {
|
|
243
|
+
// The hook ITSELF broke (unexpected exception) — stay silent, same as the
|
|
244
|
+
// stdin-parse failure above: a broken guard must never block real work.
|
|
245
|
+
process.stderr.write(`[hypo-close-guard] error: ${err?.message ?? String(err)}\n`);
|
|
246
|
+
}
|
|
@@ -17,11 +17,31 @@ import {
|
|
|
17
17
|
computeSessionGrowth,
|
|
18
18
|
formatGrowthMetrics,
|
|
19
19
|
deriveRootLogEntries,
|
|
20
|
+
recordTouchedPaths,
|
|
20
21
|
} from './hypo-shared.mjs';
|
|
21
22
|
|
|
22
23
|
const HOT_PATH = join(HYPO_DIR, 'hot.md');
|
|
23
24
|
const GROWTH_CACHE = join(HYPO_DIR, '.cache', 'last-session-growth.json');
|
|
24
25
|
|
|
26
|
+
// This Stop hook runs BEFORE hypo-auto-commit and can write hot.md
|
|
27
|
+
// (rebuild) and log.md (deriveRootLogEntries), both hook-generated, not user
|
|
28
|
+
// Write/Edit, so hypo-auto-stage never sees them. Read session_id off stdin so
|
|
29
|
+
// whatever this hook writes still lands in the scoped commit's set; without
|
|
30
|
+
// this, a scope built from Write/Edit alone would silently drop these files
|
|
31
|
+
// from every session's auto-commit.
|
|
32
|
+
let sessionId = null;
|
|
33
|
+
try {
|
|
34
|
+
const raw = await new Promise((r) => {
|
|
35
|
+
let d = '';
|
|
36
|
+
process.stdin.on('data', (c) => (d += c));
|
|
37
|
+
process.stdin.on('end', () => r(d));
|
|
38
|
+
});
|
|
39
|
+
const payload = JSON.parse(raw || '{}') || {};
|
|
40
|
+
sessionId = payload.session_id || payload.sessionId || null;
|
|
41
|
+
} catch {
|
|
42
|
+
sessionId = null;
|
|
43
|
+
}
|
|
44
|
+
|
|
25
45
|
function parseFrontmatter(content) {
|
|
26
46
|
const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
27
47
|
if (!m) return {};
|
|
@@ -52,12 +72,13 @@ function parsePointerRows(content) {
|
|
|
52
72
|
return rows;
|
|
53
73
|
}
|
|
54
74
|
|
|
75
|
+
/** @returns {boolean} true when hot.md was actually rewritten. */
|
|
55
76
|
function rebuild() {
|
|
56
|
-
if (!existsSync(HOT_PATH)) return;
|
|
77
|
+
if (!existsSync(HOT_PATH)) return false;
|
|
57
78
|
|
|
58
79
|
const current = readFileSync(HOT_PATH, 'utf-8');
|
|
59
80
|
const rows = parsePointerRows(current);
|
|
60
|
-
if (rows.length === 0) return;
|
|
81
|
+
if (rows.length === 0) return false;
|
|
61
82
|
|
|
62
83
|
const today = new Date().toISOString().slice(0, 10);
|
|
63
84
|
|
|
@@ -93,7 +114,11 @@ ${tableRows}
|
|
|
93
114
|
3. Read \`projects/<name>/hot.md\` for project background
|
|
94
115
|
`;
|
|
95
116
|
|
|
96
|
-
if (canonical !== current)
|
|
117
|
+
if (canonical !== current) {
|
|
118
|
+
writeFileSync(HOT_PATH, canonical);
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
return false;
|
|
97
122
|
}
|
|
98
123
|
|
|
99
124
|
function emitGrowth() {
|
|
@@ -107,16 +132,18 @@ function emitGrowth() {
|
|
|
107
132
|
} catch {}
|
|
108
133
|
}
|
|
109
134
|
|
|
135
|
+
let hotWritten = false;
|
|
110
136
|
try {
|
|
111
|
-
rebuild();
|
|
137
|
+
hotWritten = rebuild();
|
|
112
138
|
} catch (err) {
|
|
113
139
|
process.stderr.write(`[hypo-hot-rebuild] error: ${err?.message ?? String(err)}\n`);
|
|
114
140
|
}
|
|
115
141
|
// Auto-derive the root log.md session entry from each project's session-log
|
|
116
142
|
// heading (runs AFTER rebuild() so root hot.md is already fresh and isn't itself
|
|
117
143
|
// counted as the project's open gate problem). Best-effort: own try/catch.
|
|
144
|
+
let logEntriesAdded = 0;
|
|
118
145
|
try {
|
|
119
|
-
deriveRootLogEntries(HYPO_DIR);
|
|
146
|
+
logEntriesAdded = deriveRootLogEntries(HYPO_DIR);
|
|
120
147
|
} catch (err) {
|
|
121
148
|
process.stderr.write(`[hypo-hot-rebuild] log-derive error: ${err?.message ?? String(err)}\n`);
|
|
122
149
|
}
|
|
@@ -126,6 +153,17 @@ try {
|
|
|
126
153
|
process.stderr.write(`[hypo-hot-rebuild] error: ${err?.message ?? String(err)}\n`);
|
|
127
154
|
}
|
|
128
155
|
|
|
156
|
+
// Feed this hook's own writes into the session's scoped auto-commit
|
|
157
|
+
// set (see the sessionId comment above). No-op without a session_id.
|
|
158
|
+
try {
|
|
159
|
+
const touched = [];
|
|
160
|
+
if (hotWritten) touched.push('hot.md');
|
|
161
|
+
if (logEntriesAdded > 0) touched.push('log.md');
|
|
162
|
+
if (touched.length > 0) recordTouchedPaths(HYPO_DIR, sessionId, touched);
|
|
163
|
+
} catch (err) {
|
|
164
|
+
process.stderr.write(`[hypo-hot-rebuild] touched-paths error: ${err?.message ?? String(err)}\n`);
|
|
165
|
+
}
|
|
166
|
+
|
|
129
167
|
try {
|
|
130
168
|
console.log(JSON.stringify({ continue: true, suppressOutput: true }));
|
|
131
169
|
} catch {}
|
|
@@ -19,6 +19,8 @@ import {
|
|
|
19
19
|
formatGrowthMetrics,
|
|
20
20
|
readSyncState,
|
|
21
21
|
clearSyncState,
|
|
22
|
+
recordSyncSuccess,
|
|
23
|
+
classifySyncOp,
|
|
22
24
|
readClearMarker,
|
|
23
25
|
clearClearMarker,
|
|
24
26
|
loadHypoIgnore,
|
|
@@ -35,6 +37,8 @@ import {
|
|
|
35
37
|
currentDevice,
|
|
36
38
|
scopeVisible,
|
|
37
39
|
readVisibilityScope,
|
|
40
|
+
pkgRootDriftStatus,
|
|
41
|
+
PKG_ROOT,
|
|
38
42
|
} from './hypo-shared.mjs';
|
|
39
43
|
import {
|
|
40
44
|
defaultCachePath,
|
|
@@ -48,6 +52,12 @@ import {
|
|
|
48
52
|
computeSiblingNotice,
|
|
49
53
|
siblingAlreadyNotified,
|
|
50
54
|
markSiblingNotified,
|
|
55
|
+
pkgRootDriftAlreadyNotified,
|
|
56
|
+
markPkgRootDriftNotified,
|
|
57
|
+
clearPkgRootDriftNotified,
|
|
58
|
+
pkgRootNullAlreadyNotified,
|
|
59
|
+
markPkgRootNullNotified,
|
|
60
|
+
clearPkgRootNullNotified,
|
|
51
61
|
} from './version-check.mjs';
|
|
52
62
|
import { snapshotBase, overwriteTargets } from './base-store.mjs';
|
|
53
63
|
import { listProposals } from './proposal-store.mjs';
|
|
@@ -214,6 +224,94 @@ function buildSiblingNotice() {
|
|
|
214
224
|
}
|
|
215
225
|
}
|
|
216
226
|
|
|
227
|
+
/**
|
|
228
|
+
* pkgRoot drift notice. hypo-shared.mjs's resolvePkgRoot() already
|
|
229
|
+
* self-corrects PKG_ROOT in memory whenever the code's own resolved location
|
|
230
|
+
* disagrees with the cached hypo-pkg.json — but silent self-correction is the
|
|
231
|
+
* exact failure this closes: the user's own `upgrade` habit stops mattering
|
|
232
|
+
* and nothing ever tells them hypo-pkg.json fell behind. Surfaced once per
|
|
233
|
+
* (cached → self-location) pair via the same notify-once cache the sibling
|
|
234
|
+
* notice above uses — a fresh drift (new self-location) re-notifies, but
|
|
235
|
+
* staying on the same drifted state doesn't nag every session.
|
|
236
|
+
*
|
|
237
|
+
* Tri-state (pkgRootDriftStatus): 'match' CLEARS any earlier mark (checked
|
|
238
|
+
* FIRST, unconditionally — even under opt-out, so a drift that resolves while
|
|
239
|
+
* opted out doesn't leave a stale mark that then suppresses a genuine
|
|
240
|
+
* recurrence once opt-out is lifted); 'unknown' touches nothing (self-location
|
|
241
|
+
* could not be resolved this session — the permanent steady state for the
|
|
242
|
+
* npm/manual channel, not evidence either way); only 'drift' can produce a
|
|
243
|
+
* banner, and opt-out is checked there so an opted-out session never marks a
|
|
244
|
+
* pair as notified it never actually showed.
|
|
245
|
+
*/
|
|
246
|
+
function buildPkgRootDriftNotice() {
|
|
247
|
+
try {
|
|
248
|
+
const status = pkgRootDriftStatus();
|
|
249
|
+
const cachePath = defaultCachePath();
|
|
250
|
+
if (status.status === 'match') {
|
|
251
|
+
clearPkgRootDriftNotified(cachePath);
|
|
252
|
+
return '';
|
|
253
|
+
}
|
|
254
|
+
if (status.status === 'unknown') return '';
|
|
255
|
+
if (isOptedOut()) return '';
|
|
256
|
+
const key = `${status.cached || '(none)'}->${status.self}`;
|
|
257
|
+
const cache = readCache(cachePath);
|
|
258
|
+
if (pkgRootDriftAlreadyNotified(cache, key)) return '';
|
|
259
|
+
markPkgRootDriftNotified(cachePath, key);
|
|
260
|
+
return (
|
|
261
|
+
`[Hypomnema] Package metadata drift: hypo-pkg.json still points at ` +
|
|
262
|
+
`\`${status.cached || '(none)'}\`, but the code actually running resolves to ` +
|
|
263
|
+
`\`${status.self}\`.\n` +
|
|
264
|
+
` Hooks already resolved the correct root for this session — this is a ` +
|
|
265
|
+
`heads-up, not a blocker.\n` +
|
|
266
|
+
` → run \`/hypo:upgrade --apply\` to bring hypo-pkg.json back in sync.`
|
|
267
|
+
);
|
|
268
|
+
} catch {
|
|
269
|
+
return '';
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* PKG_ROOT-null notice. A different failure than the drift banner above:
|
|
275
|
+
* drift only fires when self-location DID resolve (PKG_ROOT is non-null,
|
|
276
|
+
* just disagreeing with the cache). This fires when hooks/hypo-shared.mjs's
|
|
277
|
+
* resolvePkgRoot() came up with nothing at all — self-location failed AND no
|
|
278
|
+
* verified provenance sidecar covered it — which is exactly the state where
|
|
279
|
+
* PreCompact's lint/feedback calls silently no-op (they have no root to
|
|
280
|
+
* shell scripts through). The two conditions cannot both hold in the same
|
|
281
|
+
* session (drift requires a non-null self-location), so there is no overlap
|
|
282
|
+
* to arbitrate — they use separate notify-once cache fields regardless, so
|
|
283
|
+
* neither one depends on that being true forever.
|
|
284
|
+
*
|
|
285
|
+
* Same notify-once shape as the drift banner: shown once, cleared as soon as
|
|
286
|
+
* PKG_ROOT resolves again so a later recurrence re-notifies instead of
|
|
287
|
+
* staying suppressed by a mark from a different install state.
|
|
288
|
+
*/
|
|
289
|
+
function buildPkgRootNullNotice() {
|
|
290
|
+
try {
|
|
291
|
+
const cachePath = defaultCachePath();
|
|
292
|
+
if (PKG_ROOT) {
|
|
293
|
+
clearPkgRootNullNotified(cachePath);
|
|
294
|
+
return '';
|
|
295
|
+
}
|
|
296
|
+
if (isOptedOut()) return '';
|
|
297
|
+
const cache = readCache(cachePath);
|
|
298
|
+
if (pkgRootNullAlreadyNotified(cache)) return '';
|
|
299
|
+
markPkgRootNullNotified(cachePath);
|
|
300
|
+
return (
|
|
301
|
+
`[Hypomnema] Package root unresolved: this install's hooks cannot locate ` +
|
|
302
|
+
`their own package, so PreCompact's lint/feedback checks are silently ` +
|
|
303
|
+
`skipped this session.\n` +
|
|
304
|
+
` → run \`hypomnema upgrade --apply\` to sync this install's hook copies ` +
|
|
305
|
+
`with the current package (the \`/hypo:upgrade --apply\` slash command does ` +
|
|
306
|
+
`the same thing, where slash commands were installed). \`/hypo:init\` will ` +
|
|
307
|
+
`NOT fix this — it skips every hook file that already exists.\n` +
|
|
308
|
+
` → or run \`hypomnema doctor\` to see what's missing.`
|
|
309
|
+
);
|
|
310
|
+
} catch {
|
|
311
|
+
return '';
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
217
315
|
const PROJECTS_DIR = join(HYPO_DIR, 'projects');
|
|
218
316
|
const GROWTH_CACHE = join(HYPO_DIR, '.cache', 'last-session-growth.json');
|
|
219
317
|
|
|
@@ -253,14 +351,22 @@ function buildClearRecoveryLine(source) {
|
|
|
253
351
|
);
|
|
254
352
|
}
|
|
255
353
|
|
|
256
|
-
/**
|
|
354
|
+
/**
|
|
355
|
+
* Pull the wiki repo. Returns true only when the pull actually succeeded. On
|
|
356
|
+
* success, also records the last-success timestamp (silently — no notice; the
|
|
357
|
+
* existing failure notice below is unchanged) so doctor never reports "never
|
|
358
|
+
* synced" right after a healthy startup pull, even when no auto-commit Stop
|
|
359
|
+
* hook has run yet this session.
|
|
360
|
+
*/
|
|
257
361
|
function gitPull(dir) {
|
|
258
362
|
if (!existsSync(join(dir, '.git'))) return false;
|
|
259
363
|
const r = spawnSync('git', ['-C', dir, 'pull', '--ff-only', '--quiet'], {
|
|
260
364
|
stdio: 'pipe',
|
|
261
365
|
timeout: 10000,
|
|
262
366
|
});
|
|
263
|
-
|
|
367
|
+
const ok = r.status === 0;
|
|
368
|
+
if (ok) recordSyncSuccess(dir, 'pull');
|
|
369
|
+
return ok;
|
|
264
370
|
}
|
|
265
371
|
|
|
266
372
|
/**
|
|
@@ -274,7 +380,9 @@ function gitPull(dir) {
|
|
|
274
380
|
* fresh `hypo init` wiki does not git-ignore `.cache/`, so a broader cleanliness
|
|
275
381
|
* check would see the sync-state file itself and never clear.
|
|
276
382
|
*
|
|
277
|
-
* @returns {string} a `[WIKI: last sync failed: ...]`
|
|
383
|
+
* @returns {string} a `[WIKI: last sync failed: ...]` (or, for a conflict/
|
|
384
|
+
* conflict-unresolved entry, dedicated manual-merge guidance) line, or ''
|
|
385
|
+
* when clear.
|
|
278
386
|
*/
|
|
279
387
|
function syncStateNotice(pullOk) {
|
|
280
388
|
const { entries, parseError } = readSyncState(HYPO_DIR);
|
|
@@ -295,13 +403,44 @@ function syncStateNotice(pullOk) {
|
|
|
295
403
|
return '';
|
|
296
404
|
}
|
|
297
405
|
const last = entries[entries.length - 1];
|
|
298
|
-
|
|
406
|
+
// classifySyncOp (hypo-shared.mjs) is the single judgment both this hook
|
|
407
|
+
// and doctor.mjs's checkSyncState branch on, so the two surfaces cannot
|
|
408
|
+
// silently diverge on WHICH op gets which treatment: this exact check used
|
|
409
|
+
// to be an exact `=== 'conflict'` comparison here that missed
|
|
410
|
+
// 'conflict-unresolved' — the MORE dangerous op, since the abort itself
|
|
411
|
+
// failed and the tree may still be half-merged — while doctor already
|
|
412
|
+
// caught it via startsWith('conflict').
|
|
413
|
+
const cls = classifySyncOp(last.op);
|
|
414
|
+
if (cls === 'conflict-unresolved') {
|
|
415
|
+
return (
|
|
416
|
+
`[WIKI: remote diverged AND the automatic merge-abort failed — the working ` +
|
|
417
|
+
`tree may still be half-merged (unmerged paths or an in-progress merge). ` +
|
|
418
|
+
`Do NOT commit or push yet. Inspect \`git -C ${HYPO_DIR} status\` first: if a ` +
|
|
419
|
+
`merge is in progress, resolve the conflicts, then \`git -C ${HYPO_DIR} add <resolved paths>\` ` +
|
|
420
|
+
`and \`git -C ${HYPO_DIR} commit\` (git refuses a commit while unmerged entries remain staged) ` +
|
|
421
|
+
`— or run \`git -C ${HYPO_DIR} merge --abort\` to discard it instead, before continuing.]`
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
if (cls === 'conflict') {
|
|
299
425
|
return (
|
|
300
426
|
`[WIKI: remote diverged — auto-merge was aborted to protect your edits ` +
|
|
301
427
|
`(your local work is committed and safe; the other machine's version is on the remote). ` +
|
|
302
428
|
`Resolve manually: \`git -C ${HYPO_DIR} pull --no-rebase\`, fix conflicts, then push.]`
|
|
303
429
|
);
|
|
304
430
|
}
|
|
431
|
+
// An unrecognized `conflict*` op — some future syncRemote failure mode this
|
|
432
|
+
// hook has no dedicated branch for. Neither the clean-conflict claim above
|
|
433
|
+
// ("committed and safe") nor the conflict-unresolved claim ("the abort
|
|
434
|
+
// failed") is known to be true here, so assert neither: say plainly that
|
|
435
|
+
// the state is unknown and treat it as unresolved until a human checks.
|
|
436
|
+
if (cls === 'unknown-conflict') {
|
|
437
|
+
return (
|
|
438
|
+
`[WIKI: remote diverged — an unrecognized conflict-related sync failure was recorded ` +
|
|
439
|
+
`(op='${last.op}'). Its resolution state cannot be confirmed automatically, so treat it ` +
|
|
440
|
+
`as unresolved: do NOT commit or push yet. Inspect \`git -C ${HYPO_DIR} status\` first for ` +
|
|
441
|
+
`unmerged paths or an in-progress merge before continuing.]`
|
|
442
|
+
);
|
|
443
|
+
}
|
|
305
444
|
return `[WIKI: last sync failed: ${last.op || '?'} — ${last.error || 'unknown'}]`;
|
|
306
445
|
}
|
|
307
446
|
/**
|
|
@@ -406,15 +545,25 @@ process.stdin.on('end', () => {
|
|
|
406
545
|
const clearRecoveryLine = buildClearRecoveryLine(data.source);
|
|
407
546
|
const updateLine = buildUpdateNotice();
|
|
408
547
|
const siblingLine = buildSiblingNotice();
|
|
409
|
-
|
|
410
|
-
//
|
|
411
|
-
//
|
|
412
|
-
//
|
|
413
|
-
//
|
|
414
|
-
//
|
|
415
|
-
|
|
416
|
-
//
|
|
417
|
-
|
|
548
|
+
const pkgDriftLine = buildPkgRootDriftNotice();
|
|
549
|
+
// pkgDriftLine and pkgNullLine can never both be non-empty in the same
|
|
550
|
+
// session: drift requires PKG_ROOT to have resolved (non-null) via
|
|
551
|
+
// self-location, while the null notice fires exactly when it did not.
|
|
552
|
+
// Listed together below on that basis, not because one is chosen over
|
|
553
|
+
// the other.
|
|
554
|
+
const pkgNullLine = buildPkgRootNullNotice();
|
|
555
|
+
// The update + stale-sibling + pkgRoot-drift/null banners must reach the
|
|
556
|
+
// USER. On a SessionStart hook that exits 0, stderr is invisible in the
|
|
557
|
+
// normal TUI (only shown on exit 2 / --verbose) and additionalContext is
|
|
558
|
+
// model-only — `systemMessage` is the documented user-visible channel.
|
|
559
|
+
// Route those banners there. They ALSO stay in noticePrefix →
|
|
560
|
+
// additionalContext below, so the model and the user start the session
|
|
561
|
+
// looking at the same state. (The other stderr notices —
|
|
562
|
+
// sync/growth/clear/suggest — are intentionally transcript/--verbose only
|
|
563
|
+
// and out of this banner's scope.)
|
|
564
|
+
const userMessage = [updateLine, siblingLine, pkgDriftLine, pkgNullLine]
|
|
565
|
+
.filter(Boolean)
|
|
566
|
+
.join('\n\n');
|
|
418
567
|
if (userMessage) outExtra = { ...outExtra, systemMessage: userMessage };
|
|
419
568
|
const notices = [
|
|
420
569
|
syncLine,
|
|
@@ -423,6 +572,8 @@ process.stdin.on('end', () => {
|
|
|
423
572
|
clearRecoveryLine,
|
|
424
573
|
updateLine,
|
|
425
574
|
siblingLine,
|
|
575
|
+
pkgDriftLine,
|
|
576
|
+
pkgNullLine,
|
|
426
577
|
].filter(Boolean);
|
|
427
578
|
let noticePrefix = notices.length ? `${notices.join('\n\n')}\n\n` : '';
|
|
428
579
|
if (syncLine) process.stderr.write(`\n\x1b[33m${syncLine}\x1b[0m\n`);
|
|
@@ -432,6 +583,8 @@ process.stdin.on('end', () => {
|
|
|
432
583
|
process.stderr.write(`\n\x1b[33m${clearRecoveryLine.split('\n')[0]}\x1b[0m\n`);
|
|
433
584
|
if (updateLine) process.stderr.write(`\n\x1b[33m${updateLine}\x1b[0m\n`);
|
|
434
585
|
if (siblingLine) process.stderr.write(`\n\x1b[33m${siblingLine}\x1b[0m\n`);
|
|
586
|
+
if (pkgDriftLine) process.stderr.write(`\n\x1b[33m${pkgDriftLine}\x1b[0m\n`);
|
|
587
|
+
if (pkgNullLine) process.stderr.write(`\n\x1b[33m${pkgNullLine}\x1b[0m\n`);
|
|
435
588
|
const cwd = data.cwd || data.directory || process.cwd();
|
|
436
589
|
const sessionId = data.session_id || 'default';
|
|
437
590
|
const MARKER_FILE = sessionMarkerPath(sessionId);
|