hypomnema 1.6.2 → 1.7.1
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 +18 -1
- package/hooks/hypo-auto-commit.mjs +92 -15
- package/hooks/hypo-auto-minimal-crystallize.mjs +63 -20
- package/hooks/hypo-auto-stage.mjs +41 -1
- package/hooks/hypo-close-guard.mjs +246 -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-hot-rebuild.mjs +43 -5
- 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 +199 -20
- package/hooks/hypo-shared.mjs +2080 -176
- package/hooks/proposal-store.mjs +513 -0
- package/hooks/version-check.mjs +45 -0
- package/package.json +42 -14
- package/scripts/capture.mjs +556 -37
- package/scripts/crystallize.mjs +751 -110
- package/scripts/doctor.mjs +787 -26
- package/scripts/feedback-sync.mjs +515 -44
- package/scripts/graph.mjs +35 -13
- package/scripts/init.mjs +287 -41
- package/scripts/lib/extensions.mjs +656 -1
- package/scripts/lib/git-hooks-dir.mjs +229 -0
- 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/project-create.mjs +5 -1
- package/scripts/lib/rename-marker.mjs +39 -0
- package/scripts/lib/wd-match.mjs +23 -5
- package/scripts/lib/wikilink.mjs +32 -6
- package/scripts/lint.mjs +103 -5
- package/scripts/proposal.mjs +1032 -0
- package/scripts/query.mjs +25 -4
- package/scripts/rename.mjs +223 -18
- package/scripts/resume.mjs +34 -12
- package/scripts/stats.mjs +41 -9
- 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 +9 -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
|
@@ -2,32 +2,109 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* hypo-auto-commit.mjs — Stop hook
|
|
4
4
|
*
|
|
5
|
-
* At session end: stage
|
|
5
|
+
* At session end: stage this session's touched paths, commit if any, then
|
|
6
|
+
* pull+push to sync remote.
|
|
7
|
+
*
|
|
8
|
+
* Scoped, not whole-tree: this no longer sweeps the entire working tree. The
|
|
9
|
+
* scope is this session's accumulated touched-paths set (hypo-auto-stage.mjs
|
|
10
|
+
* writes, plus whatever the earlier Stop-chain generators, hot-rebuild and
|
|
11
|
+
* session-record, appended for the same session_id). No session_id means
|
|
12
|
+
* nothing was ever accumulated, so the scoped commit is skipped cleanly;
|
|
13
|
+
* never a whole-tree fallback.
|
|
14
|
+
*
|
|
15
|
+
* PEEK, don't drain, and hold ONE lock across peek+commit+clear
|
|
16
|
+
* (commitTouchedPaths, hypo-shared.mjs): a drain-then-requeue-on-failure
|
|
17
|
+
* design was tried and dropped — the requeue write is itself a fallible
|
|
18
|
+
* operation (lock-timeout, I/O), so a commit failure could still lose the
|
|
19
|
+
* scope in the narrow window between the drain and the requeue. A peek
|
|
20
|
+
* that released its lock before the commit, then a SEPARATE clear
|
|
21
|
+
* afterward, was also tried and dropped — a `recordTouchedPaths` for a
|
|
22
|
+
* path already in the just-peeked set could land in the window between the
|
|
23
|
+
* commit and the clear and be silently wiped out by it (the set only
|
|
24
|
+
* tracks path presence, not a version, so that write is indistinguishable
|
|
25
|
+
* from the one already peeked). commitTouchedPaths holds ONE per-session
|
|
26
|
+
* lock across the whole peek → commit → clear window, so neither loss mode
|
|
27
|
+
* is possible: nothing is deleted until the commit has actually succeeded,
|
|
28
|
+
* and no accumulate can land inside the window at all.
|
|
6
29
|
*/
|
|
7
30
|
|
|
8
31
|
import { spawnSync } from 'child_process';
|
|
9
|
-
import {
|
|
32
|
+
import {
|
|
33
|
+
HYPO_DIR,
|
|
34
|
+
syncRemote,
|
|
35
|
+
commitWikiChanges,
|
|
36
|
+
commitTouchedPaths,
|
|
37
|
+
vaultCommitLockTarget,
|
|
38
|
+
withFileLock,
|
|
39
|
+
} from './hypo-shared.mjs';
|
|
10
40
|
|
|
11
41
|
function hasRemote() {
|
|
12
42
|
const r = spawnSync('git', ['-C', HYPO_DIR, 'remote'], { encoding: 'utf-8', timeout: 30000 });
|
|
13
43
|
return (r.stdout || '').trim().length > 0;
|
|
14
44
|
}
|
|
15
45
|
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
46
|
+
// Overridable so a test can force a fast lock-timeout instead of waiting out
|
|
47
|
+
// the real default (mirrors crystallize.mjs's HYPO_APPEND_LOCK_TIMEOUT_MS).
|
|
48
|
+
const VAULT_LOCK_TIMEOUT_MS = Number(process.env.HYPO_VAULT_LOCK_TIMEOUT_MS) || 5000;
|
|
49
|
+
|
|
50
|
+
let input = {};
|
|
51
|
+
try {
|
|
52
|
+
const raw = await new Promise((r) => {
|
|
53
|
+
let d = '';
|
|
54
|
+
process.stdin.on('data', (c) => (d += c));
|
|
55
|
+
process.stdin.on('end', () => r(d));
|
|
56
|
+
});
|
|
57
|
+
input = JSON.parse(raw || '{}') || {};
|
|
58
|
+
} catch {
|
|
59
|
+
input = {};
|
|
23
60
|
}
|
|
61
|
+
const sessionId = input.session_id || input.sessionId || null;
|
|
62
|
+
|
|
63
|
+
// Stage + commit + sync as one critical section, serialized against every
|
|
64
|
+
// other writer of this vault (the crystallize.mjs --apply-session-close path
|
|
65
|
+
// holds the SAME lock around its own stage+commit). Without this, two
|
|
66
|
+
// concurrent sessions on a shared vault could interleave `git add`/`git
|
|
67
|
+
// commit`/`git pull`/`git push`. This does NOT gate pushes on whole-tree
|
|
68
|
+
// cleanliness: a scoped commit may legitimately leave other sessions' dirty
|
|
69
|
+
// files behind, and a `git pull --no-rebase` failure from that residual is
|
|
70
|
+
// already logged via appendSyncFailure and surfaced by doctor/session-start.
|
|
71
|
+
// Full cross-session isolation is out of scope (it needs separate worktrees).
|
|
72
|
+
//
|
|
73
|
+
// The vault lock (shared with crystallize.mjs's apply commit) serializes
|
|
74
|
+
// git operations across concurrent sessions on this vault; the per-session
|
|
75
|
+
// touched-paths lock commitTouchedPaths takes internally is a DIFFERENT
|
|
76
|
+
// lock file, so the two nest without any ordering conflict (vault lock is
|
|
77
|
+
// always acquired first here; accumulation elsewhere only ever takes the
|
|
78
|
+
// per-session lock, never the vault lock).
|
|
79
|
+
try {
|
|
80
|
+
withFileLock(
|
|
81
|
+
vaultCommitLockTarget(HYPO_DIR),
|
|
82
|
+
() => {
|
|
83
|
+
// Peek this session's scope, run the scoped commit, and — only on
|
|
84
|
+
// success — clear exactly what committed, ALL under one hold of the
|
|
85
|
+
// per-session lock. See commitTouchedPaths's docstring for why a
|
|
86
|
+
// commit failure or a same-path race can't lose anything under this.
|
|
87
|
+
const result = commitTouchedPaths(HYPO_DIR, sessionId, (paths) =>
|
|
88
|
+
commitWikiChanges(HYPO_DIR, paths),
|
|
89
|
+
);
|
|
90
|
+
if (!result.committed) return;
|
|
24
91
|
|
|
25
|
-
if (hasRemote()) {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
92
|
+
if (hasRemote()) {
|
|
93
|
+
// pull/push failures must not stop the session, but they can no longer be
|
|
94
|
+
// swallowed silently — syncRemote records each to .cache/sync-state.json and,
|
|
95
|
+
// on a merge conflict, aborts the merge so the tree is never left half-merged
|
|
96
|
+
// (part of the v1.4 sync hardening). session-start + doctor surface the result next session.
|
|
97
|
+
syncRemote(HYPO_DIR);
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
{ timeoutMs: VAULT_LOCK_TIMEOUT_MS },
|
|
101
|
+
);
|
|
102
|
+
} catch {
|
|
103
|
+
// Lock-timeout (or an unexpected lock error) on the OUTER vault lock: we
|
|
104
|
+
// never entered the critical section, so commitTouchedPaths never ran —
|
|
105
|
+
// the touched-paths file is untouched on disk, and the next Stop retries
|
|
106
|
+
// this session's commit from the same scope. Best-effort, like every
|
|
107
|
+
// other step in this hook.
|
|
31
108
|
}
|
|
32
109
|
|
|
33
110
|
console.log(JSON.stringify({ continue: true, suppressOutput: true }));
|
|
@@ -123,10 +123,20 @@ function emitBlock(sessionId, transcriptPath, gate = null, opts = {}) {
|
|
|
123
123
|
// an install/transcript path contains spaces (display text only — never exec'd
|
|
124
124
|
// here).
|
|
125
125
|
const transcriptHint = transcriptPath ? ` --transcript-path="${transcriptPath}"` : '';
|
|
126
|
+
// Recovery command carries EVIDENCE, never the recency-derived close.project.
|
|
127
|
+
// The evidence-backed attribution is a singleton close scope (transcript
|
|
128
|
+
// close-files ∪ this marker's projects); when it is unambiguous, embed it as
|
|
129
|
+
// --project so the re-run is one-shot instead of failing closed for lack of
|
|
130
|
+
// evidence. The session cwd (from the payload) rides along so the re-run's own
|
|
131
|
+
// session-cwd close check runs against the same project this Stop evaluated.
|
|
132
|
+
const scope = gate?.close?.scope || [];
|
|
133
|
+
const evidenceProject = scope.length === 1 ? scope[0] : null;
|
|
134
|
+
const projectHint = evidenceProject ? ` --project=${evidenceProject}` : '';
|
|
135
|
+
const cwdHint = opts.sessionCwd ? ` --session-cwd="${opts.sessionCwd}"` : '';
|
|
126
136
|
const cliBase = PKG_ROOT ? `node "${join(PKG_ROOT, 'scripts', 'crystallize.mjs')}"` : null;
|
|
127
137
|
const markCmd = cliBase
|
|
128
|
-
? `${cliBase} --mark-session-closed --session-id=${sessionId}${transcriptHint}`
|
|
129
|
-
: `/hypo:crystallize (session_id=${sessionId}${transcriptHint})`;
|
|
138
|
+
? `${cliBase} --mark-session-closed --session-id=${sessionId}${projectHint}${transcriptHint}${cwdHint}`
|
|
139
|
+
: `/hypo:crystallize (session_id=${sessionId}${projectHint}${transcriptHint})`;
|
|
130
140
|
// The log-only escape hatch for a non-project (wiki/tooling-only)
|
|
131
141
|
// session. Offered ONLY as an explicit alternative when a close blocker is
|
|
132
142
|
// present — never as the default recovery, so a real project session is not
|
|
@@ -150,7 +160,9 @@ function emitBlock(sessionId, transcriptPath, gate = null, opts = {}) {
|
|
|
150
160
|
// Only when a project-close blocker is what's holding the session: a
|
|
151
161
|
// non-project session has nothing to close, so offer log-only as the way out
|
|
152
162
|
// (Claude decides whether this session is project-scoped — no auto-attribution).
|
|
153
|
-
|
|
163
|
+
// A session-cwd close blocker (the session's own cwd project is unstarted) is
|
|
164
|
+
// the same kind of project-close hold, so it gets the same escape.
|
|
165
|
+
if (gate.blockers.some((b) => b.type === 'close' || b.type === 'close-cwd')) {
|
|
154
166
|
reason += ` If this was a non-project (wiki/tooling-only) session with no project to close, run \`${logOnlyCmd}\` instead (log-only close, no project attribution).`;
|
|
155
167
|
}
|
|
156
168
|
} else {
|
|
@@ -202,6 +214,9 @@ process.stdin.on('end', () => {
|
|
|
202
214
|
|
|
203
215
|
const sessionId = payload.session_id || payload.sessionId || null;
|
|
204
216
|
const transcriptPath = payload.transcript_path || payload.transcriptPath || null;
|
|
217
|
+
// Authoritative session cwd (the one verified cwd source) for the session-cwd
|
|
218
|
+
// close check below. Absent on older Claude Code payloads → the check is skipped.
|
|
219
|
+
const sessionCwd = payload.cwd || null;
|
|
205
220
|
|
|
206
221
|
// 3. substantial-session gate. Pure Q&A / incidental-lookup sessions skip
|
|
207
222
|
// the block; mutating sessions AND high-volume read-only investigations
|
|
@@ -220,10 +235,49 @@ process.stdin.on('end', () => {
|
|
|
220
235
|
return;
|
|
221
236
|
}
|
|
222
237
|
|
|
223
|
-
//
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
238
|
+
// Read-only /compact gate (same precompactGateStatus the real PreCompact hook
|
|
239
|
+
// uses) sharpens the block message and, with sessionCwd, backs the session-cwd
|
|
240
|
+
// close check below. The hook NEVER writes the marker here (file-header
|
|
241
|
+
// invariant); this is read-only. Any error → null → emitBlock falls back to the
|
|
242
|
+
// generic message (fail-open). Computed lazily: the common closed-session path
|
|
243
|
+
// (a project marker whose cwd project is complete) still short-circuits without
|
|
244
|
+
// paying the gate cost, unless a cwd signal makes the cwd check meaningful.
|
|
245
|
+
let gate = null;
|
|
246
|
+
const computeGate = () => {
|
|
247
|
+
try {
|
|
248
|
+
return precompactGateStatus(HYPO_DIR, {
|
|
249
|
+
...(transcriptPath ? { transcriptPath } : {}),
|
|
250
|
+
...(sessionCwd ? { sessionCwd } : {}),
|
|
251
|
+
...(sessionId ? { sessionId } : {}),
|
|
252
|
+
});
|
|
253
|
+
} catch {
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
// 5. close already verified for this session_id — but a project marker only
|
|
259
|
+
// attests the project(s) it recorded. If THIS session's cwd project still has
|
|
260
|
+
// an unstarted close, honoring the marker would end the session green while
|
|
261
|
+
// that project stays open (the session-cwd false-green). So re-check the cwd
|
|
262
|
+
// project before accepting a project marker. A log-only marker (non-project
|
|
263
|
+
// session) is exempt, and without a cwd signal we accept the marker as before
|
|
264
|
+
// (back-compat with payloads that carry no cwd).
|
|
265
|
+
if (sessionId) {
|
|
266
|
+
const marker = readSessionClosedMarker(HYPO_DIR, sessionId);
|
|
267
|
+
if (marker) {
|
|
268
|
+
if (marker.scope === 'log-only' || !sessionCwd) {
|
|
269
|
+
emitContinue();
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
gate = computeGate();
|
|
273
|
+
const cwdBlocked = !!gate?.blockers?.some((b) => b.type === 'close-cwd');
|
|
274
|
+
if (!cwdBlocked) {
|
|
275
|
+
emitContinue();
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
// marker present but the session's cwd project close is incomplete: fall
|
|
279
|
+
// through to block, reusing the gate computed above.
|
|
280
|
+
}
|
|
227
281
|
}
|
|
228
282
|
|
|
229
283
|
// 6. block — but only when we have a session_id to address the recovery
|
|
@@ -234,18 +288,7 @@ process.stdin.on('end', () => {
|
|
|
234
288
|
return;
|
|
235
289
|
}
|
|
236
290
|
|
|
237
|
-
|
|
238
|
-
// PreCompact hook uses) sharpens the block message — distinguishes "close
|
|
239
|
-
// is compact-ready, only the marker is missing" from "there are real
|
|
240
|
-
// blockers". The hook NEVER writes the marker here (file-header invariant);
|
|
241
|
-
// this is read-only. Any error → null → emitBlock falls back to the generic
|
|
242
|
-
// message (fail-open).
|
|
243
|
-
let gate = null;
|
|
244
|
-
try {
|
|
245
|
-
gate = precompactGateStatus(HYPO_DIR, transcriptPath ? { transcriptPath } : {});
|
|
246
|
-
} catch {
|
|
247
|
-
gate = null;
|
|
248
|
-
}
|
|
291
|
+
if (!gate) gate = computeGate();
|
|
249
292
|
|
|
250
293
|
// Reconfirm decision (conditional-close-reconfirm): "close" and
|
|
251
294
|
// "work-incomplete" together are exactly the case where the transcript's
|
|
@@ -269,7 +312,7 @@ process.stdin.on('end', () => {
|
|
|
269
312
|
return;
|
|
270
313
|
}
|
|
271
314
|
|
|
272
|
-
emitBlock(sessionId, transcriptPath, gate, { reconfirm: workIncomplete });
|
|
315
|
+
emitBlock(sessionId, transcriptPath, gate, { reconfirm: workIncomplete, sessionCwd });
|
|
273
316
|
} catch (err) {
|
|
274
317
|
// Fail-open on any unexpected error.
|
|
275
318
|
process.stderr.write(`[hypo-auto-minimal-crystallize] error: ${err?.message ?? String(err)}\n`);
|
|
@@ -6,7 +6,17 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { spawnSync } from 'child_process';
|
|
9
|
-
import {
|
|
9
|
+
import { relative } from 'path';
|
|
10
|
+
import { HYPO_DIR, loadHypoIgnore, isIgnored, recordTouchedPaths } 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,36 @@ 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
|
+
if (WRITE_TOOLS.has(input.tool_name)) {
|
|
44
|
+
const rel = relative(HYPO_DIR, filePath);
|
|
45
|
+
|
|
46
|
+
// Accumulate this write into the session's scoped auto-commit
|
|
47
|
+
// set, keyed by session_id (no-op without one; never a shared bucket).
|
|
48
|
+
// hypo-auto-commit.mjs drains this at Stop instead of sweeping the whole
|
|
49
|
+
// working tree, so another session's concurrent writes to this vault
|
|
50
|
+
// never land in THIS session's commit.
|
|
51
|
+
recordTouchedPaths(HYPO_DIR, input.session_id, rel);
|
|
52
|
+
|
|
53
|
+
// Write=proposal gate provenance: when this session's own write lands on one of
|
|
54
|
+
// the overwrite targets it snapshotted at start, advance that target's base so
|
|
55
|
+
// the close guard reads the change as "I wrote this", not "someone else did"
|
|
56
|
+
// (which would fail safe into a false proposal against the session's own edit).
|
|
57
|
+
// Self-scoping — a no-op unless the path is a tracked base key — so it runs
|
|
58
|
+
// regardless of .hypoignore (provenance is independent of privacy). Best-effort.
|
|
59
|
+
//
|
|
60
|
+
// The Write tool carries its full `content`, so advance to the bytes THIS
|
|
61
|
+
// session wrote (race-safe: a concurrent write landing between the tool and
|
|
62
|
+
// this hook cannot be adopted as our base). Edit/MultiEdit have no full content
|
|
63
|
+
// in the payload, so they fall back to a post-write disk read.
|
|
64
|
+
if (input.session_id) {
|
|
65
|
+
const known =
|
|
66
|
+
input.tool_name === 'Write' && typeof input.tool_input?.content === 'string'
|
|
67
|
+
? hashContent(input.tool_input.content)
|
|
68
|
+
: null;
|
|
69
|
+
advanceBaseForWrite(HYPO_DIR, input.session_id, rel, filePath, known);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
32
72
|
}
|
|
33
73
|
|
|
34
74
|
console.log(JSON.stringify({ continue: true, suppressOutput: true }));
|
|
@@ -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
|
+
}
|
|
@@ -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(
|