hypomnema 1.7.0 → 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/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 +110 -13
- package/hooks/hypo-shared.mjs +1224 -96
- package/hooks/version-check.mjs +45 -0
- package/package.json +3 -1
- package/scripts/crystallize.mjs +112 -2
- package/scripts/doctor.mjs +483 -17
- package/scripts/graph.mjs +27 -12
- package/scripts/init.mjs +57 -7
- package/scripts/lib/git-hooks-dir.mjs +229 -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/templates/gitignore +4 -0
- package/templates/hypo-config.md +1 -1
package/hooks/hooks.json
CHANGED
|
@@ -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 }));
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import { spawnSync } from 'child_process';
|
|
9
9
|
import { relative } from 'path';
|
|
10
|
-
import { HYPO_DIR, loadHypoIgnore, isIgnored } from './hypo-shared.mjs';
|
|
10
|
+
import { HYPO_DIR, loadHypoIgnore, isIgnored, recordTouchedPaths } from './hypo-shared.mjs';
|
|
11
11
|
import { advanceBaseForWrite, hashContent } from './base-store.mjs';
|
|
12
12
|
|
|
13
13
|
// Tools that REPLACE file bytes. The base advance below must fire only for these:
|
|
@@ -40,24 +40,34 @@ if (filePath.startsWith(HYPO_DIR + '/') || filePath === HYPO_DIR) {
|
|
|
40
40
|
spawnSync('git', ['-C', HYPO_DIR, 'add', filePath], { stdio: 'ignore' });
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
|
|
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) {
|
|
43
|
+
if (WRITE_TOOLS.has(input.tool_name)) {
|
|
55
44
|
const rel = relative(HYPO_DIR, filePath);
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
+
}
|
|
61
71
|
}
|
|
62
72
|
}
|
|
63
73
|
|
|
@@ -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 {}
|