hypomnema 1.6.2 → 1.7.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/README.ko.md +39 -14
- package/README.md +39 -14
- package/commands/capture.md +8 -6
- package/commands/crystallize.md +39 -20
- package/docs/ARCHITECTURE.md +49 -14
- package/docs/CONTRIBUTING.md +31 -29
- package/hooks/base-store.mjs +265 -0
- package/hooks/hooks.json +7 -1
- package/hooks/hypo-auto-minimal-crystallize.mjs +63 -20
- package/hooks/hypo-auto-stage.mjs +30 -0
- package/hooks/hypo-cwd-change.mjs +31 -2
- package/hooks/hypo-file-watch.mjs +21 -2
- package/hooks/hypo-first-prompt.mjs +19 -3
- package/hooks/hypo-lookup.mjs +86 -29
- package/hooks/hypo-personal-check.mjs +24 -3
- package/hooks/hypo-session-record.mjs +2 -3
- package/hooks/hypo-session-start.mjs +89 -7
- package/hooks/hypo-shared.mjs +904 -128
- package/hooks/proposal-store.mjs +513 -0
- package/package.json +40 -14
- package/scripts/capture.mjs +556 -37
- package/scripts/crystallize.mjs +639 -108
- package/scripts/doctor.mjs +304 -9
- package/scripts/feedback-sync.mjs +515 -44
- package/scripts/graph.mjs +9 -2
- package/scripts/init.mjs +230 -34
- package/scripts/lib/extensions.mjs +656 -1
- package/scripts/lib/hypo-ignore.mjs +54 -6
- package/scripts/lib/hypo-root.mjs +56 -6
- package/scripts/lib/page-usage.mjs +15 -2
- package/scripts/lib/pkg-json.mjs +40 -0
- package/scripts/lib/plugin-detect.mjs +96 -6
- package/scripts/lib/wd-match.mjs +23 -5
- package/scripts/lib/wikilink.mjs +32 -6
- package/scripts/lint.mjs +20 -4
- package/scripts/proposal.mjs +1032 -0
- package/scripts/query.mjs +25 -4
- package/scripts/resume.mjs +34 -12
- package/scripts/stats.mjs +28 -8
- package/scripts/uninstall.mjs +141 -6
- package/scripts/upgrade.mjs +197 -15
- package/skills/crystallize/SKILL.md +44 -7
- package/skills/debate/SKILL.md +88 -0
- package/skills/debate/references/orchestration-patterns.md +83 -0
- package/templates/.hyposcanignore +10 -0
- package/templates/SCHEMA.md +12 -0
- package/templates/gitignore +5 -0
- package/templates/hypo-config.md +1 -1
- package/templates/hypo-guide.md +6 -0
- package/scripts/.gitkeep +0 -0
- package/scripts/check-bilingual.mjs +0 -153
- package/scripts/check-readme-version.mjs +0 -126
- package/scripts/check-tracker-ids.mjs +0 -426
- package/scripts/check-versions.mjs +0 -171
- package/scripts/install-git-hooks.mjs +0 -293
- package/scripts/lib/changelog-classify.mjs +0 -216
- package/scripts/lib/check-bilingual.mjs +0 -244
- package/scripts/lib/check-tracker-ids.mjs +0 -217
- package/scripts/lib/pre-commit-format.mjs +0 -251
- package/scripts/pre-commit-format.mjs +0 -198
|
@@ -6,7 +6,17 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { spawnSync } from 'child_process';
|
|
9
|
+
import { relative } from 'path';
|
|
9
10
|
import { HYPO_DIR, loadHypoIgnore, isIgnored } from './hypo-shared.mjs';
|
|
11
|
+
import { advanceBaseForWrite, hashContent } from './base-store.mjs';
|
|
12
|
+
|
|
13
|
+
// Tools that REPLACE file bytes. The base advance below must fire only for these:
|
|
14
|
+
// this hook has no matcher in hooks.json (it runs on every PostToolUse), and a
|
|
15
|
+
// read-only tool like Read also carries `tool_input.file_path`. Without this
|
|
16
|
+
// allowlist, merely Reading a target another session had drifted would advance
|
|
17
|
+
// the base to that other session's bytes — silently defeating the write=proposal
|
|
18
|
+
// guard at close. tool_name, not file_path, is the write signal.
|
|
19
|
+
const WRITE_TOOLS = new Set(['Write', 'Edit', 'MultiEdit']);
|
|
10
20
|
|
|
11
21
|
let input = {};
|
|
12
22
|
try {
|
|
@@ -29,6 +39,26 @@ if (filePath.startsWith(HYPO_DIR + '/') || filePath === HYPO_DIR) {
|
|
|
29
39
|
if (patterns.length === 0 || !isIgnored(filePath, HYPO_DIR, patterns)) {
|
|
30
40
|
spawnSync('git', ['-C', HYPO_DIR, 'add', filePath], { stdio: 'ignore' });
|
|
31
41
|
}
|
|
42
|
+
|
|
43
|
+
// Write=proposal gate provenance: when this session's own write lands on one of
|
|
44
|
+
// the overwrite targets it snapshotted at start, advance that target's base so
|
|
45
|
+
// the close guard reads the change as "I wrote this", not "someone else did"
|
|
46
|
+
// (which would fail safe into a false proposal against the session's own edit).
|
|
47
|
+
// Self-scoping — a no-op unless the path is a tracked base key — so it runs
|
|
48
|
+
// regardless of .hypoignore (provenance is independent of privacy). Best-effort.
|
|
49
|
+
//
|
|
50
|
+
// The Write tool carries its full `content`, so advance to the bytes THIS
|
|
51
|
+
// session wrote (race-safe: a concurrent write landing between the tool and
|
|
52
|
+
// this hook cannot be adopted as our base). Edit/MultiEdit have no full content
|
|
53
|
+
// in the payload, so they fall back to a post-write disk read.
|
|
54
|
+
if (WRITE_TOOLS.has(input.tool_name) && input.session_id) {
|
|
55
|
+
const rel = relative(HYPO_DIR, filePath);
|
|
56
|
+
const known =
|
|
57
|
+
input.tool_name === 'Write' && typeof input.tool_input?.content === 'string'
|
|
58
|
+
? hashContent(input.tool_input.content)
|
|
59
|
+
: null;
|
|
60
|
+
advanceBaseForWrite(HYPO_DIR, input.session_id, rel, filePath, known);
|
|
61
|
+
}
|
|
32
62
|
}
|
|
33
63
|
|
|
34
64
|
console.log(JSON.stringify({ continue: true, suppressOutput: true }));
|
|
@@ -24,6 +24,9 @@ import {
|
|
|
24
24
|
pickProjectByCwd,
|
|
25
25
|
collectProjectWorkingDirs,
|
|
26
26
|
buildVaultOrientation,
|
|
27
|
+
currentDevice,
|
|
28
|
+
scopeVisible,
|
|
29
|
+
readVisibilityScope,
|
|
27
30
|
} from './hypo-shared.mjs';
|
|
28
31
|
|
|
29
32
|
const PROJECTS_DIR = join(HYPO_DIR, 'projects');
|
|
@@ -32,10 +35,32 @@ const MAX_CHARS = 3000;
|
|
|
32
35
|
|
|
33
36
|
// Privacy guard: a .hypoignore-matched hot.md must not be
|
|
34
37
|
// re-emitted into additionalContext on cwd change.
|
|
38
|
+
//
|
|
39
|
+
// Visibility guard: same contract as hypo-file-watch and hypo-session-start. A
|
|
40
|
+
// machine-scoped hot.md stays off every machine but its owner. Scope is read
|
|
41
|
+
// from the RAW content before the MAX_CHARS slice, since slicing first could cut
|
|
42
|
+
// the frontmatter off and fail open. The root hot.md carries no frontmatter, so
|
|
43
|
+
// it reads as '' and passes as shared.
|
|
35
44
|
function readIfNotIgnored(path, patterns) {
|
|
36
45
|
if (!path) return null;
|
|
37
46
|
if (patterns.length > 0 && isIgnored(path, HYPO_DIR, patterns)) return null;
|
|
38
|
-
|
|
47
|
+
const raw = readFileSync(path, 'utf-8');
|
|
48
|
+
if (!scopeVisible(readVisibilityScope(raw), currentDevice())) return null;
|
|
49
|
+
return raw.slice(0, MAX_CHARS);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Scoped-out is not absent. Both make readIfNotIgnored return null, but the
|
|
53
|
+
// absent-case placeholder tells the model the file "will be created at session
|
|
54
|
+
// close". On a foreign machine that invites it to author over a hot.md that
|
|
55
|
+
// exists and belongs elsewhere. Report the fact, never the withheld body.
|
|
56
|
+
function isScopedOut(path, patterns) {
|
|
57
|
+
try {
|
|
58
|
+
if (!path || !existsSync(path)) return false;
|
|
59
|
+
if (patterns.length > 0 && isIgnored(path, HYPO_DIR, patterns)) return false;
|
|
60
|
+
return !scopeVisible(readVisibilityScope(readFileSync(path, 'utf-8')), currentDevice());
|
|
61
|
+
} catch {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
39
64
|
}
|
|
40
65
|
|
|
41
66
|
function findProjectHot(cwd) {
|
|
@@ -87,7 +112,11 @@ process.stdin.on('end', () => {
|
|
|
87
112
|
|
|
88
113
|
if (newHit) {
|
|
89
114
|
const fromFile = readIfNotIgnored(newHit.hotPath, ignorePatterns);
|
|
90
|
-
const content =
|
|
115
|
+
const content =
|
|
116
|
+
fromFile ??
|
|
117
|
+
(isScopedOut(newHit.hotPath, ignorePatterns)
|
|
118
|
+
? '(hot.md for this project is scoped to another machine and is not visible here)'
|
|
119
|
+
: '(no hot.md yet — will be created at session close)');
|
|
91
120
|
// arm the first-prompt marker so the NEXT user prompt re-triggers
|
|
92
121
|
// hypo-first-prompt, which forces a "Resuming <project>" summary line.
|
|
93
122
|
// Only arm when real hot content was actually injected — if hot.md is
|
|
@@ -8,7 +8,14 @@
|
|
|
8
8
|
|
|
9
9
|
import { readFileSync, existsSync } from 'fs';
|
|
10
10
|
import { join } from 'path';
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
HYPO_DIR,
|
|
13
|
+
loadHypoIgnore,
|
|
14
|
+
isIgnored,
|
|
15
|
+
currentDevice,
|
|
16
|
+
scopeVisible,
|
|
17
|
+
readVisibilityScope,
|
|
18
|
+
} from './hypo-shared.mjs';
|
|
12
19
|
|
|
13
20
|
const MAX_CHARS = 2000;
|
|
14
21
|
|
|
@@ -43,7 +50,19 @@ process.stdin.on('end', () => {
|
|
|
43
50
|
return;
|
|
44
51
|
}
|
|
45
52
|
|
|
46
|
-
const
|
|
53
|
+
const fileRaw = readFileSync(filePath, 'utf-8');
|
|
54
|
+
|
|
55
|
+
// Visibility guard: a machine-scoped page (visibility_scope: machine:<owner>)
|
|
56
|
+
// must not be re-injected on a machine other than its owner. Read the scope
|
|
57
|
+
// from the raw, unsliced content — slicing to MAX_CHARS first could cut the
|
|
58
|
+
// frontmatter off and silently make every scoped page pass fail-open.
|
|
59
|
+
const scope = readVisibilityScope(fileRaw);
|
|
60
|
+
if (!scopeVisible(scope, currentDevice())) {
|
|
61
|
+
console.log(JSON.stringify({ continue: true, suppressOutput: true }));
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const content = fileRaw.slice(0, MAX_CHARS);
|
|
47
66
|
const relPath = filePath.replace(HYPO_DIR + '/', '');
|
|
48
67
|
|
|
49
68
|
console.log(
|
|
@@ -48,7 +48,17 @@ process.stdin.on('end', () => {
|
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
const hasSnapshot = marker.hasSnapshot ?? (marker.hotPath && existsSync(marker.hotPath));
|
|
51
|
-
|
|
51
|
+
// A snapshot withheld by visibility_scope is NOT an absent one. session-start
|
|
52
|
+
// stamps the marker so this hook can tell them apart: without it, a project
|
|
53
|
+
// resumed on a foreign machine is announced as a first session, and the model
|
|
54
|
+
// then has every reason to author a fresh hot.md over the one that exists on
|
|
55
|
+
// the owning machine. Never name the withheld contents, only the fact.
|
|
56
|
+
const scopedOut = marker.scopedOut === true;
|
|
57
|
+
const snapshotNote = hasSnapshot
|
|
58
|
+
? ''
|
|
59
|
+
: scopedOut
|
|
60
|
+
? ' (snapshot scoped to another machine)'
|
|
61
|
+
: ' (no snapshot yet — first session)';
|
|
52
62
|
// a cwd-change re-trigger says "Resuming"; a fresh session start
|
|
53
63
|
// (default source) says "Previously working on".
|
|
54
64
|
const verb = marker.source === 'cwd-change' ? 'Resuming' : 'Previously working on';
|
|
@@ -63,11 +73,17 @@ process.stdin.on('end', () => {
|
|
|
63
73
|
// first-ever session (codex v2 review 2026-05-26).
|
|
64
74
|
const exampleLine = hasSnapshot
|
|
65
75
|
? `${verb} ${projSafe}: [one-line summary]. Continue with [next task]?`
|
|
66
|
-
:
|
|
76
|
+
: scopedOut
|
|
77
|
+
? `${projSafe}: this project has a prior snapshot, but it is scoped to another machine and is not visible here. What would you like to work on?`
|
|
78
|
+
: `${verb} ${projSafe}: no prior snapshot yet — first session. What would you like to start with?`;
|
|
67
79
|
const fillNote = hasSnapshot
|
|
68
80
|
? `Replace the bracketed placeholders using the [HOT] / [SESSION STATE] ` +
|
|
69
81
|
`context already injected this session — do NOT emit the literal brackets.`
|
|
70
|
-
:
|
|
82
|
+
: scopedOut
|
|
83
|
+
? `Use the line above verbatim. The project has prior work; its snapshot ` +
|
|
84
|
+
`simply belongs to another machine, so treat it as an existing project ` +
|
|
85
|
+
`whose history you cannot see from here.`
|
|
86
|
+
: `Use the line above verbatim — there is no prior snapshot to summarize.`;
|
|
71
87
|
|
|
72
88
|
console.log(
|
|
73
89
|
JSON.stringify(
|
package/hooks/hypo-lookup.mjs
CHANGED
|
@@ -19,6 +19,9 @@ import {
|
|
|
19
19
|
staleMarkerFor,
|
|
20
20
|
pageUsageLoggingAllowed,
|
|
21
21
|
recordLookupUsage,
|
|
22
|
+
currentDevice,
|
|
23
|
+
scopeVisible,
|
|
24
|
+
readVisibilityScope,
|
|
22
25
|
} from './hypo-shared.mjs';
|
|
23
26
|
|
|
24
27
|
const INDEX_PATH = join(HYPO_DIR, 'index.md');
|
|
@@ -32,7 +35,16 @@ function buildPageMap(dir, root = dir, map = {}, ignorePatterns = [], hypoDir =
|
|
|
32
35
|
for (const entry of readdirSync(dir)) {
|
|
33
36
|
const full = join(dir, entry);
|
|
34
37
|
if (isIgnored(full, hypoDir, ignorePatterns)) continue;
|
|
35
|
-
|
|
38
|
+
// Skip an entry we can't stat (dangling symlink, race, permission) instead
|
|
39
|
+
// of throwing: the map is now built before the miss branch too, so one bad
|
|
40
|
+
// entry must not sink the whole lookup into the silent outer catch.
|
|
41
|
+
let st;
|
|
42
|
+
try {
|
|
43
|
+
st = statSync(full);
|
|
44
|
+
} catch {
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (st.isDirectory()) {
|
|
36
48
|
buildPageMap(full, root, map, ignorePatterns, hypoDir);
|
|
37
49
|
} else if (entry.endsWith('.md')) {
|
|
38
50
|
const rel = full.slice(root.length + 1).replace(/\.md$/, '');
|
|
@@ -208,25 +220,6 @@ process.stdin.on('end', () => {
|
|
|
208
220
|
const topScore = scored[0]?.score ?? 0;
|
|
209
221
|
const matched = scored.filter((e) => e.score >= topScore * 0.5);
|
|
210
222
|
|
|
211
|
-
if (matched.length === 0) {
|
|
212
|
-
const topic = keywords.slice(0, 5).join(', ');
|
|
213
|
-
const closest = bm25Score(keywords, entries)
|
|
214
|
-
.map((e) => ({ ...e, score: e.score * typePrior(e.slug) }))
|
|
215
|
-
.sort((a, c) => c.score - a.score)
|
|
216
|
-
.slice(0, 3)
|
|
217
|
-
.map((e) => `[[${e.slug}]]`)
|
|
218
|
-
.join(', ');
|
|
219
|
-
console.log(
|
|
220
|
-
JSON.stringify(
|
|
221
|
-
buildOutput(`[WIKI LOOKUP: miss] "${topic}" — no match. Closest: ${closest || 'none'}`, {
|
|
222
|
-
continue: true,
|
|
223
|
-
suppressOutput: true,
|
|
224
|
-
}),
|
|
225
|
-
),
|
|
226
|
-
);
|
|
227
|
-
return;
|
|
228
|
-
}
|
|
229
|
-
|
|
230
223
|
const ignorePatterns = loadHypoIgnore(HYPO_DIR);
|
|
231
224
|
const pageMap = {
|
|
232
225
|
...buildPageMap(
|
|
@@ -245,19 +238,81 @@ process.stdin.on('end', () => {
|
|
|
245
238
|
),
|
|
246
239
|
};
|
|
247
240
|
|
|
241
|
+
// Single device snapshot for the whole prompt (currentDevice() itself stays
|
|
242
|
+
// uncached upstream so tests can override via HYPO_DEVICE; this hook only
|
|
243
|
+
// needs one read per lookup, not one per candidate).
|
|
244
|
+
const device = currentDevice();
|
|
245
|
+
|
|
246
|
+
// Resolve a slug to its on-disk path the same way the injection loop does.
|
|
247
|
+
const resolveSlugPath = (slug) =>
|
|
248
|
+
pageMap[slug] ?? pageMap[slug.replace(/^(pages|projects)\//, '')] ?? pageMap[basename(slug)];
|
|
249
|
+
|
|
250
|
+
// Read a page's raw content at most once per lookup: the visibility gate and
|
|
251
|
+
// the injection loop both need it, so memoize to avoid a double read.
|
|
252
|
+
const rawCache = new Map();
|
|
253
|
+
const readRaw = (path) => {
|
|
254
|
+
if (rawCache.has(path)) return rawCache.get(path);
|
|
255
|
+
let raw = null;
|
|
256
|
+
try {
|
|
257
|
+
raw = readFileSync(path, 'utf-8');
|
|
258
|
+
} catch {
|
|
259
|
+
raw = null;
|
|
260
|
+
}
|
|
261
|
+
rawCache.set(path, raw);
|
|
262
|
+
return raw;
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
// Visibility gate: MUST read raw content and go through readVisibilityScope
|
|
266
|
+
// (never a local frontmatter parser) so first-wins + comment-strip stay in
|
|
267
|
+
// step with the write side — a local last-wins/no-comment parser would miss
|
|
268
|
+
// `machine:devA # note` on devA itself. An unresolved/unreadable slug passes
|
|
269
|
+
// through (nothing to leak; the missing-file paths handle it downstream).
|
|
270
|
+
const isSlugVisible = (slug) => {
|
|
271
|
+
const path = resolveSlugPath(slug);
|
|
272
|
+
if (!path || !existsSync(path)) return true;
|
|
273
|
+
const raw = readRaw(path);
|
|
274
|
+
if (raw === null) return true;
|
|
275
|
+
return scopeVisible(readVisibilityScope(raw), device);
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
// Filter BEFORE the miss decision so a device that matches only machine-other
|
|
279
|
+
// pages takes the clean miss path (with closest VISIBLE suggestions), not the
|
|
280
|
+
// "index hit but files missing" branch. Hidden pages never reach injection,
|
|
281
|
+
// the empty-injection slug branch, or the "+N more" count — all read only
|
|
282
|
+
// visibleMatched, so no machine-other slug can leak through any path.
|
|
283
|
+
const visibleMatched = matched.filter((e) => isSlugVisible(e.slug));
|
|
284
|
+
|
|
285
|
+
if (visibleMatched.length === 0) {
|
|
286
|
+
const topic = keywords.slice(0, 5).join(', ');
|
|
287
|
+
const closest = bm25Score(keywords, entries)
|
|
288
|
+
.map((e) => ({ ...e, score: e.score * typePrior(e.slug) }))
|
|
289
|
+
.sort((a, c) => c.score - a.score)
|
|
290
|
+
.filter((e) => isSlugVisible(e.slug))
|
|
291
|
+
.slice(0, 3)
|
|
292
|
+
.map((e) => `[[${e.slug}]]`)
|
|
293
|
+
.join(', ');
|
|
294
|
+
console.log(
|
|
295
|
+
JSON.stringify(
|
|
296
|
+
buildOutput(`[WIKI LOOKUP: miss] "${topic}" — no match. Closest: ${closest || 'none'}`, {
|
|
297
|
+
continue: true,
|
|
298
|
+
suppressOutput: true,
|
|
299
|
+
}),
|
|
300
|
+
),
|
|
301
|
+
);
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
|
|
248
305
|
// UTC to match doctor.mjs' overdue set (D1/D2). STALE is advisory, so a
|
|
249
306
|
// one-day UTC/local skew never misleads.
|
|
250
307
|
const TODAY = new Date().toISOString().slice(0, 10);
|
|
251
308
|
|
|
252
309
|
const injected = [];
|
|
253
310
|
const injectedSlugs = [];
|
|
254
|
-
for (const { slug } of
|
|
255
|
-
const path =
|
|
256
|
-
pageMap[slug] ??
|
|
257
|
-
pageMap[slug.replace(/^(pages|projects)\//, '')] ??
|
|
258
|
-
pageMap[basename(slug)];
|
|
311
|
+
for (const { slug } of visibleMatched.slice(0, MAX_HITS)) {
|
|
312
|
+
const path = resolveSlugPath(slug);
|
|
259
313
|
if (path && existsSync(path)) {
|
|
260
|
-
const raw =
|
|
314
|
+
const raw = readRaw(path);
|
|
315
|
+
if (raw === null) continue;
|
|
261
316
|
// Compute the marker on raw (pre-slice) so a long body can't truncate
|
|
262
317
|
// frontmatter out of reach. fail-open: a marker failure drops the marker,
|
|
263
318
|
// never the page.
|
|
@@ -275,7 +330,7 @@ process.stdin.on('end', () => {
|
|
|
275
330
|
}
|
|
276
331
|
|
|
277
332
|
if (injected.length === 0) {
|
|
278
|
-
const slugs =
|
|
333
|
+
const slugs = visibleMatched
|
|
279
334
|
.slice(0, MAX_HITS)
|
|
280
335
|
.map((e) => e.slug)
|
|
281
336
|
.join(', ');
|
|
@@ -297,9 +352,11 @@ process.stdin.on('end', () => {
|
|
|
297
352
|
recordLookupUsage(HYPO_DIR, { sessionId, slugs: injectedSlugs });
|
|
298
353
|
}
|
|
299
354
|
|
|
355
|
+
// visibleMatched (not matched): a hidden count would still name a "+N more"
|
|
356
|
+
// total that includes machine-other pages this device can never see.
|
|
300
357
|
const overflow =
|
|
301
|
-
|
|
302
|
-
? `\n(+${
|
|
358
|
+
visibleMatched.length > MAX_HITS
|
|
359
|
+
? `\n(+${visibleMatched.length - MAX_HITS} more matches — search wiki index for more)`
|
|
303
360
|
: '';
|
|
304
361
|
|
|
305
362
|
console.log(
|
|
@@ -42,6 +42,7 @@ process.stdin.on('data', (chunk) => (raw += chunk));
|
|
|
42
42
|
process.stdin.on('end', () => {
|
|
43
43
|
let transcriptPath = null;
|
|
44
44
|
let sessionId = null;
|
|
45
|
+
let sessionCwd = null;
|
|
45
46
|
try {
|
|
46
47
|
const input = JSON.parse(raw || '{}');
|
|
47
48
|
transcriptPath = input.transcript_path ?? null;
|
|
@@ -49,6 +50,11 @@ process.stdin.on('end', () => {
|
|
|
49
50
|
// semantics (no project attribution) so /compact does not block a closed
|
|
50
51
|
// non-project session on the active/phantom project's files.
|
|
51
52
|
sessionId = input.session_id ?? input.sessionId ?? null;
|
|
53
|
+
// Authoritative session cwd for the session-cwd close check. This is the one
|
|
54
|
+
// verified cwd source (mirrors hypo-session-record); it lets /compact block a
|
|
55
|
+
// session whose own project close was never started, which the recency-based
|
|
56
|
+
// global status cannot see.
|
|
57
|
+
sessionCwd = input.cwd ?? null;
|
|
52
58
|
} catch {
|
|
53
59
|
/* fail-open */
|
|
54
60
|
}
|
|
@@ -107,6 +113,7 @@ process.stdin.on('end', () => {
|
|
|
107
113
|
gate = precompactGateStatus(HYPO_DIR, {
|
|
108
114
|
transcriptPath,
|
|
109
115
|
...(sessionId ? { sessionId } : {}),
|
|
116
|
+
...(sessionCwd ? { sessionCwd } : {}),
|
|
110
117
|
});
|
|
111
118
|
} catch (err) {
|
|
112
119
|
// Defense-in-depth: precompactGateStatus fails open per-check, but if it ever
|
|
@@ -122,9 +129,11 @@ process.stdin.on('end', () => {
|
|
|
122
129
|
// turn the (otherwise non-blocking) drift into a blocker, since real drift is
|
|
123
130
|
// confirmed and silently passing it would defeat the gate. --write only applies
|
|
124
131
|
// when no target conflicts/over-caps (code===0 across ALL targets), so a late
|
|
125
|
-
// race exits non-zero and blocks here.
|
|
126
|
-
//
|
|
127
|
-
//
|
|
132
|
+
// race exits non-zero and blocks here. Each FILE it writes is atomic (tmp+
|
|
133
|
+
// rename, see feedback-sync's atomicWrite), so a mid-write fault can no longer
|
|
134
|
+
// truncate a target; what is still not atomic is the write ACROSS targets (one
|
|
135
|
+
// file can land before another fails), which the preflight narrows to genuine
|
|
136
|
+
// fs errors and no further.
|
|
128
137
|
let feedbackHealed = '';
|
|
129
138
|
if (gate.ok && gate.driftTargets.length > 0) {
|
|
130
139
|
const feedbackPath = PKG_ROOT ? join(PKG_ROOT, 'scripts', 'feedback-sync.mjs') : null;
|
|
@@ -179,6 +188,18 @@ process.stdin.on('end', () => {
|
|
|
179
188
|
const fold = `+${otherDebtCount} pre-existing lint issue(s) elsewhere in the vault (other projects / shared pages, not blocking) — run \`/hypo:lint\` for the full list.`;
|
|
180
189
|
noticeText = noticeText ? `${noticeText}\n${fold}` : `[WIKI CHECK] ${fold}`;
|
|
181
190
|
}
|
|
191
|
+
// A demoted close: some OTHER session left a project's close incomplete. It no
|
|
192
|
+
// longer blocks this compact, but it must still be SEEN — a notice list that the
|
|
193
|
+
// gate silently swallows (suppressOutput when nothing else surfaced) would turn the
|
|
194
|
+
// demotion into a disappearance, and nobody would ever fix the dangling close
|
|
195
|
+
// (codex design BLOCKER).
|
|
196
|
+
const closeDebt = gate.notices.filter((n) => n.type === 'close-debt');
|
|
197
|
+
if (closeDebt.length > 0) {
|
|
198
|
+
const line = `[WIKI CHECK] ${closeDebt.length} project(s) with an incomplete session close from another session (not blocking this compact): ${closeDebt
|
|
199
|
+
.map((n) => n.project)
|
|
200
|
+
.join(', ')} — each is fixed by that project's next close.`;
|
|
201
|
+
noticeText = noticeText ? `${noticeText}\n${line}` : line;
|
|
202
|
+
}
|
|
182
203
|
// Surface the self-heal so a re-synced projection is not a silent mutation of
|
|
183
204
|
// the user's MEMORY.md / CLAUDE.md (transparency).
|
|
184
205
|
if (feedbackHealed) noticeText = noticeText ? `${noticeText}\n${feedbackHealed}` : feedbackHealed;
|
|
@@ -12,8 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
import { existsSync, mkdirSync, appendFileSync } from 'fs';
|
|
14
14
|
import { dirname, join } from 'path';
|
|
15
|
-
import {
|
|
16
|
-
import { HYPO_DIR } from './hypo-shared.mjs';
|
|
15
|
+
import { HYPO_DIR, currentDevice } from './hypo-shared.mjs';
|
|
17
16
|
|
|
18
17
|
const INDEX_PATH = join(HYPO_DIR, '.cache', 'sessions', 'index.jsonl');
|
|
19
18
|
|
|
@@ -54,7 +53,7 @@ process.stdin.on('end', () => {
|
|
|
54
53
|
// machine identity for multi-machine audit. index.jsonl lives
|
|
55
54
|
// under .cache/ (gitignored in a normal vault), so this is a LOCAL-only
|
|
56
55
|
// per-session record — accurate for every session, no sync/privacy cost.
|
|
57
|
-
device:
|
|
56
|
+
device: currentDevice(),
|
|
58
57
|
};
|
|
59
58
|
appendFileSync(INDEX_PATH, JSON.stringify(entry) + '\n');
|
|
60
59
|
} catch (err) {
|
|
@@ -32,6 +32,9 @@ import {
|
|
|
32
32
|
collectProjectWorkingDirs,
|
|
33
33
|
buildVaultOrientation,
|
|
34
34
|
staleMarkerFor,
|
|
35
|
+
currentDevice,
|
|
36
|
+
scopeVisible,
|
|
37
|
+
readVisibilityScope,
|
|
35
38
|
} from './hypo-shared.mjs';
|
|
36
39
|
import {
|
|
37
40
|
defaultCachePath,
|
|
@@ -46,15 +49,45 @@ import {
|
|
|
46
49
|
siblingAlreadyNotified,
|
|
47
50
|
markSiblingNotified,
|
|
48
51
|
} from './version-check.mjs';
|
|
52
|
+
import { snapshotBase, overwriteTargets } from './base-store.mjs';
|
|
53
|
+
import { listProposals } from './proposal-store.mjs';
|
|
49
54
|
|
|
50
55
|
// Privacy guard: refuse to read+inject .hypoignore-matched
|
|
51
56
|
// wiki files into additionalContext. Without this, a user who lists
|
|
52
57
|
// `projects/private/hot.md` in .hypoignore would still see SECRET emit because
|
|
53
58
|
// session-start reads hot/state paths directly.
|
|
59
|
+
//
|
|
60
|
+
// Visibility guard: a machine-scoped page (visibility_scope: machine:<owner>)
|
|
61
|
+
// must not be injected on a machine other than its owner. hypo-file-watch
|
|
62
|
+
// already filters these very files, so leaving session start unfiltered made the
|
|
63
|
+
// SAME file behave differently depending on which path opened it: the user sets
|
|
64
|
+
// the field, sees it honored on edit, and never learns that session start still
|
|
65
|
+
// ships the body. Read the scope from the RAW content before the maxChars slice:
|
|
66
|
+
// slicing first could cut the frontmatter off and silently fail open.
|
|
67
|
+
// The root hot.md is a frontmatter-less pointer table, so it reads as '' and
|
|
68
|
+
// passes (shared) unchanged.
|
|
54
69
|
function readIfNotIgnored(path, maxChars, patterns) {
|
|
55
70
|
if (!path) return null;
|
|
56
71
|
if (patterns.length > 0 && isIgnored(path, HYPO_DIR, patterns)) return null;
|
|
57
|
-
|
|
72
|
+
const raw = readFileSync(path, 'utf-8');
|
|
73
|
+
if (!scopeVisible(readVisibilityScope(raw), currentDevice())) return null;
|
|
74
|
+
return raw.slice(0, maxChars);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Scoped-out is not the same as absent. Both make readIfNotIgnored return null,
|
|
78
|
+
// but telling the model "no snapshot yet / first session" when the snapshot merely
|
|
79
|
+
// belongs to another machine is a lie it will act on. Returns false for an ignored
|
|
80
|
+
// or missing file so only a real machine-scope hide reports true.
|
|
81
|
+
// The caller may name the project and the fact, never the withheld body: a message
|
|
82
|
+
// explaining the hide must not re-leak what it hid.
|
|
83
|
+
function isScopedOut(path, patterns) {
|
|
84
|
+
try {
|
|
85
|
+
if (!path || !existsSync(path)) return false;
|
|
86
|
+
if (patterns.length > 0 && isIgnored(path, HYPO_DIR, patterns)) return false;
|
|
87
|
+
return !scopeVisible(readVisibilityScope(readFileSync(path, 'utf-8')), currentDevice());
|
|
88
|
+
} catch {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
58
91
|
}
|
|
59
92
|
|
|
60
93
|
// Compute the STALE marker for a hot/state file from its RAW content (readIfNotIgnored
|
|
@@ -271,6 +304,24 @@ function syncStateNotice(pullOk) {
|
|
|
271
304
|
}
|
|
272
305
|
return `[WIKI: last sync failed: ${last.op || '?'} — ${last.error || 'unknown'}]`;
|
|
273
306
|
}
|
|
307
|
+
/**
|
|
308
|
+
* Surface the vault-wide count of parked write-proposals (T8). Routed
|
|
309
|
+
* exactly like syncStateNotice: the line joins the `notices` array (→
|
|
310
|
+
* additionalContext) and is also written to stderr, so both the model and the
|
|
311
|
+
* user's transcript see it. NOT a systemMessage banner (that channel is
|
|
312
|
+
* reserved for the update/sibling notices). Pure read (listProposals never
|
|
313
|
+
* mutates); best-effort so a store read failure never breaks SessionStart. '' when
|
|
314
|
+
* there are no pending proposals, so nothing surfaces on the empty path.
|
|
315
|
+
*/
|
|
316
|
+
function pendingProposalNotice() {
|
|
317
|
+
try {
|
|
318
|
+
const n = listProposals(HYPO_DIR).length;
|
|
319
|
+
if (n === 0) return '';
|
|
320
|
+
return `[WIKI: 대기 proposal ${n}건 (검토: hypomnema proposal list)]`;
|
|
321
|
+
} catch {
|
|
322
|
+
return '';
|
|
323
|
+
}
|
|
324
|
+
}
|
|
274
325
|
const GLOBAL_HOT = join(HYPO_DIR, 'hot.md');
|
|
275
326
|
const HOT_CHARS = 2000;
|
|
276
327
|
const STATE_CHARS = 2000;
|
|
@@ -346,6 +397,7 @@ process.stdin.on('end', () => {
|
|
|
346
397
|
|
|
347
398
|
const pullOk = gitPull(HYPO_DIR);
|
|
348
399
|
const syncLine = syncStateNotice(pullOk);
|
|
400
|
+
const proposalLine = pendingProposalNotice();
|
|
349
401
|
const growthLine = readLastGrowthLine();
|
|
350
402
|
// On source='clear', surface the dying
|
|
351
403
|
// session's identity that hypo-session-end stashed so Claude can recover
|
|
@@ -364,11 +416,17 @@ process.stdin.on('end', () => {
|
|
|
364
416
|
// transcript/--verbose only and out of this banner's scope.)
|
|
365
417
|
const userMessage = [updateLine, siblingLine].filter(Boolean).join('\n\n');
|
|
366
418
|
if (userMessage) outExtra = { ...outExtra, systemMessage: userMessage };
|
|
367
|
-
const notices = [
|
|
368
|
-
|
|
369
|
-
|
|
419
|
+
const notices = [
|
|
420
|
+
syncLine,
|
|
421
|
+
proposalLine,
|
|
422
|
+
growthLine,
|
|
423
|
+
clearRecoveryLine,
|
|
424
|
+
updateLine,
|
|
425
|
+
siblingLine,
|
|
426
|
+
].filter(Boolean);
|
|
370
427
|
let noticePrefix = notices.length ? `${notices.join('\n\n')}\n\n` : '';
|
|
371
428
|
if (syncLine) process.stderr.write(`\n\x1b[33m${syncLine}\x1b[0m\n`);
|
|
429
|
+
if (proposalLine) process.stderr.write(`\n\x1b[33m${proposalLine}\x1b[0m\n`);
|
|
372
430
|
if (growthLine) process.stderr.write(`\n\x1b[36m${growthLine}\x1b[0m\n`);
|
|
373
431
|
if (clearRecoveryLine)
|
|
374
432
|
process.stderr.write(`\n\x1b[33m${clearRecoveryLine.split('\n')[0]}\x1b[0m\n`);
|
|
@@ -379,6 +437,18 @@ process.stdin.on('end', () => {
|
|
|
379
437
|
const MARKER_FILE = sessionMarkerPath(sessionId);
|
|
380
438
|
const hit = findProjectFiles(cwd);
|
|
381
439
|
|
|
440
|
+
// Observed-base snapshot for the write=proposal gate. Deliberately AFTER gitPull: the base must
|
|
441
|
+
// describe the tree this session actually starts from, remote merges
|
|
442
|
+
// included, or the first close would raise a proposal against content the
|
|
443
|
+
// session never had a chance to conflict with. Once per session
|
|
444
|
+
// (existence-check inside snapshotBase), so resume and compact leave it
|
|
445
|
+
// alone. `data.session_id` is used raw rather than the 'default' fallback
|
|
446
|
+
// above. A session with no id has no base and closes down the legacy
|
|
447
|
+
// direct-write path.
|
|
448
|
+
if (data.session_id) {
|
|
449
|
+
snapshotBase(HYPO_DIR, data.session_id, overwriteTargets(hit ? hit.proj : null));
|
|
450
|
+
}
|
|
451
|
+
|
|
382
452
|
const ignorePatterns = loadHypoIgnore(HYPO_DIR);
|
|
383
453
|
|
|
384
454
|
// When cwd is a project working_dir that is NOT the vault itself, tell the
|
|
@@ -425,17 +495,29 @@ process.stdin.on('end', () => {
|
|
|
425
495
|
),
|
|
426
496
|
);
|
|
427
497
|
} else {
|
|
498
|
+
// A snapshot that exists but is scoped to another machine must not be
|
|
499
|
+
// reported as "no snapshot yet": the model would treat a resumed project
|
|
500
|
+
// as a first session. Say which it is, and say nothing of the contents.
|
|
501
|
+
const scopedOut =
|
|
502
|
+
isScopedOut(hit.hotPath, ignorePatterns) || isScopedOut(hit.statePath, ignorePatterns);
|
|
503
|
+
const reason = scopedOut ? 'snapshot scoped to another machine' : 'no snapshot yet';
|
|
428
504
|
process.stderr.write(
|
|
429
|
-
`\n\x1b[36m[Hypomnema]\x1b[0m project: \x1b[1m${hit.proj}\x1b[0m (
|
|
505
|
+
`\n\x1b[36m[Hypomnema]\x1b[0m project: \x1b[1m${hit.proj}\x1b[0m (${reason})\n\n`,
|
|
430
506
|
);
|
|
507
|
+
// Carry the reason into the marker, not just this hook's output.
|
|
508
|
+
// hypo-first-prompt derives its resume line from the marker alone, so a
|
|
509
|
+
// marker that says only `hotPath: null` makes the NEXT prompt announce
|
|
510
|
+
// "first session" for a project that merely belongs to another machine.
|
|
511
|
+
// That lie is what invites the model to author a fresh hot.md over one
|
|
512
|
+
// that already exists elsewhere.
|
|
431
513
|
writeFileSync(
|
|
432
514
|
MARKER_FILE,
|
|
433
|
-
JSON.stringify({ proj: hit.proj, hotPath: null, ts: Date.now() }),
|
|
515
|
+
JSON.stringify({ proj: hit.proj, hotPath: null, scopedOut, ts: Date.now() }),
|
|
434
516
|
);
|
|
435
517
|
console.log(
|
|
436
518
|
JSON.stringify(
|
|
437
519
|
buildOutput(
|
|
438
|
-
`${noticePrefix}${hitPrefix}[WIKI HOT CACHE: project=${sanitizeProjForPrompt(hit.proj)},
|
|
520
|
+
`${noticePrefix}${hitPrefix}[WIKI HOT CACHE: project=${sanitizeProjForPrompt(hit.proj)}, ${reason}]`,
|
|
439
521
|
outExtra,
|
|
440
522
|
),
|
|
441
523
|
),
|