hypomnema 1.8.2 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +141 -51
- package/README.ko.md +18 -17
- package/README.md +16 -15
- package/commands/crystallize.md +89 -24
- package/commands/doctor.md +3 -1
- package/docs/ARCHITECTURE.md +45 -14
- package/docs/CONTRIBUTING.md +26 -8
- package/hooks/atomic-write.mjs +44 -0
- package/hooks/base-store.mjs +409 -90
- package/hooks/close-gate-store.mjs +131 -16
- package/hooks/close-journal.mjs +250 -0
- package/hooks/close-receipt.mjs +559 -0
- package/hooks/hooks.json +3 -38
- package/hooks/hypo-auto-commit.mjs +80 -10
- package/hooks/hypo-auto-minimal-crystallize.mjs +129 -25
- package/hooks/hypo-auto-stage.mjs +13 -1
- package/hooks/hypo-close-guard.mjs +2 -1
- package/hooks/hypo-cwd-change.mjs +45 -20
- package/hooks/hypo-file-watch.mjs +44 -6
- package/hooks/hypo-first-prompt.mjs +15 -9
- package/hooks/hypo-hot-rebuild.mjs +169 -112
- package/hooks/hypo-personal-check.mjs +9 -1
- package/hooks/hypo-session-record.mjs +1 -1
- package/hooks/hypo-session-start.mjs +566 -20
- package/hooks/hypo-shared.mjs +2250 -206
- package/hooks/hypo-stop.mjs +207 -0
- package/hooks/proposal-store.mjs +201 -35
- package/hooks/shared.json +15 -0
- package/hooks/version-check.mjs +9 -7
- package/package.json +3 -1
- package/scripts/crystallize.mjs +3 -4
- package/scripts/doctor.mjs +549 -123
- package/scripts/init.mjs +86 -109
- package/scripts/lib/core-hooks.mjs +48 -22
- package/scripts/lib/crystallize-close-apply.mjs +2397 -188
- package/scripts/lib/crystallize-close-check.mjs +43 -23
- package/scripts/lib/design-history-stale.mjs +36 -9
- package/scripts/lib/frontmatter.mjs +39 -0
- package/scripts/lib/git-hooks-dir.mjs +427 -52
- package/scripts/lib/hook-inventory.mjs +180 -0
- package/scripts/lib/page-usage.mjs +1 -1
- package/scripts/lib/pkg-provenance.mjs +18 -1
- package/scripts/lib/plugin-detect.mjs +43 -9
- package/scripts/lib/project-create.mjs +40 -44
- package/scripts/lib/schema-vocab.mjs +22 -11
- package/scripts/lib/template-schema-version.mjs +48 -0
- package/scripts/lib/time.mjs +46 -0
- package/scripts/lint.mjs +66 -18
- package/scripts/proposal.mjs +423 -18
- package/scripts/resume.mjs +3 -2
- package/scripts/session-audit.mjs +2 -1
- package/scripts/uninstall.mjs +144 -66
- package/scripts/upgrade.mjs +523 -234
- package/scripts/verify.mjs +2 -1
- package/scripts/weekly-report.mjs +2 -1
- package/templates/SCHEMA.md +27 -7
- package/templates/gitignore +11 -0
- package/templates/hypo-automation.md +10 -13
- package/templates/hypo-config.md +1 -1
- package/templates/hypo-guide.md +53 -25
- package/templates/hot.md +0 -24
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* hypo-auto-commit.mjs
|
|
3
|
+
* hypo-auto-commit.mjs: Stop chain stage 3 (spawned by hypo-stop.mjs)
|
|
4
4
|
*
|
|
5
5
|
* At session end: stage this session's touched paths, commit if any, then
|
|
6
6
|
* pull+push to sync remote.
|
|
@@ -31,11 +31,15 @@
|
|
|
31
31
|
import { spawnSync } from 'child_process';
|
|
32
32
|
import {
|
|
33
33
|
HYPO_DIR,
|
|
34
|
-
|
|
34
|
+
pullRemote,
|
|
35
|
+
pushRemote,
|
|
36
|
+
resolvePushTarget,
|
|
35
37
|
commitWikiChanges,
|
|
36
38
|
commitTouchedPaths,
|
|
37
39
|
vaultCommitLockTarget,
|
|
38
40
|
withFileLock,
|
|
41
|
+
rootHotProjectionIsCurrent,
|
|
42
|
+
writeRootHotHealthNotice,
|
|
39
43
|
} from './hypo-shared.mjs';
|
|
40
44
|
|
|
41
45
|
function hasRemote() {
|
|
@@ -76,6 +80,27 @@ const sessionId = input.session_id || input.sessionId || null;
|
|
|
76
80
|
// lock file, so the two nest without any ordering conflict (vault lock is
|
|
77
81
|
// always acquired first here; accumulation elsewhere only ever takes the
|
|
78
82
|
// per-session lock, never the vault lock).
|
|
83
|
+
//
|
|
84
|
+
// The PUSH is deliberately not in here (review r5-w4 major 2). `git pull
|
|
85
|
+
// --no-rebase` rewrites working-tree files, so it stays under the lock with
|
|
86
|
+
// the commit. `git push` changes nothing locally, and it is a network round
|
|
87
|
+
// trip with a 30s spawn timeout: holding the vault lock across it let one
|
|
88
|
+
// session's Stop own the vault for up to a minute, while a sibling
|
|
89
|
+
// SessionStart needs that same lock TWICE (its own `git pull` and the root
|
|
90
|
+
// hot.md projection write) at 5s each, out of a 30s hook budget. The pushable
|
|
91
|
+
// decision is made inside the lock and acted on after it is released.
|
|
92
|
+
//
|
|
93
|
+
// "The push changes nothing locally" was the whole justification for that
|
|
94
|
+
// split, and it was only half true (codex major). What the push sends is
|
|
95
|
+
// decided at push time from HEAD, so a sibling session that commits in the
|
|
96
|
+
// window between the unlock and the push moves HEAD, and a bare `git push`
|
|
97
|
+
// sends THEIR commit instead of the one this hook just made and verified.
|
|
98
|
+
// So the target is pinned inside the lock, not just the decision to push:
|
|
99
|
+
// `pushTarget` names one commit object and one fully-qualified remote ref, and
|
|
100
|
+
// pushRemote sends exactly that or declines. See pushRemote's own comment for
|
|
101
|
+
// the half this does NOT close: a client-side pre-push hook still runs
|
|
102
|
+
// arbitrary code, from outside the lock, on this working tree.
|
|
103
|
+
let pushTarget = null;
|
|
79
104
|
try {
|
|
80
105
|
withFileLock(
|
|
81
106
|
vaultCommitLockTarget(HYPO_DIR),
|
|
@@ -84,17 +109,55 @@ try {
|
|
|
84
109
|
// success — clear exactly what committed, ALL under one hold of the
|
|
85
110
|
// per-session lock. See commitTouchedPaths's docstring for why a
|
|
86
111
|
// commit failure or a same-path race can't lose anything under this.
|
|
87
|
-
const result = commitTouchedPaths(HYPO_DIR, sessionId, (paths) =>
|
|
88
|
-
|
|
89
|
-
|
|
112
|
+
const result = commitTouchedPaths(HYPO_DIR, sessionId, (paths) => {
|
|
113
|
+
// BLOCKER fix (r5-w1.md): 'hot.md' being IN this session's
|
|
114
|
+
// touched-paths set only ever proves this session once intended to
|
|
115
|
+
// write it, never that the bytes on disk right now are what it
|
|
116
|
+
// actually wrote: a scan error at SessionStart, a lock-timeout on
|
|
117
|
+
// the post-write record, or a claim simply outliving a later
|
|
118
|
+
// mid-session edit can all leave the claim standing over content
|
|
119
|
+
// this session never produced. Re-verify at this last possible
|
|
120
|
+
// moment, right before staging, instead of trying to revoke the
|
|
121
|
+
// claim on every branch that can go wrong upstream: a mismatch means
|
|
122
|
+
// whatever is on disk did not come from this session's own write, so
|
|
123
|
+
// it is dropped from THIS commit rather than swept in. It is not
|
|
124
|
+
// lost: the next SessionStart/Stop that manages a successful scan
|
|
125
|
+
// backs it up through the same ownership check before overwriting.
|
|
126
|
+
// n1 fix: sessionId, not just HYPO_DIR, since a sibling session's write
|
|
127
|
+
// updates the global ownership hash too, so this session's own
|
|
128
|
+
// receipt must independently confirm it produced the current bytes.
|
|
129
|
+
const scoped = rootHotProjectionIsCurrent(HYPO_DIR, sessionId)
|
|
130
|
+
? paths
|
|
131
|
+
: paths.filter((p) => p !== 'hot.md');
|
|
132
|
+
if (scoped.length !== paths.length) {
|
|
133
|
+
writeRootHotHealthNotice(
|
|
134
|
+
HYPO_DIR,
|
|
135
|
+
'루트 hot.md이 이번 세션이 실제로 쓴 내용과 달라 이번 커밋에서 제외했습니다. 다음 세션 시작/종료 시 다시 확인됩니다.',
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
return commitWikiChanges(HYPO_DIR, scoped);
|
|
139
|
+
});
|
|
90
140
|
if (!result.committed) return;
|
|
91
141
|
|
|
92
142
|
if (hasRemote()) {
|
|
93
143
|
// pull/push failures must not stop the session, but they can no longer be
|
|
94
|
-
// swallowed silently
|
|
95
|
-
// on a merge conflict,
|
|
96
|
-
// (part of the v1.4 sync hardening).
|
|
97
|
-
|
|
144
|
+
// swallowed silently: pullRemote/pushRemote record each to
|
|
145
|
+
// .cache/sync-state.json and, on a merge conflict, the pull aborts the merge
|
|
146
|
+
// so the tree is never left half-merged (part of the v1.4 sync hardening).
|
|
147
|
+
// session-start + doctor surface the result next session.
|
|
148
|
+
//
|
|
149
|
+
// A conflicting pull leaves this branch diverged, so the push is
|
|
150
|
+
// suppressed rather than deferred: the same ordering guarantee
|
|
151
|
+
// syncRemote has always had, now carried across the lock boundary by
|
|
152
|
+
// this target instead of by a `return`.
|
|
153
|
+
//
|
|
154
|
+
// Resolved AFTER the pull, not from `result.sha`: a clean
|
|
155
|
+
// `git pull --no-rebase` can land a merge commit on top of ours, and
|
|
156
|
+
// pushing the pre-merge sha would be rejected as a non-fast-forward.
|
|
157
|
+
// HEAD here is still under the lock, so it is this session's commit
|
|
158
|
+
// (plus whatever the pull brought down) and nothing a sibling added
|
|
159
|
+
// after the unlock.
|
|
160
|
+
if (!pullRemote(HYPO_DIR).conflict) pushTarget = resolvePushTarget(HYPO_DIR);
|
|
98
161
|
}
|
|
99
162
|
},
|
|
100
163
|
{ timeoutMs: VAULT_LOCK_TIMEOUT_MS },
|
|
@@ -104,7 +167,14 @@ try {
|
|
|
104
167
|
// never entered the critical section, so commitTouchedPaths never ran —
|
|
105
168
|
// the touched-paths file is untouched on disk, and the next Stop retries
|
|
106
169
|
// this session's commit from the same scope. Best-effort, like every
|
|
107
|
-
// other step in this hook.
|
|
170
|
+
// other step in this hook. `pushTarget` is still null here, so a lock
|
|
171
|
+
// we never took can never turn into a push.
|
|
108
172
|
}
|
|
109
173
|
|
|
174
|
+
// Outside the lock on purpose: see the note above the try block. Guarded by a
|
|
175
|
+
// target resolved only after a successful commit and a non-conflicting pull,
|
|
176
|
+
// so the set of cases that push is exactly the set that pushed before this
|
|
177
|
+
// split, and what each one sends is fixed before the lock goes.
|
|
178
|
+
if (pushTarget) pushRemote(HYPO_DIR, pushTarget);
|
|
179
|
+
|
|
110
180
|
console.log(JSON.stringify({ continue: true, suppressOutput: true }));
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* hypo-auto-minimal-crystallize.mjs
|
|
3
|
+
* hypo-auto-minimal-crystallize.mjs: Stop chain stage 4, last (Layer 3)
|
|
4
|
+
*
|
|
5
|
+
* Spawned by hypo-stop.mjs, which is the only Stop registration. This stage runs
|
|
6
|
+
* last on purpose: it is the only one that can emit `decision: "block"`, and its
|
|
7
|
+
* close gate reads the committed state stage 3 (hypo-auto-commit) produced.
|
|
4
8
|
*
|
|
5
9
|
* Last hook in the Stop chain: a final-line defense that blocks `Stop` when
|
|
6
10
|
* the current session did substantial work (mutation, or a high-volume
|
|
@@ -17,8 +21,19 @@
|
|
|
17
21
|
* calls (Read/Grep/Glob/Bash) — 6a, so read-only review/debug sessions
|
|
18
22
|
* are also nudged to close. Pure Q&A / incidental lookups still skip.
|
|
19
23
|
* 4. no recent user close-intent → continue (close-intent gate, see below)
|
|
20
|
-
* 5.
|
|
21
|
-
*
|
|
24
|
+
* 5. close verdict for session_id, closeCheckpointState (close-receipt.mjs):
|
|
25
|
+
* a. closed: valid receipt + a marker naming its generation
|
|
26
|
+
* → continue (cwd/log-only exemption unchanged)
|
|
27
|
+
* b. legacy-closed: no receipt, marker with NO receiptGeneration
|
|
28
|
+
* → continue (marker's own 7-day TTL)
|
|
29
|
+
* c. broken: receipt and marker disagree (a marker promising a missing,
|
|
30
|
+
* invalid or different receipt, or a valid receipt with no marker or
|
|
31
|
+
* a mismatched one) → fall through to block, naming why
|
|
32
|
+
* d. open: neither → fall through to block
|
|
33
|
+
* A valid receipt only certifies the files it names (the checkpoint
|
|
34
|
+
* contract), so branch (a) also fires an unresolved-changes
|
|
35
|
+
* systemMessage once per receipt generation (see notifyUnresolved).
|
|
36
|
+
* This is never a block, only a notice riding along on the continue reply.
|
|
22
37
|
* 6. otherwise → decision:block
|
|
23
38
|
*
|
|
24
39
|
* Close-intent gate (added after PR-C dogfooding revealed every-turn block —
|
|
@@ -55,13 +70,12 @@
|
|
|
55
70
|
* mode the per-session marker was introduced to prevent.
|
|
56
71
|
*/
|
|
57
72
|
|
|
58
|
-
import { existsSync } from 'fs';
|
|
59
|
-
import { join } from 'path';
|
|
73
|
+
import { existsSync, readFileSync } from 'fs';
|
|
74
|
+
import { join, dirname } from 'path';
|
|
60
75
|
import {
|
|
61
76
|
HYPO_DIR,
|
|
62
77
|
PKG_ROOT,
|
|
63
78
|
isSubstantialSession,
|
|
64
|
-
readSessionClosedMarker,
|
|
65
79
|
extractUserMessages,
|
|
66
80
|
isClosePattern,
|
|
67
81
|
isGateSkipped,
|
|
@@ -70,10 +84,72 @@ import {
|
|
|
70
84
|
isCloseReconfirmDeclined,
|
|
71
85
|
CLOSE_RECONFIRM_MARK,
|
|
72
86
|
resolveGateProjectOverride,
|
|
87
|
+
gitDirtyFiles,
|
|
73
88
|
} from './hypo-shared.mjs';
|
|
89
|
+
import { closeCheckpointState, isCloseComplete, receiptPath } from './close-receipt.mjs';
|
|
90
|
+
import { atomicWrite } from './atomic-write.mjs';
|
|
91
|
+
|
|
92
|
+
// Carrying a systemMessage does not turn this into a block: the session is
|
|
93
|
+
// still free to end, the message just rides along on the same continue reply
|
|
94
|
+
// (hooks/hypo-stop.mjs merges every stage's systemMessage into one).
|
|
95
|
+
function emitContinue(systemMessage) {
|
|
96
|
+
const out = { continue: true, suppressOutput: !systemMessage };
|
|
97
|
+
if (systemMessage) out.systemMessage = systemMessage;
|
|
98
|
+
console.log(JSON.stringify(out));
|
|
99
|
+
}
|
|
74
100
|
|
|
75
|
-
|
|
76
|
-
|
|
101
|
+
// The checkpoint contract: a valid receipt proves only the files it names were
|
|
102
|
+
// committed at the receipt's commit, never that the rest of the session's work
|
|
103
|
+
// was saved, and never that a named file still has those bytes. Every dirty
|
|
104
|
+
// path is unresolved: a path outside the receipt's entries was never certified,
|
|
105
|
+
// and a path inside them differs from HEAD now, so the bytes on disk are
|
|
106
|
+
// committed nowhere (the receipt's commit is an ancestor of HEAD). The second
|
|
107
|
+
// kind is listed apart as changed after the close checkpoint. The notice fires
|
|
108
|
+
// once per receipt generation, or again once the dirty list itself changes,
|
|
109
|
+
// never on every Stop turn a long session produces. State lives next to the
|
|
110
|
+
// receipt it is keyed to (`.cache/sessions/<sid>/`), so an invalidated or
|
|
111
|
+
// replaced receipt (a fresh close attempt) starts this notice fresh too.
|
|
112
|
+
function notifyUnresolved(hypoDir, sessionId, receipt) {
|
|
113
|
+
const certified = new Set((receipt.entries || []).map((e) => e && e.path));
|
|
114
|
+
const dirty = gitDirtyFiles(hypoDir).sort();
|
|
115
|
+
if (dirty.length === 0) return null;
|
|
116
|
+
const rp = receiptPath(hypoDir, sessionId);
|
|
117
|
+
if (!rp) return null;
|
|
118
|
+
const notifyPath = join(dirname(rp), 'unresolved-notified.json');
|
|
119
|
+
let prior = null;
|
|
120
|
+
try {
|
|
121
|
+
prior = JSON.parse(readFileSync(notifyPath, 'utf-8'));
|
|
122
|
+
} catch {
|
|
123
|
+
prior = null;
|
|
124
|
+
}
|
|
125
|
+
const sameList =
|
|
126
|
+
prior &&
|
|
127
|
+
prior.generation === receipt.generation &&
|
|
128
|
+
Array.isArray(prior.files) &&
|
|
129
|
+
prior.files.length === dirty.length &&
|
|
130
|
+
prior.files.every((f, i) => f === dirty[i]);
|
|
131
|
+
if (sameList) return null;
|
|
132
|
+
try {
|
|
133
|
+
atomicWrite(notifyPath, JSON.stringify({ generation: receipt.generation, files: dirty }));
|
|
134
|
+
} catch {
|
|
135
|
+
// Best-effort: a failed write only means this same notice repeats next
|
|
136
|
+
// turn, never that it silently stops appearing.
|
|
137
|
+
}
|
|
138
|
+
const uncertified = dirty.filter((f) => !certified.has(f));
|
|
139
|
+
const changedAfter = dirty.filter((f) => certified.has(f));
|
|
140
|
+
const parts = [];
|
|
141
|
+
if (uncertified.length > 0) parts.push(uncertified.join(', '));
|
|
142
|
+
if (changedAfter.length > 0) {
|
|
143
|
+
// No ownership test here: a shared append target such as log.md is in
|
|
144
|
+
// nearly every receipt, and another session's append makes it dirty too.
|
|
145
|
+
parts.push(
|
|
146
|
+
`[close 체크포인트 뒤 바뀜(이 세션 또는 다른 세션), 지금 내용은 커밋되지 않음: ${changedAfter.join(', ')}]`,
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
return (
|
|
150
|
+
`[WIKI_AUTOCLOSE] close checkpoint 확인됨 (session_id=${sessionId}). 다만 이 체크포인트가 ` +
|
|
151
|
+
`증명하지 않는 미해결 변경이 있습니다: ${parts.join(' ')}`
|
|
152
|
+
);
|
|
77
153
|
}
|
|
78
154
|
|
|
79
155
|
function emitBlock(sessionId, transcriptPath, gate = null, opts = {}) {
|
|
@@ -92,6 +168,12 @@ function emitBlock(sessionId, transcriptPath, gate = null, opts = {}) {
|
|
|
92
168
|
// label can't drift out of sync silently. The decline label ("아직, 계속")
|
|
93
169
|
// stays a literal here — it is matched by the tolerant DECLINE regex in
|
|
94
170
|
// isCloseReconfirmDeclined, not by an exact-mark check.
|
|
171
|
+
// A broken checkpoint (receipt and marker disagree) is not "never closed":
|
|
172
|
+
// say so, whichever wording the block takes, so the rerun is understood as
|
|
173
|
+
// re-issuing both artifacts rather than as a first close.
|
|
174
|
+
const brokenNote = opts.brokenReason
|
|
175
|
+
? ` 이전 close 체크포인트가 깨져 있어 완료로 보지 않았습니다: ${opts.brokenReason}. close 를 다시 실행하면 영수증과 마커를 함께 새로 씁니다. --mark-session-closed 가 prior-checkpoint-rewritten 으로 거부되면 이전 close 가 증명한 커밋이 히스토리에서 사라진 것이니, 그 커밋을 되살리거나 사용자에게 다시 close 를 요청받아 /hypo:crystallize 로 새 close 를 적용하세요.`
|
|
176
|
+
: '';
|
|
95
177
|
if (opts.reconfirm) {
|
|
96
178
|
console.log(
|
|
97
179
|
JSON.stringify({
|
|
@@ -102,7 +184,8 @@ function emitBlock(sessionId, transcriptPath, gate = null, opts = {}) {
|
|
|
102
184
|
`임의로 닫지 말고 AskUserQuestion으로 사용자에게 지금 세션을 닫을지 물어보세요. ` +
|
|
103
185
|
`선택지는 "${CLOSE_RECONFIRM_MARK}"와 "아직, 계속"으로 제시합니다. 사용자가 ` +
|
|
104
186
|
`"${CLOSE_RECONFIRM_MARK}"를 고른 뒤에만 세션 마무리를 진행하세요. 그전에는 ` +
|
|
105
|
-
`마커를 쓰거나 종료 명령을 실행하지
|
|
187
|
+
`마커를 쓰거나 종료 명령을 실행하지 마세요.` +
|
|
188
|
+
brokenNote,
|
|
106
189
|
stopReason: 'session-close incomplete (Layer 3, reconfirm)',
|
|
107
190
|
}),
|
|
108
191
|
);
|
|
@@ -172,7 +255,7 @@ function emitBlock(sessionId, transcriptPath, gate = null, opts = {}) {
|
|
|
172
255
|
console.log(
|
|
173
256
|
JSON.stringify({
|
|
174
257
|
decision: 'block',
|
|
175
|
-
reason,
|
|
258
|
+
reason: reason + brokenNote,
|
|
176
259
|
stopReason: 'session-close incomplete (Layer 3)',
|
|
177
260
|
}),
|
|
178
261
|
);
|
|
@@ -244,6 +327,10 @@ process.stdin.on('end', () => {
|
|
|
244
327
|
// (a project marker whose cwd project is complete) still short-circuits without
|
|
245
328
|
// paying the gate cost, unless a cwd signal makes the cwd check meaningful.
|
|
246
329
|
let gate = null;
|
|
330
|
+
// This session's close verdict (step 5), read once and shared with the
|
|
331
|
+
// gate so its log-only and attribution reading uses the same marker Stop
|
|
332
|
+
// judged, never one the verdict rejected.
|
|
333
|
+
let checkpoint = null;
|
|
247
334
|
const computeGate = () => {
|
|
248
335
|
try {
|
|
249
336
|
// resolveGateProjectOverride (session-close-scope-boundary spec §2/§3): Stop
|
|
@@ -258,6 +345,9 @@ process.stdin.on('end', () => {
|
|
|
258
345
|
...(transcriptPath ? { transcriptPath } : {}),
|
|
259
346
|
...(sessionCwd ? { sessionCwd } : {}),
|
|
260
347
|
...(sessionId ? { sessionId } : {}),
|
|
348
|
+
...(checkpoint
|
|
349
|
+
? { closeMarker: isCloseComplete(checkpoint) ? checkpoint.marker : null }
|
|
350
|
+
: {}),
|
|
261
351
|
...(attributionScope ? { attributionScope } : {}),
|
|
262
352
|
});
|
|
263
353
|
} catch {
|
|
@@ -265,28 +355,38 @@ process.stdin.on('end', () => {
|
|
|
265
355
|
}
|
|
266
356
|
};
|
|
267
357
|
|
|
268
|
-
// 5. close
|
|
269
|
-
//
|
|
270
|
-
//
|
|
271
|
-
//
|
|
272
|
-
//
|
|
273
|
-
//
|
|
274
|
-
// (
|
|
358
|
+
// 5. close verdict for this session_id (order in the file header), from
|
|
359
|
+
// closeCheckpointState, the same verdict check, doctor and PreCompact use.
|
|
360
|
+
// A finished close only attests the project(s)/scope it recorded: if THIS
|
|
361
|
+
// session's cwd project still has an unstarted close, honoring it would end
|
|
362
|
+
// the session green while that project stays open (the session-cwd
|
|
363
|
+
// false-green), so the cwd re-check below still runs. A broken checkpoint
|
|
364
|
+
// (receipt and marker disagree) falls through to block and says why.
|
|
365
|
+
let brokenReason = null;
|
|
275
366
|
if (sessionId) {
|
|
276
|
-
|
|
277
|
-
if (
|
|
278
|
-
|
|
279
|
-
|
|
367
|
+
checkpoint = closeCheckpointState(HYPO_DIR, sessionId);
|
|
368
|
+
if (isCloseComplete(checkpoint)) {
|
|
369
|
+
const { receipt, marker } = checkpoint;
|
|
370
|
+
// A legacy close has no receipt, so no certified file list to compare
|
|
371
|
+
// the dirty tree against: no unresolved-changes notice, same as before.
|
|
372
|
+
const notice = () => (receipt ? notifyUnresolved(HYPO_DIR, sessionId, receipt) : undefined);
|
|
373
|
+
const isLogOnly = receipt
|
|
374
|
+
? receipt.scope?.mode === 'log-only'
|
|
375
|
+
: marker.scope === 'log-only';
|
|
376
|
+
if (isLogOnly || !sessionCwd) {
|
|
377
|
+
emitContinue(notice());
|
|
280
378
|
return;
|
|
281
379
|
}
|
|
282
380
|
gate = computeGate();
|
|
283
381
|
const cwdBlocked = !!gate?.blockers?.some((b) => b.type === 'close-cwd');
|
|
284
382
|
if (!cwdBlocked) {
|
|
285
|
-
emitContinue();
|
|
383
|
+
emitContinue(notice());
|
|
286
384
|
return;
|
|
287
385
|
}
|
|
288
|
-
//
|
|
289
|
-
// through to block, reusing the gate computed above.
|
|
386
|
+
// finished close, but the session's cwd project close is incomplete:
|
|
387
|
+
// fall through to block, reusing the gate computed above.
|
|
388
|
+
} else if (checkpoint.state === 'broken') {
|
|
389
|
+
brokenReason = checkpoint.reason;
|
|
290
390
|
}
|
|
291
391
|
}
|
|
292
392
|
|
|
@@ -322,7 +422,11 @@ process.stdin.on('end', () => {
|
|
|
322
422
|
return;
|
|
323
423
|
}
|
|
324
424
|
|
|
325
|
-
emitBlock(sessionId, transcriptPath, gate, {
|
|
425
|
+
emitBlock(sessionId, transcriptPath, gate, {
|
|
426
|
+
reconfirm: workIncomplete,
|
|
427
|
+
sessionCwd,
|
|
428
|
+
brokenReason,
|
|
429
|
+
});
|
|
326
430
|
} catch (err) {
|
|
327
431
|
// Fail-open on any unexpected error.
|
|
328
432
|
process.stderr.write(`[hypo-auto-minimal-crystallize] error: ${err?.message ?? String(err)}\n`);
|
|
@@ -48,7 +48,19 @@ if (filePath.startsWith(HYPO_DIR + '/') || filePath === HYPO_DIR) {
|
|
|
48
48
|
// hypo-auto-commit.mjs drains this at Stop instead of sweeping the whole
|
|
49
49
|
// working tree, so another session's concurrent writes to this vault
|
|
50
50
|
// never land in THIS session's commit.
|
|
51
|
-
recordTouchedPaths(HYPO_DIR, input.session_id, rel);
|
|
51
|
+
const recorded = recordTouchedPaths(HYPO_DIR, input.session_id, rel);
|
|
52
|
+
if (!recorded) {
|
|
53
|
+
// A lock-timeout or write failure here leaves the file just written
|
|
54
|
+
// with nothing in ANY session's touched-paths set to bring it into an
|
|
55
|
+
// auto-commit: it sits dirty until an unrelated later write to the
|
|
56
|
+
// same path happens to succeed. This hook's stdout is not suppressed
|
|
57
|
+
// wholesale like the Stop hooks, but its own output is a fixed
|
|
58
|
+
// continue/suppressOutput payload below, so stderr is the only channel
|
|
59
|
+
// available to surface this.
|
|
60
|
+
process.stderr.write(
|
|
61
|
+
`[hypo-auto-stage] ${rel} 를 이번 세션의 커밋 범위에 기록하지 못했습니다\n`,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
52
64
|
|
|
53
65
|
// Write=proposal gate provenance: when this session's own write lands on one of
|
|
54
66
|
// the overwrite targets it snapshotted at start, advance that target's base so
|
|
@@ -32,7 +32,8 @@
|
|
|
32
32
|
*
|
|
33
33
|
* KNOWN WINDOW: the structural signal is only alive for one turn. Stop's
|
|
34
34
|
* auto-commit chain (hypo-auto-commit.mjs → commitTouchedPaths) commits and
|
|
35
|
-
* then CLEARS a session's touched-paths set every time Stop runs
|
|
35
|
+
* then CLEARS a session's touched-paths set every time Stop runs (only a
|
|
36
|
+
* path `.hypoignore` keeps out of the commit stays). Write
|
|
36
37
|
* session-state.md in turn 1 and hot.md in turn 2 (Stop runs in between) and
|
|
37
38
|
* the touched-paths file no longer has the first path — structuralHit reads
|
|
38
39
|
* false. This is accepted, not fixed: a real close writes both files in the
|
|
@@ -2,8 +2,12 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* hypo-cwd-change.mjs — CwdChanged hook
|
|
4
4
|
*
|
|
5
|
-
* When the working directory changes mid-session,
|
|
6
|
-
* project hot.md.
|
|
5
|
+
* When the working directory changes mid-session, display a notification for
|
|
6
|
+
* the matching project hot.md. Tracing the installed binary (Claude Code
|
|
7
|
+
* 2.1.276, reconfirmed on 2.1.283) shows the `systemMessage` this hook emits
|
|
8
|
+
* does not reach the model, only a 5-second terminal toast, so "re-inject"
|
|
9
|
+
* overstates what this hook can actually do. Skips if still within the same
|
|
10
|
+
* project subtree.
|
|
7
11
|
*/
|
|
8
12
|
|
|
9
13
|
import { readFileSync, writeFileSync, existsSync, realpathSync } from 'fs';
|
|
@@ -32,8 +36,8 @@ const PROJECTS_DIR = join(HYPO_DIR, 'projects');
|
|
|
32
36
|
const GLOBAL_HOT = join(HYPO_DIR, 'hot.md');
|
|
33
37
|
const MAX_CHARS = 3000;
|
|
34
38
|
|
|
35
|
-
// Privacy guard: a .hypoignore-matched hot.md must not be
|
|
36
|
-
//
|
|
39
|
+
// Privacy guard: a .hypoignore-matched hot.md must not be re-emitted in this
|
|
40
|
+
// hook's output on cwd change, whichever field carries it.
|
|
37
41
|
//
|
|
38
42
|
// Visibility guard: same contract as hypo-file-watch and hypo-session-start. A
|
|
39
43
|
// machine-scoped hot.md stays off every machine but its owner. Scope is read
|
|
@@ -98,7 +102,7 @@ process.stdin.on('end', () => {
|
|
|
98
102
|
const oldCwd = data.old_cwd || data.old_directory || data.previous_cwd || '';
|
|
99
103
|
const sessionId = data.session_id || 'default';
|
|
100
104
|
|
|
101
|
-
// Skip re-
|
|
105
|
+
// Skip re-notifying if still in the same project
|
|
102
106
|
const oldHit = oldCwd ? findProjectHot(oldCwd) : null;
|
|
103
107
|
const newHit = findProjectHot(newCwd);
|
|
104
108
|
|
|
@@ -109,13 +113,13 @@ process.stdin.on('end', () => {
|
|
|
109
113
|
|
|
110
114
|
const ignorePatterns = loadHypoIgnore(HYPO_DIR);
|
|
111
115
|
|
|
112
|
-
//
|
|
113
|
-
// (see base-store.mjs's recordObserved): a session's tracked
|
|
116
|
+
// Reading this hot.md is not a licensing surface for the observed-set
|
|
117
|
+
// gate (see base-store.mjs's recordObserved): a session's tracked
|
|
114
118
|
// `targets` are fixed at whichever project the OWNING SessionStart hit,
|
|
115
119
|
// and a cwd move can land here in a different project entirely, so
|
|
116
120
|
// recording an observation against this read could credit a path outside
|
|
117
121
|
// that fixed target set. Nothing here calls recordObserved; the guard
|
|
118
|
-
// stays exactly as conservative for a cwd-change
|
|
122
|
+
// stays exactly as conservative for a cwd-change notice as it was
|
|
119
123
|
// before the observed set existed.
|
|
120
124
|
if (newHit) {
|
|
121
125
|
const fromFile = readIfNotIgnored(newHit.hotPath, ignorePatterns);
|
|
@@ -125,10 +129,12 @@ process.stdin.on('end', () => {
|
|
|
125
129
|
? '(hot.md for this project is scoped to another machine and is not visible here)'
|
|
126
130
|
: '(no hot.md yet — will be created at session close)');
|
|
127
131
|
// arm the first-prompt marker so the NEXT user prompt re-triggers
|
|
128
|
-
// hypo-first-prompt, which forces
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
//
|
|
132
|
+
// hypo-first-prompt, which forces Claude's reply to lead with a
|
|
133
|
+
// "Resuming <project>" line, verbatim rather than a content summary
|
|
134
|
+
// (see hypo-first-prompt.mjs's cwdMove branch). Only arm when real hot
|
|
135
|
+
// content was actually found; if hot.md is missing or .hypoignore'd
|
|
136
|
+
// (fromFile null), there is no snapshot to resume from, so claiming
|
|
137
|
+
// "Resuming" would be false.
|
|
132
138
|
if (fromFile) {
|
|
133
139
|
try {
|
|
134
140
|
writeFileSync(
|
|
@@ -152,16 +158,35 @@ process.stdin.on('end', () => {
|
|
|
152
158
|
// working_dir distinct from the vault, surface where wiki files live.
|
|
153
159
|
const vaultOrientation = buildVaultOrientation(newCwd);
|
|
154
160
|
const orientPrefix = vaultOrientation ? `${vaultOrientation}\n\n` : '';
|
|
155
|
-
// Built inline rather than through buildOutput():
|
|
156
|
-
//
|
|
157
|
-
//
|
|
158
|
-
//
|
|
159
|
-
//
|
|
161
|
+
// Built inline rather than through buildOutput(): the hook reference's
|
|
162
|
+
// CwdChanged section documents watchPaths and systemMessage as the
|
|
163
|
+
// fields Claude Code reads from that event's output, and neither is an
|
|
164
|
+
// additionalContext path, so there is no additionalContext path here.
|
|
165
|
+
// CwdChanged and FileChanged each get their own section and each repeats
|
|
166
|
+
// this sentence, so the two are not sharing one paragraph (checked
|
|
167
|
+
// against the published reference 2026-09-21). The notice rides
|
|
168
|
+
// systemMessage instead. The reference says systemMessage "shows the
|
|
169
|
+
// systemMessage as a brief terminal notification" and "doesn't reach
|
|
170
|
+
// the SDK message stream," but it does not say whether systemMessage
|
|
171
|
+
// reaches the model on this event either way, so treat that as
|
|
172
|
+
// undocumented rather than denied. As of Claude Code 2.1.276 (checked
|
|
173
|
+
// 2026-09-18) and reconfirmed on 2.1.283, tracing the installed binary
|
|
174
|
+
// shows the only consumer of this systemMessage is the terminal
|
|
175
|
+
// notification queue: a low-priority toast (priority 'low', timeoutMs
|
|
176
|
+
// 5000, key 'env-hook') that a later same-keyed message replaces and
|
|
177
|
+
// that fades after five seconds, with no branch that forwards it to the
|
|
178
|
+
// model; outside the interactive REPL (SDK, headless) the injecting
|
|
179
|
+
// function is null, so the message is simply dropped. No live-session
|
|
180
|
+
// observation of this path exists either way. Until the documentation
|
|
181
|
+
// says otherwise, treat this event as not reaching the model.
|
|
182
|
+
// Whatever the documentation eventually says, the .hypoignore and
|
|
183
|
+
// visibility guards above run first, so a withheld page never reaches
|
|
184
|
+
// this line.
|
|
160
185
|
console.log(
|
|
161
186
|
JSON.stringify({
|
|
162
187
|
continue: true,
|
|
163
188
|
suppressOutput: true,
|
|
164
|
-
|
|
189
|
+
systemMessage: `${orientPrefix}[WIKI: cwd changed → project=${sanitizeProjForPrompt(newHit.proj)}]\n\n${content}`,
|
|
165
190
|
}),
|
|
166
191
|
);
|
|
167
192
|
return;
|
|
@@ -202,7 +227,7 @@ process.stdin.on('end', () => {
|
|
|
202
227
|
JSON.stringify({
|
|
203
228
|
continue: true,
|
|
204
229
|
suppressOutput: true,
|
|
205
|
-
|
|
230
|
+
systemMessage: suggestPrefix.trimEnd(),
|
|
206
231
|
}),
|
|
207
232
|
);
|
|
208
233
|
} else {
|
|
@@ -214,7 +239,7 @@ process.stdin.on('end', () => {
|
|
|
214
239
|
JSON.stringify({
|
|
215
240
|
continue: true,
|
|
216
241
|
suppressOutput: true,
|
|
217
|
-
|
|
242
|
+
systemMessage: `${suggestPrefix}[WIKI: cwd changed → no project match, global hot notice]\n\n${globalContent}`,
|
|
218
243
|
}),
|
|
219
244
|
);
|
|
220
245
|
} catch (err) {
|
|
@@ -2,8 +2,13 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* hypo-file-watch.mjs — FileChanged hook
|
|
4
4
|
*
|
|
5
|
-
* When
|
|
6
|
-
*
|
|
5
|
+
* When any file under HYPO_DIR changes, build a notification of its contents.
|
|
6
|
+
* FileChanged fires for edits from any source: Claude's own Write/Edit/Bash
|
|
7
|
+
* tools as well as an external process (a remote agent, another Claude Code
|
|
8
|
+
* session), not only external ones. Tracing the installed binary (Claude Code
|
|
9
|
+
* 2.1.276, reconfirmed on 2.1.283) shows that notification is a five-second
|
|
10
|
+
* terminal toast that does not reach the model, so this hook does not put
|
|
11
|
+
* anything back in front of the model, only the terminal.
|
|
7
12
|
*/
|
|
8
13
|
|
|
9
14
|
import { readFileSync, existsSync } from 'fs';
|
|
@@ -36,9 +41,12 @@ process.stdin.on('end', () => {
|
|
|
36
41
|
return;
|
|
37
42
|
}
|
|
38
43
|
|
|
39
|
-
// Privacy guard: refuse to
|
|
40
|
-
//
|
|
41
|
-
//
|
|
44
|
+
// Privacy guard: refuse to emit .hypoignore-matched paths. Without this,
|
|
45
|
+
// `.env*` or other secrets under HYPO_DIR are re-emitted in this hook's
|
|
46
|
+
// output. The guard does not depend on which field carries that output:
|
|
47
|
+
// whether or not the systemMessage terminal notification documented for
|
|
48
|
+
// FileChanged actually appears on a given machine is unmeasured, so the
|
|
49
|
+
// only safe assumption is that a matched path must never be put in it.
|
|
42
50
|
const patterns = loadHypoIgnore(HYPO_DIR);
|
|
43
51
|
if (patterns.length > 0 && isIgnored(filePath, HYPO_DIR, patterns)) {
|
|
44
52
|
console.log(JSON.stringify({ continue: true, suppressOutput: true }));
|
|
@@ -65,11 +73,41 @@ process.stdin.on('end', () => {
|
|
|
65
73
|
const content = fileRaw.slice(0, MAX_CHARS);
|
|
66
74
|
const relPath = filePath.replace(HYPO_DIR + '/', '');
|
|
67
75
|
|
|
76
|
+
// Built inline rather than through buildOutput(): the hook reference's
|
|
77
|
+
// FileChanged section documents the same two fields as its CwdChanged
|
|
78
|
+
// section (watchPaths, systemMessage), and neither is an additionalContext
|
|
79
|
+
// path, so the notice rides systemMessage. The two events are documented
|
|
80
|
+
// separately, each repeating the sentence for itself (checked against the
|
|
81
|
+
// published reference 2026-09-21). The reference says systemMessage
|
|
82
|
+
// "shows the systemMessage as a brief terminal notification" and
|
|
83
|
+
// "doesn't reach the SDK message stream," but it does not say whether
|
|
84
|
+
// systemMessage reaches the model on this event either way. As of Claude
|
|
85
|
+
// Code 2.1.276 (checked 2026-09-18) and reconfirmed on 2.1.283, tracing
|
|
86
|
+
// the installed binary shows the same shared consumer as
|
|
87
|
+
// hypo-cwd-change.mjs: a low-priority terminal toast with no branch to
|
|
88
|
+
// the model, dropped entirely outside the interactive REPL. No
|
|
89
|
+
// live-session observation of this path exists either way. Until the
|
|
90
|
+
// documentation says otherwise, treat this event as not reaching the
|
|
91
|
+
// model.
|
|
92
|
+
// On a stock install this hook still has no registered trigger: its
|
|
93
|
+
// matcher is omitted (not blank), which the reference says matches every
|
|
94
|
+
// watched file while adding nothing to the watch list, and nothing in
|
|
95
|
+
// this package returns watchPaths to seed that list. The watch list is
|
|
96
|
+
// session-global though, not scoped to this plugin: if another hook or
|
|
97
|
+
// plugin seeds it, this matcher-less group fires on every watched file,
|
|
98
|
+
// not just wiki ones. The HYPO_DIR check above (`filePath.startsWith(HYPO_DIR
|
|
99
|
+
// + '/')`, a string-prefix test that does not resolve symlinks) still
|
|
100
|
+
// returns immediately for any path that does not start with HYPO_DIR, so
|
|
101
|
+
// a watch seeded elsewhere cannot point this hook at a path outside the
|
|
102
|
+
// wiki. A symlink living inside the wiki is a separate case: its path
|
|
103
|
+
// still starts with HYPO_DIR and passes the check above, but
|
|
104
|
+
// readFileSync follows it, so whatever that link points to, inside the
|
|
105
|
+
// wiki or not, is what actually gets emitted.
|
|
68
106
|
console.log(
|
|
69
107
|
JSON.stringify({
|
|
70
108
|
continue: true,
|
|
71
109
|
suppressOutput: true,
|
|
72
|
-
|
|
110
|
+
systemMessage: `[WIKI FILE UPDATED: ${relPath}]\n\n${content}`,
|
|
73
111
|
}),
|
|
74
112
|
);
|
|
75
113
|
} catch (err) {
|