hypomnema 1.2.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.ko.md +72 -26
- package/README.md +53 -7
- package/commands/crystallize.md +23 -6
- package/commands/feedback.md +1 -1
- package/docs/CONTRIBUTING.md +96 -11
- package/hooks/hypo-auto-commit.mjs +3 -3
- package/hooks/hypo-auto-minimal-crystallize.mjs +8 -3
- package/hooks/hypo-cwd-change.mjs +10 -6
- package/hooks/hypo-first-prompt.mjs +40 -8
- package/hooks/hypo-personal-check.mjs +60 -8
- package/hooks/hypo-session-start.mjs +60 -9
- package/hooks/hypo-shared.mjs +162 -13
- package/hooks/version-check.mjs +204 -6
- package/package.json +5 -2
- package/scripts/bump-version.mjs +9 -3
- package/scripts/check-bilingual.mjs +115 -0
- package/scripts/crystallize.mjs +124 -15
- package/scripts/doctor.mjs +45 -9
- package/scripts/feedback-sync.mjs +44 -15
- package/scripts/feedback.mjs +5 -5
- package/scripts/fix-status-verify.mjs +256 -0
- package/scripts/init.mjs +45 -4
- package/scripts/install-git-hooks.mjs +258 -0
- package/scripts/lib/adr-corpus.mjs +79 -0
- package/scripts/lib/check-bilingual.mjs +141 -0
- package/scripts/lib/extensions.mjs +3 -3
- package/scripts/lib/feedback-scope.mjs +21 -0
- package/scripts/lib/fix-manifest.mjs +109 -0
- package/scripts/lib/fix-status-verify.mjs +438 -0
- package/scripts/lib/pre-commit-format.mjs +251 -0
- package/scripts/lib/project-create.mjs +2 -2
- package/scripts/lint.mjs +48 -8
- package/scripts/pre-commit-format.mjs +198 -0
- package/scripts/resume.mjs +5 -1
- package/scripts/smoke-pack.mjs +16 -0
- package/scripts/upgrade.mjs +55 -23
- package/skills/crystallize/SKILL.md +13 -2
- package/templates/hot.md +1 -1
- package/templates/hypo-config.md +1 -1
- package/templates/hypo-guide.md +4 -0
|
@@ -43,9 +43,9 @@ if (staged) {
|
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
if (hasRemote()) {
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
46
|
+
// pull/push failures must not stop the session, but they can no longer be
|
|
47
|
+
// swallowed silently — record each to .cache/sync-state.json so session-start
|
|
48
|
+
// and doctor can surface them next session.
|
|
49
49
|
const pull = git('pull', '--no-rebase', '-q');
|
|
50
50
|
if (pull.status !== 0) appendSyncFailure(HYPO_DIR, 'pull', pull.stderr || pull.stdout);
|
|
51
51
|
const push = git('push');
|
|
@@ -54,12 +54,17 @@ function emitContinue() {
|
|
|
54
54
|
console.log(JSON.stringify({ continue: true, suppressOutput: true }));
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
-
function emitBlock(sessionId) {
|
|
57
|
+
function emitBlock(sessionId, transcriptPath) {
|
|
58
58
|
// One-line, skill-first. /hypo:crystallize is the documented session-close
|
|
59
59
|
// alias; passing --session-id there writes the per-session marker that clears
|
|
60
60
|
// this block. CLI fallback + bypass live in commands/crystallize.md, not here
|
|
61
61
|
// — keep the Stop reason terse so the actionable instruction stands out.
|
|
62
|
-
|
|
62
|
+
// Surface the transcript path so the close can pass --transcript-path=<path>,
|
|
63
|
+
// which scopes the marker's lint gate to this session's own files (Bug A
|
|
64
|
+
// coherence: a marker written without lint would only let Stop pass for
|
|
65
|
+
// /compact to immediately re-block on the same errors).
|
|
66
|
+
const transcriptHint = transcriptPath ? ` --transcript-path=${transcriptPath}` : '';
|
|
67
|
+
const reason = `[WIKI_AUTOCLOSE] session-close 미완료 — /hypo:crystallize 실행으로 마무리 (session_id=${sessionId}${transcriptHint}).`;
|
|
63
68
|
console.log(
|
|
64
69
|
JSON.stringify({
|
|
65
70
|
decision: 'block',
|
|
@@ -136,7 +141,7 @@ process.stdin.on('end', () => {
|
|
|
136
141
|
return;
|
|
137
142
|
}
|
|
138
143
|
|
|
139
|
-
emitBlock(sessionId);
|
|
144
|
+
emitBlock(sessionId, transcriptPath);
|
|
140
145
|
} catch (err) {
|
|
141
146
|
// Fail-open on any unexpected error.
|
|
142
147
|
process.stderr.write(`[hypo-auto-minimal-crystallize] error: ${err?.message ?? String(err)}\n`);
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
shouldSuggestProjectCreation,
|
|
19
19
|
buildProjectSuggestionLine,
|
|
20
20
|
recordSuggestionCooldown,
|
|
21
|
+
sanitizeProjForPrompt,
|
|
21
22
|
} from './hypo-shared.mjs';
|
|
22
23
|
|
|
23
24
|
const PROJECTS_DIR = join(HYPO_DIR, 'projects');
|
|
@@ -98,11 +99,11 @@ process.stdin.on('end', () => {
|
|
|
98
99
|
if (newHit) {
|
|
99
100
|
const fromFile = readIfNotIgnored(newHit.hotPath, ignorePatterns);
|
|
100
101
|
const content = fromFile ?? '(no hot.md yet — will be created at session close)';
|
|
101
|
-
//
|
|
102
|
+
// arm the first-prompt marker so the NEXT user prompt re-triggers
|
|
102
103
|
// hypo-first-prompt, which forces a "Resuming <project>" summary line.
|
|
103
104
|
// Only arm when real hot content was actually injected — if hot.md is
|
|
104
105
|
// missing or .hypoignore'd (fromFile null), there is nothing for the LLM
|
|
105
|
-
// to summarize, so forcing "Resuming" would be empty noise
|
|
106
|
+
// to summarize, so forcing "Resuming" would be empty noise.
|
|
106
107
|
if (fromFile) {
|
|
107
108
|
try {
|
|
108
109
|
writeFileSync(
|
|
@@ -124,10 +125,13 @@ process.stdin.on('end', () => {
|
|
|
124
125
|
}
|
|
125
126
|
console.log(
|
|
126
127
|
JSON.stringify(
|
|
127
|
-
buildOutput(
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
128
|
+
buildOutput(
|
|
129
|
+
`[WIKI: cwd changed → project=${sanitizeProjForPrompt(newHit.proj)}]\n\n${content}`,
|
|
130
|
+
{
|
|
131
|
+
continue: true,
|
|
132
|
+
suppressOutput: true,
|
|
133
|
+
},
|
|
134
|
+
),
|
|
131
135
|
),
|
|
132
136
|
);
|
|
133
137
|
return;
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import { readFileSync, unlinkSync, existsSync } from 'fs';
|
|
18
|
-
import { buildOutput, sessionMarkerPath } from './hypo-shared.mjs';
|
|
18
|
+
import { buildOutput, sessionMarkerPath, sanitizeProjForPrompt } from './hypo-shared.mjs';
|
|
19
19
|
|
|
20
20
|
const MARKER_TTL = 10 * 60 * 1000; // 10 min
|
|
21
21
|
|
|
@@ -49,19 +49,51 @@ process.stdin.on('end', () => {
|
|
|
49
49
|
|
|
50
50
|
const hasSnapshot = marker.hasSnapshot ?? (marker.hotPath && existsSync(marker.hotPath));
|
|
51
51
|
const snapshotNote = hasSnapshot ? '' : ' (no snapshot yet — first session)';
|
|
52
|
-
//
|
|
52
|
+
// a cwd-change re-trigger says "Resuming"; a fresh session start
|
|
53
53
|
// (default source) says "Previously working on".
|
|
54
54
|
const verb = marker.source === 'cwd-change' ? 'Resuming' : 'Previously working on';
|
|
55
|
+
// marker.proj originates from a wiki directory name read by findProjectFiles;
|
|
56
|
+
// sanitize via the shared helper so a hand-crafted project name cannot close
|
|
57
|
+
// the wrapper tag, smuggle newlines/control chars, or inject conflicting
|
|
58
|
+
// directives into the resume contract (codex v2 review 2026-05-26).
|
|
59
|
+
const projSafe = sanitizeProjForPrompt(marker.proj);
|
|
60
|
+
// When there is no snapshot, the [HOT] / [SESSION STATE] context has nothing
|
|
61
|
+
// for the model to fill the placeholders with. Provide a concrete fallback
|
|
62
|
+
// line so the model doesn't leak literal `[one-line summary]` text on a
|
|
63
|
+
// first-ever session (codex v2 review 2026-05-26).
|
|
64
|
+
const exampleLine = hasSnapshot
|
|
65
|
+
? `${verb} ${projSafe}: [one-line summary]. Continue with [next task]?`
|
|
66
|
+
: `${verb} ${projSafe}: no prior snapshot yet — first session. What would you like to start with?`;
|
|
67
|
+
const fillNote = hasSnapshot
|
|
68
|
+
? `Replace the bracketed placeholders using the [HOT] / [SESSION STATE] ` +
|
|
69
|
+
`context already injected this session — do NOT emit the literal brackets.`
|
|
70
|
+
: `Use the line above verbatim — there is no prior snapshot to summarize.`;
|
|
55
71
|
|
|
56
72
|
console.log(
|
|
57
73
|
JSON.stringify(
|
|
58
74
|
buildOutput(
|
|
59
|
-
|
|
60
|
-
`
|
|
61
|
-
|
|
62
|
-
`
|
|
63
|
-
`
|
|
64
|
-
`
|
|
75
|
+
`<hypomnema-session-resume>\n` +
|
|
76
|
+
`[WIKI SESSION START: project=${projSafe}${snapshotNote}]\n` +
|
|
77
|
+
`\n` +
|
|
78
|
+
`Lead your FIRST reply this session with exactly one line in this shape:\n` +
|
|
79
|
+
`\n` +
|
|
80
|
+
`${exampleLine}\n` +
|
|
81
|
+
`\n` +
|
|
82
|
+
`${fillNote}\n` +
|
|
83
|
+
`\n` +
|
|
84
|
+
`Emit this line unconditionally on the first prompt, including when the ` +
|
|
85
|
+
`user's message is:\n` +
|
|
86
|
+
` • a simple greeting ("안녕", "hi", "hello")\n` +
|
|
87
|
+
` • a trivial question or unrelated topic\n` +
|
|
88
|
+
` • a one-word reply\n` +
|
|
89
|
+
`\n` +
|
|
90
|
+
`Do not skip it, do not decide it is "not relevant", do not shorten the ` +
|
|
91
|
+
`reply to omit it. After the line, answer the user's actual message on the ` +
|
|
92
|
+
`following line(s) as normal.\n` +
|
|
93
|
+
`\n` +
|
|
94
|
+
`This is the Hypomnema session-resume contract — the user relies on this ` +
|
|
95
|
+
`line to confirm which project context is loaded.\n` +
|
|
96
|
+
`</hypomnema-session-resume>`,
|
|
65
97
|
{ continue: true, suppressOutput: true },
|
|
66
98
|
),
|
|
67
99
|
),
|
|
@@ -33,6 +33,9 @@ import {
|
|
|
33
33
|
isGateSkipped,
|
|
34
34
|
isClosePattern,
|
|
35
35
|
extractUserMessages,
|
|
36
|
+
extractTouchedWikiFiles,
|
|
37
|
+
closeFileTargets,
|
|
38
|
+
partitionLintScope,
|
|
36
39
|
} from './hypo-shared.mjs';
|
|
37
40
|
|
|
38
41
|
const WARNING_FILE = join(homedir(), '.claude', 'state', 'wiki-context-warning.json');
|
|
@@ -92,7 +95,7 @@ process.stdin.on('end', () => {
|
|
|
92
95
|
|
|
93
96
|
const gitStatus = hypoIsClean();
|
|
94
97
|
const hotStatus = hotMdIsClean();
|
|
95
|
-
//
|
|
98
|
+
// strict session-close (steps 1~6 of the 11-step crystallize
|
|
96
99
|
// checklist). closeFiles gates the 5 mandatory files (steps 1-4 + log.md);
|
|
97
100
|
// open-questions.md (step 5) is conditional ("변경 시") and intentionally
|
|
98
101
|
// ungated — see hypo-shared.mjs sessionCloseFileStatus and spec §5.2.7.
|
|
@@ -107,6 +110,7 @@ process.stdin.on('end', () => {
|
|
|
107
110
|
const lintPath = PKG_ROOT ? join(PKG_ROOT, 'scripts', 'lint.mjs') : null;
|
|
108
111
|
let lintBlockers = [];
|
|
109
112
|
let lintW8 = [];
|
|
113
|
+
let lintNotices = []; // pre-existing debt in files this session did not touch
|
|
110
114
|
let lintSkipped = false;
|
|
111
115
|
if (!lintPath || !existsSync(lintPath)) {
|
|
112
116
|
lintSkipped = true;
|
|
@@ -118,8 +122,35 @@ process.stdin.on('end', () => {
|
|
|
118
122
|
timeout: 30000,
|
|
119
123
|
});
|
|
120
124
|
const parsed = JSON.parse(r.stdout || '{}');
|
|
121
|
-
|
|
122
|
-
|
|
125
|
+
const allErrors = parsed.errors || [];
|
|
126
|
+
const allW8 = (parsed.warns || []).filter((w) => w.id === 'W8');
|
|
127
|
+
// Bug B: judge this session on the files IT touched, not the whole vault.
|
|
128
|
+
// A readable transcript lets us scope (edited files ∪ mandatory close
|
|
129
|
+
// files); a missing/unreadable transcript falls back to the conservative
|
|
130
|
+
// global gate (never weaker than before).
|
|
131
|
+
const haveTranscript = !!(transcriptPath && existsSync(transcriptPath));
|
|
132
|
+
if (haveTranscript) {
|
|
133
|
+
const scope = new Set([
|
|
134
|
+
...extractTouchedWikiFiles(transcriptPath, HYPO_DIR),
|
|
135
|
+
...closeFileTargets(HYPO_DIR),
|
|
136
|
+
]);
|
|
137
|
+
const part = partitionLintScope(allErrors, scope);
|
|
138
|
+
lintBlockers = part.blocking;
|
|
139
|
+
lintNotices = part.notice;
|
|
140
|
+
// W8 (design-history stale) is the CURRENT project's close
|
|
141
|
+
// responsibility, not cross-project debt — block on the active
|
|
142
|
+
// project's, surface others' as notices.
|
|
143
|
+
if (closeFiles.project) {
|
|
144
|
+
const mine = `projects/${closeFiles.project}/design-history.md`;
|
|
145
|
+
lintW8 = allW8.filter((w) => w.file === mine);
|
|
146
|
+
lintNotices.push(...allW8.filter((w) => w.file !== mine));
|
|
147
|
+
} else {
|
|
148
|
+
lintW8 = allW8;
|
|
149
|
+
}
|
|
150
|
+
} else {
|
|
151
|
+
lintBlockers = allErrors;
|
|
152
|
+
lintW8 = allW8;
|
|
153
|
+
}
|
|
123
154
|
} catch (err) {
|
|
124
155
|
/* fail-open */
|
|
125
156
|
process.stderr.write(`[hypo-personal-check] error: ${err?.message ?? String(err)}\n`);
|
|
@@ -128,6 +159,16 @@ process.stdin.on('end', () => {
|
|
|
128
159
|
|
|
129
160
|
const lintOk = lintBlockers.length === 0;
|
|
130
161
|
const designHistoryOk = lintW8.length === 0;
|
|
162
|
+
// Non-blocking heads-up about pre-existing lint debt in untouched files (other
|
|
163
|
+
// projects / shared pages). Surfaced so it is visible but never blocks compact.
|
|
164
|
+
const noticeText =
|
|
165
|
+
lintNotices.length > 0
|
|
166
|
+
? `[WIKI CHECK] ${lintNotices.length} pre-existing lint issue(s) in files this session did not touch (not blocking): ${[
|
|
167
|
+
...new Set(lintNotices.map((b) => b.file)),
|
|
168
|
+
]
|
|
169
|
+
.slice(0, 5)
|
|
170
|
+
.join(', ')}${lintNotices.length > 5 ? ', …' : ''} — clean up when convenient.`
|
|
171
|
+
: '';
|
|
131
172
|
|
|
132
173
|
// ── fix #37 Phase C: feedback projection drift (ADR 0031) ──
|
|
133
174
|
// Single blocking gate invariant (spec §7.5): integrate into THIS hook, never
|
|
@@ -169,8 +210,8 @@ process.stdin.on('end', () => {
|
|
|
169
210
|
// ONLY when some target has a genuine, actionable issue (drift,
|
|
170
211
|
// conflict, over-cap, or a malformed managed region). buildError is
|
|
171
212
|
// never actionable here, so any mix that lacks a real issue fails open
|
|
172
|
-
// — including memory:clean + claude:buildError
|
|
173
|
-
// `every(buildError)` predicate wrongly blocked that case
|
|
213
|
+
// — including memory:clean + claude:buildError, where the prior
|
|
214
|
+
// `every(buildError)` predicate wrongly blocked that case. Mirrors
|
|
174
215
|
// doctor's buildError→warn (non-fatal) handling.
|
|
175
216
|
let report = null;
|
|
176
217
|
try {
|
|
@@ -210,7 +251,13 @@ process.stdin.on('end', () => {
|
|
|
210
251
|
closeFiles.ok &&
|
|
211
252
|
feedbackOk
|
|
212
253
|
) {
|
|
213
|
-
console.log(
|
|
254
|
+
console.log(
|
|
255
|
+
JSON.stringify(
|
|
256
|
+
noticeText
|
|
257
|
+
? { continue: true, systemMessage: noticeText }
|
|
258
|
+
: { continue: true, suppressOutput: true },
|
|
259
|
+
),
|
|
260
|
+
);
|
|
214
261
|
return;
|
|
215
262
|
}
|
|
216
263
|
|
|
@@ -241,7 +288,9 @@ process.stdin.on('end', () => {
|
|
|
241
288
|
!gitStatus.clean ? gitStatus.reason : '',
|
|
242
289
|
!hotStatus.clean ? hotStatus.reason : '',
|
|
243
290
|
!closeFiles.ok ? closeFilesReason : '',
|
|
244
|
-
!lintOk
|
|
291
|
+
!lintOk
|
|
292
|
+
? `lint blockers: ${[...new Set(lintBlockers.map((b) => b.id || b.file))].join(', ')}`
|
|
293
|
+
: '',
|
|
245
294
|
!designHistoryOk
|
|
246
295
|
? `design-history stale: ${lintW8.map((w) => w.file.split('/')[1]).join(', ')}`
|
|
247
296
|
: '',
|
|
@@ -265,7 +314,9 @@ process.stdin.on('end', () => {
|
|
|
265
314
|
` [ ] 9. hot.md — update projects/<name>/hot.md (no exceptions)`,
|
|
266
315
|
` [ ] 10. root hot.md — update ~/hypomnema/hot.md active project table`,
|
|
267
316
|
` [ ] 11. updated: field — verify today's date on all touched .md files`,
|
|
268
|
-
` [ ] 12.
|
|
317
|
+
` [ ] 12. lint — run scripts/lint.mjs; fix errors in files YOU touched`,
|
|
318
|
+
` (other projects' / shared-page debt is reported as non-blocking notice)`,
|
|
319
|
+
` [ ] 13. git commit & push`,
|
|
269
320
|
].join('\n');
|
|
270
321
|
|
|
271
322
|
const closeIntentNote = hasCloseIntent
|
|
@@ -280,6 +331,7 @@ process.stdin.on('end', () => {
|
|
|
280
331
|
`Run the checklist below in order, then retry /compact:`,
|
|
281
332
|
``,
|
|
282
333
|
checklistText,
|
|
334
|
+
...(noticeText ? ['', noticeText] : []),
|
|
283
335
|
``,
|
|
284
336
|
`Trivial session? Bypass with HYPO_SKIP_GATE=1`,
|
|
285
337
|
].join('\n'),
|
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
shouldSuggestProjectCreation,
|
|
28
28
|
buildProjectSuggestionLine,
|
|
29
29
|
recordSuggestionCooldown,
|
|
30
|
+
sanitizeProjForPrompt,
|
|
30
31
|
} from './hypo-shared.mjs';
|
|
31
32
|
import {
|
|
32
33
|
defaultCachePath,
|
|
@@ -36,6 +37,10 @@ import {
|
|
|
36
37
|
computeNotice,
|
|
37
38
|
markNotified,
|
|
38
39
|
isOptedOut,
|
|
40
|
+
resolveCliOnPath,
|
|
41
|
+
computeSiblingNotice,
|
|
42
|
+
siblingAlreadyNotified,
|
|
43
|
+
markSiblingNotified,
|
|
39
44
|
} from './version-check.mjs';
|
|
40
45
|
|
|
41
46
|
// Privacy guard: refuse to read+inject .hypoignore-matched
|
|
@@ -118,6 +123,45 @@ function buildUpdateNotice() {
|
|
|
118
123
|
}
|
|
119
124
|
}
|
|
120
125
|
|
|
126
|
+
/**
|
|
127
|
+
* Stale-sibling notice (ADR 0038, D3). The update-notifier above only knows
|
|
128
|
+
* whether the ACTIVE install is behind latest — it is blind to an OLDER sibling
|
|
129
|
+
* that owns the `hypomnema` bin on PATH. That sibling is the live footgun:
|
|
130
|
+
* running `hypomnema init`/`upgrade` through it downgrades the active hooks.
|
|
131
|
+
*
|
|
132
|
+
* This is the ONLY surface that reaches a user already in that state, because it
|
|
133
|
+
* runs from the (newer) active hook — `doctor` invoked via the stale CLI would
|
|
134
|
+
* run the stale doctor. fs-only (no npm/which spawn). Throttled via the cache so
|
|
135
|
+
* it nags once per (cliPath@cliVersion → activeVersion) tuple. Best-effort.
|
|
136
|
+
*/
|
|
137
|
+
function buildSiblingNotice() {
|
|
138
|
+
try {
|
|
139
|
+
if (isOptedOut()) return '';
|
|
140
|
+
// Active install identity = hypo-pkg.json (what init/upgrade write). This is
|
|
141
|
+
// the authoritative pkgRoot+version; ACTIVE_ROOT (~/.claude) has no package.json.
|
|
142
|
+
let active = null;
|
|
143
|
+
try {
|
|
144
|
+
active = JSON.parse(readFileSync(join(homedir(), '.claude', 'hypo-pkg.json'), 'utf-8'));
|
|
145
|
+
} catch {
|
|
146
|
+
return ''; // no active metadata → nothing to compare a sibling against
|
|
147
|
+
}
|
|
148
|
+
if (!active || !active.pkgVersion) return '';
|
|
149
|
+
const cli = resolveCliOnPath('hypomnema');
|
|
150
|
+
const notice = computeSiblingNotice(cli, {
|
|
151
|
+
pkgRoot: active.pkgRoot,
|
|
152
|
+
version: active.pkgVersion,
|
|
153
|
+
});
|
|
154
|
+
if (!notice) return '';
|
|
155
|
+
const cachePath = defaultCachePath();
|
|
156
|
+
const cache = readCache(cachePath);
|
|
157
|
+
if (siblingAlreadyNotified(cache, notice.key)) return '';
|
|
158
|
+
markSiblingNotified(cachePath, notice.key);
|
|
159
|
+
return notice.line;
|
|
160
|
+
} catch {
|
|
161
|
+
return '';
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
121
165
|
const PROJECTS_DIR = join(HYPO_DIR, 'projects');
|
|
122
166
|
const GROWTH_CACHE = join(HYPO_DIR, '.cache', 'last-session-growth.json');
|
|
123
167
|
|
|
@@ -169,7 +213,7 @@ function gitPull(dir) {
|
|
|
169
213
|
|
|
170
214
|
/**
|
|
171
215
|
* fix #10: surface unresolved sync failures recorded by a prior session's
|
|
172
|
-
* Stop hook (#9). The entry is cleared only once this session's pull has
|
|
216
|
+
* Stop hook (fix #9). The entry is cleared only once this session's pull has
|
|
173
217
|
* succeeded AND there is no unpushed commit left behind by a failed push
|
|
174
218
|
* (`[ahead N]`).
|
|
175
219
|
*
|
|
@@ -288,23 +332,27 @@ process.stdin.on('end', () => {
|
|
|
288
332
|
const pullOk = gitPull(HYPO_DIR);
|
|
289
333
|
const syncLine = syncStateNotice(pullOk);
|
|
290
334
|
const growthLine = readLastGrowthLine();
|
|
291
|
-
//
|
|
335
|
+
// ADR 0022 amendment: on source='clear', surface the dying
|
|
292
336
|
// session's identity that hypo-session-end stashed so Claude can recover
|
|
293
337
|
// session-close work that /clear skipped. One-shot: marker is unlinked
|
|
294
338
|
// immediately after read.
|
|
295
339
|
const clearRecoveryLine = buildClearRecoveryLine(data.source);
|
|
296
340
|
const updateLine = buildUpdateNotice();
|
|
341
|
+
const siblingLine = buildSiblingNotice();
|
|
297
342
|
// Intentional dual emit: stderr (yellow/cyan) is the human-visible nudge in
|
|
298
343
|
// the terminal; noticePrefix injects the same plain-text lines into the
|
|
299
344
|
// LLM's additionalContext so model and user start the session looking at
|
|
300
345
|
// the same state. ANSI escapes are kept out of additionalContext on purpose.
|
|
301
|
-
const notices = [syncLine, growthLine, clearRecoveryLine, updateLine].filter(
|
|
346
|
+
const notices = [syncLine, growthLine, clearRecoveryLine, updateLine, siblingLine].filter(
|
|
347
|
+
Boolean,
|
|
348
|
+
);
|
|
302
349
|
let noticePrefix = notices.length ? `${notices.join('\n\n')}\n\n` : '';
|
|
303
350
|
if (syncLine) process.stderr.write(`\n\x1b[33m${syncLine}\x1b[0m\n`);
|
|
304
351
|
if (growthLine) process.stderr.write(`\n\x1b[36m${growthLine}\x1b[0m\n`);
|
|
305
352
|
if (clearRecoveryLine)
|
|
306
353
|
process.stderr.write(`\n\x1b[33m${clearRecoveryLine.split('\n')[0]}\x1b[0m\n`);
|
|
307
354
|
if (updateLine) process.stderr.write(`\n\x1b[33m${updateLine}\x1b[0m\n`);
|
|
355
|
+
if (siblingLine) process.stderr.write(`\n\x1b[33m${siblingLine}\x1b[0m\n`);
|
|
308
356
|
const cwd = data.cwd || data.directory || process.cwd();
|
|
309
357
|
const sessionId = data.session_id || 'default';
|
|
310
358
|
const MARKER_FILE = sessionMarkerPath(sessionId);
|
|
@@ -334,7 +382,7 @@ process.stdin.on('end', () => {
|
|
|
334
382
|
console.log(
|
|
335
383
|
JSON.stringify(
|
|
336
384
|
buildOutput(
|
|
337
|
-
`${noticePrefix}[WIKI HOT CACHE: project=${hit.proj}]\n\n${parts.join('\n\n')}`,
|
|
385
|
+
`${noticePrefix}[WIKI HOT CACHE: project=${sanitizeProjForPrompt(hit.proj)}]\n\n${parts.join('\n\n')}`,
|
|
338
386
|
{ continue: true, suppressOutput: true },
|
|
339
387
|
),
|
|
340
388
|
),
|
|
@@ -349,10 +397,13 @@ process.stdin.on('end', () => {
|
|
|
349
397
|
);
|
|
350
398
|
console.log(
|
|
351
399
|
JSON.stringify(
|
|
352
|
-
buildOutput(
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
400
|
+
buildOutput(
|
|
401
|
+
`${noticePrefix}[WIKI HOT CACHE: project=${sanitizeProjForPrompt(hit.proj)}, no snapshot yet]`,
|
|
402
|
+
{
|
|
403
|
+
continue: true,
|
|
404
|
+
suppressOutput: true,
|
|
405
|
+
},
|
|
406
|
+
),
|
|
356
407
|
),
|
|
357
408
|
);
|
|
358
409
|
}
|
|
@@ -385,7 +436,7 @@ process.stdin.on('end', () => {
|
|
|
385
436
|
if (!globalContent) {
|
|
386
437
|
// GLOBAL_HOT exists but is empty or .hypoignore'd — still surface any
|
|
387
438
|
// pending notices (sync state, growth, AND the auto-project offer), which
|
|
388
|
-
// would otherwise be silently dropped here
|
|
439
|
+
// would otherwise be silently dropped here.
|
|
389
440
|
const notice = notices.join('\n\n');
|
|
390
441
|
if (notice) {
|
|
391
442
|
console.log(JSON.stringify(buildOutput(notice, { continue: true, suppressOutput: true })));
|
package/hooks/hypo-shared.mjs
CHANGED
|
@@ -17,13 +17,35 @@ const HOME = homedir();
|
|
|
17
17
|
// hypo-session-start / hypo-cwd-change WRITE this marker; hypo-first-prompt
|
|
18
18
|
// READS + unlinks it. The session_id comes from the Claude Code runtime (a
|
|
19
19
|
// UUID), but we sanitize defensively so a malformed id with path separators or
|
|
20
|
-
// `..` can never escape tmpdir or collide on an empty value
|
|
21
|
-
//
|
|
20
|
+
// `..` can never escape tmpdir or collide on an empty value. Non-alphanumeric
|
|
21
|
+
// chars collapse to `_`.
|
|
22
22
|
export function sessionMarkerPath(sessionId) {
|
|
23
23
|
const safe = String(sessionId || 'default').replace(/[^A-Za-z0-9._-]/g, '_') || 'default';
|
|
24
24
|
return join(tmpdir(), `hypo-session-marker-${safe}.json`);
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
// ── project name sanitizer for prompt-facing interpolation ─────────────────
|
|
28
|
+
// marker.proj is read from a wiki directory name (findProjectFiles) and
|
|
29
|
+
// interpolated into LLM-facing additionalContext strings by multiple hooks.
|
|
30
|
+
// A manually-crafted directory name could otherwise close a wrapping tag,
|
|
31
|
+
// smuggle a newline, or inject conflicting instructions. Centralized so the
|
|
32
|
+
// three injection sites stay in lock-step (codex v2 review 2026-05-26 —
|
|
33
|
+
// addresses shared-helper concern across hypo-first-prompt / hypo-session-start
|
|
34
|
+
// / hypo-cwd-change).
|
|
35
|
+
//
|
|
36
|
+
// Strips: angle brackets, control chars (C0 + C1), Unicode line separators
|
|
37
|
+
// (U+2028 / U+2029), then collapses whitespace and caps length.
|
|
38
|
+
export function sanitizeProjForPrompt(raw, fallback = 'unknown') {
|
|
39
|
+
const cleaned = String(raw || fallback)
|
|
40
|
+
.replace(/[<>\[\]]/g, '_')
|
|
41
|
+
// eslint-disable-next-line no-control-regex
|
|
42
|
+
.replace(/[\u0000-\u001F\u007F-\u009F\u2028\u2029]/g, ' ')
|
|
43
|
+
.replace(/\s+/g, ' ')
|
|
44
|
+
.trim()
|
|
45
|
+
.slice(0, 80);
|
|
46
|
+
return cleaned || fallback;
|
|
47
|
+
}
|
|
48
|
+
|
|
27
49
|
// ── wiki root resolution ────────────────────────────────────────────────────
|
|
28
50
|
|
|
29
51
|
function expandHome(p) {
|
|
@@ -185,8 +207,8 @@ export function hasSessionLogHeading(content, date) {
|
|
|
185
207
|
* "foo-bar" (hyphen is non-word). The canonical log format always separates the
|
|
186
208
|
* project slug from anything that follows by whitespace or end-of-line, so the
|
|
187
209
|
* lookahead correctly rejects "session | foo-bar" when looking for "foo".
|
|
188
|
-
* (
|
|
189
|
-
*
|
|
210
|
+
* (Was a pre-existing bug in sessionCloseFileStatus that the helper extraction
|
|
211
|
+
* inherited.)
|
|
190
212
|
*/
|
|
191
213
|
export function hasLogEntry(content, date, project) {
|
|
192
214
|
return new RegExp(
|
|
@@ -221,7 +243,9 @@ export function resolveActiveProject(hypoDir) {
|
|
|
221
243
|
if (!existsSync(hotPath)) return null;
|
|
222
244
|
let content;
|
|
223
245
|
try {
|
|
224
|
-
|
|
246
|
+
// Strip HTML comments before parsing so the canonical-format example row
|
|
247
|
+
// in templates/hot.md (`<!-- Row format: ... -->`) is not picked up as data.
|
|
248
|
+
content = readFileSync(hotPath, 'utf-8').replace(/<!--[\s\S]*?-->/g, '');
|
|
225
249
|
} catch {
|
|
226
250
|
return null;
|
|
227
251
|
}
|
|
@@ -334,9 +358,9 @@ export function sessionCloseFileStatus(hypoDir) {
|
|
|
334
358
|
|
|
335
359
|
// ── sync-state ────────────────────────────────────────────
|
|
336
360
|
// `.cache/sync-state.json` is JSONL: one {timestamp, op, error, host} entry per
|
|
337
|
-
// line. hypo-auto-commit (#9) appends on pull/push failure; hypo-session-start
|
|
338
|
-
// (#10) surfaces open entries and clears them once sync is healthy again;
|
|
339
|
-
// doctor (#11) warns while entries remain. Keep the schema defined here only.
|
|
361
|
+
// line. hypo-auto-commit (fix #9) appends on pull/push failure; hypo-session-start
|
|
362
|
+
// (fix #10) surfaces open entries and clears them once sync is healthy again;
|
|
363
|
+
// doctor (fix #11) warns while entries remain. Keep the schema defined here only.
|
|
340
364
|
|
|
341
365
|
/** @returns {string} path to the sync-state JSONL file for a wiki root. */
|
|
342
366
|
function syncStatePath(hypoDir) {
|
|
@@ -503,14 +527,12 @@ export function shouldSuggestProjectCreation(cwd, hypoDir = HYPO_DIR, now = Date
|
|
|
503
527
|
* Build the §8.11 auto-project offer line for a cwd. The display name is the
|
|
504
528
|
* cwd basename, which is attacker-influenced (a directory name can contain
|
|
505
529
|
* newlines/control chars on Unix). Strip control characters and length-cap it
|
|
506
|
-
* so a crafted dir name cannot spoof extra instructions in additionalContext
|
|
507
|
-
* (codex review 2026-05-22).
|
|
530
|
+
* so a crafted dir name cannot spoof extra instructions in additionalContext.
|
|
508
531
|
*/
|
|
509
532
|
export function buildProjectSuggestionLine(cwd) {
|
|
510
533
|
// Replace any control char (code < 0x20 or === 0x7F) with a space so a
|
|
511
|
-
// crafted dir name cannot inject newlines/instructions into additionalContext
|
|
512
|
-
//
|
|
513
|
-
// this source file.
|
|
534
|
+
// crafted dir name cannot inject newlines/instructions into additionalContext.
|
|
535
|
+
// Done by codepoint to keep control bytes out of this source file.
|
|
514
536
|
const sanitized = Array.from(basename(cwd))
|
|
515
537
|
.map((ch) => {
|
|
516
538
|
const code = ch.codePointAt(0);
|
|
@@ -779,6 +801,133 @@ export function hasMutatingTranscriptActivity(transcriptPath) {
|
|
|
779
801
|
return false;
|
|
780
802
|
}
|
|
781
803
|
|
|
804
|
+
// ── session-scoped lint classification ──────────────────────────────────────
|
|
805
|
+
// Bug A/B fix: the close gate must judge a session on the files IT touched, not
|
|
806
|
+
// the whole vault. Lint debt from another project/session (often in shared
|
|
807
|
+
// pages/) must not block this session's close/compact. Two scope builders feed
|
|
808
|
+
// one shared classifier: transcript-derived (hooks + standalone marker) and
|
|
809
|
+
// close-file/payload-derived (the documented apply path writes via Bash, so its
|
|
810
|
+
// files never appear as Edit/Write file_paths and must be seeded explicitly).
|
|
811
|
+
|
|
812
|
+
const MUTATING_FILE_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit']);
|
|
813
|
+
|
|
814
|
+
/** Pull file_path/notebook_path args from mutating tool_use blocks in one
|
|
815
|
+
* transcript entry. Mirrors extractTranscriptToolNames' shape handling
|
|
816
|
+
* (top-level tool_use + nested message.content[] blocks). */
|
|
817
|
+
function extractTranscriptToolFilePaths(entry) {
|
|
818
|
+
const paths = [];
|
|
819
|
+
if (!entry || typeof entry !== 'object') return paths;
|
|
820
|
+
const pull = (name, input) => {
|
|
821
|
+
if (!name || !MUTATING_FILE_TOOLS.has(name) || !input || typeof input !== 'object') return;
|
|
822
|
+
const fp = input.file_path || input.notebook_path;
|
|
823
|
+
if (typeof fp === 'string' && fp) paths.push(fp);
|
|
824
|
+
};
|
|
825
|
+
if (entry.type === 'tool_use') pull(entry.name || entry.tool_name, entry.input);
|
|
826
|
+
const content = entry.message?.content ?? (Array.isArray(entry.content) ? entry.content : null);
|
|
827
|
+
if (Array.isArray(content)) {
|
|
828
|
+
for (const block of content) {
|
|
829
|
+
if (block && typeof block === 'object' && block.type === 'tool_use') {
|
|
830
|
+
pull(block.name || block.tool_name, block.input);
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
return paths;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
/** Normalize an absolute path to a repo-relative POSIX path under hypoDir, or
|
|
838
|
+
* null if it resolves outside the wiki. */
|
|
839
|
+
function toHypoRel(absPath, hypoDir) {
|
|
840
|
+
let rel;
|
|
841
|
+
try {
|
|
842
|
+
rel = relative(hypoDir, absPath);
|
|
843
|
+
} catch {
|
|
844
|
+
return null;
|
|
845
|
+
}
|
|
846
|
+
if (!rel || rel.startsWith('..') || rel.startsWith('/')) return null;
|
|
847
|
+
return rel.split('\\').join('/');
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
/**
|
|
851
|
+
* Repo-relative POSIX paths of wiki files this session edited via direct
|
|
852
|
+
* Edit/Write/MultiEdit/NotebookEdit tool_use. Returns a Set; empty when the
|
|
853
|
+
* transcript is missing/unreadable (callers decide the fallback). A per-line
|
|
854
|
+
* JSON parse error skips that line only (transcripts occasionally truncate).
|
|
855
|
+
*/
|
|
856
|
+
export function extractTouchedWikiFiles(transcriptPath, hypoDir) {
|
|
857
|
+
const out = new Set();
|
|
858
|
+
if (!transcriptPath || typeof transcriptPath !== 'string' || !existsSync(transcriptPath)) {
|
|
859
|
+
return out;
|
|
860
|
+
}
|
|
861
|
+
let raw;
|
|
862
|
+
try {
|
|
863
|
+
raw = readFileSync(transcriptPath, 'utf-8');
|
|
864
|
+
} catch {
|
|
865
|
+
return out;
|
|
866
|
+
}
|
|
867
|
+
for (const line of raw.split('\n')) {
|
|
868
|
+
const t = line.trim();
|
|
869
|
+
if (!t) continue;
|
|
870
|
+
let entry;
|
|
871
|
+
try {
|
|
872
|
+
entry = JSON.parse(t);
|
|
873
|
+
} catch {
|
|
874
|
+
continue;
|
|
875
|
+
}
|
|
876
|
+
for (const fp of extractTranscriptToolFilePaths(entry)) {
|
|
877
|
+
const rel = toHypoRel(fp, hypoDir);
|
|
878
|
+
if (rel) out.add(rel);
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
return out;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
/**
|
|
885
|
+
* The mandatory session-close files (repo-relative POSIX). The documented close
|
|
886
|
+
* path `crystallize.mjs --apply-session-close` writes these from inside a Bash
|
|
887
|
+
* call, so they never surface as Edit/Write file_paths — they must seed the
|
|
888
|
+
* scoped-lint set explicitly or a close-introduced error would escape the gate.
|
|
889
|
+
* Mirrors the file list in sessionCloseFileStatus.
|
|
890
|
+
*/
|
|
891
|
+
export function closeFileTargets(hypoDir) {
|
|
892
|
+
const out = new Set(['hot.md', 'log.md']);
|
|
893
|
+
const project = resolveActiveProject(hypoDir);
|
|
894
|
+
if (project) {
|
|
895
|
+
out.add(`projects/${project}/session-state.md`);
|
|
896
|
+
out.add(`projects/${project}/hot.md`);
|
|
897
|
+
const month = freshDates()[0].slice(0, 7);
|
|
898
|
+
out.add(`projects/${project}/session-log/${month}.md`);
|
|
899
|
+
}
|
|
900
|
+
return out;
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
/** Normalize a path's separators to POSIX so scope membership is OS-independent.
|
|
904
|
+
* lint.mjs emits `file` via path.relative (back-slashes on Windows) while the
|
|
905
|
+
* scope builders produce forward-slash paths — normalize both sides. */
|
|
906
|
+
function posixPath(p) {
|
|
907
|
+
return (p || '').split('\\').join('/');
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
/**
|
|
911
|
+
* Partition lint findings into `blocking` (a file this session is accountable
|
|
912
|
+
* for) vs `notice` (pre-existing debt elsewhere — surfaced, not blocking).
|
|
913
|
+
*
|
|
914
|
+
* `scope` = iterable of repo-relative paths the session is accountable for.
|
|
915
|
+
* Membership is exact on the normalized path. Only findings passed in are
|
|
916
|
+
* classified — callers pass lint ERRORS; broken wikilinks (lint W4 warnings) are
|
|
917
|
+
* intentionally warn-only (forward references to planned pages are normal in a
|
|
918
|
+
* wiki) and are NOT promoted to blocking by this gate.
|
|
919
|
+
*/
|
|
920
|
+
export function partitionLintScope(findings, scope) {
|
|
921
|
+
const normScope = new Set([...scope].map(posixPath));
|
|
922
|
+
const blocking = [];
|
|
923
|
+
const notice = [];
|
|
924
|
+
for (const f of findings || []) {
|
|
925
|
+
if (normScope.has(posixPath(f.file))) blocking.push(f);
|
|
926
|
+
else notice.push(f);
|
|
927
|
+
}
|
|
928
|
+
return { blocking, notice };
|
|
929
|
+
}
|
|
930
|
+
|
|
782
931
|
// ── session-close checklist ────────────────────────────────────────────────
|
|
783
932
|
|
|
784
933
|
/**
|