hypomnema 1.1.0 → 1.2.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 +4 -2
- package/README.md +4 -2
- package/commands/audit.md +2 -2
- package/commands/crystallize.md +113 -23
- package/commands/feedback.md +40 -26
- package/commands/ingest.md +31 -9
- package/commands/upgrade.md +2 -2
- package/docs/ARCHITECTURE.md +1 -1
- package/docs/CONTRIBUTING.md +1 -1
- package/hooks/hooks.json +30 -1
- package/hooks/hypo-auto-commit.mjs +10 -4
- package/hooks/hypo-auto-minimal-crystallize.mjs +145 -0
- package/hooks/hypo-auto-stage.mjs +4 -3
- package/hooks/hypo-compact-guard.mjs +33 -24
- package/hooks/hypo-cwd-change.mjs +107 -24
- package/hooks/hypo-file-watch.mjs +23 -10
- package/hooks/hypo-first-prompt.mjs +37 -23
- package/hooks/hypo-hot-rebuild.mjs +22 -10
- package/hooks/hypo-lookup.mjs +171 -65
- package/hooks/hypo-personal-check.mjs +207 -112
- package/hooks/hypo-pre-commit.mjs +46 -0
- package/hooks/hypo-session-end.mjs +58 -0
- package/hooks/hypo-session-record.mjs +11 -5
- package/hooks/hypo-session-start.mjs +298 -52
- package/hooks/hypo-shared.mjs +793 -37
- package/hooks/hypo-web-fetch-ingest.mjs +121 -0
- package/hooks/version-check-fetch.mjs +74 -0
- package/hooks/version-check.mjs +184 -0
- package/package.json +17 -3
- package/scripts/crystallize.mjs +623 -18
- package/scripts/doctor.mjs +730 -47
- package/scripts/feedback-sync.mjs +974 -0
- package/scripts/feedback.mjs +253 -44
- package/scripts/graph.mjs +35 -22
- package/scripts/ingest.mjs +89 -16
- package/scripts/init.mjs +398 -113
- package/scripts/lib/design-history-stale.mjs +83 -0
- package/scripts/lib/extensions.mjs +749 -0
- package/scripts/lib/frontmatter.mjs +5 -1
- package/scripts/lib/hypo-ignore.mjs +12 -10
- package/scripts/lib/pkg-json.mjs +23 -5
- package/scripts/lib/project-create.mjs +225 -0
- package/scripts/lib/schema-vocab.mjs +96 -0
- package/scripts/lint.mjs +238 -31
- package/scripts/query.mjs +26 -10
- package/scripts/resume.mjs +11 -5
- package/scripts/session-audit.mjs +37 -27
- package/scripts/smoke-pack.mjs +224 -0
- package/scripts/stats.mjs +24 -10
- package/scripts/uninstall.mjs +363 -49
- package/scripts/upgrade.mjs +706 -202
- package/scripts/verify.mjs +24 -14
- package/scripts/weekly-report.mjs +59 -25
- package/skills/crystallize/SKILL.md +20 -7
- package/skills/ingest/SKILL.md +25 -5
- package/templates/.hypoignore +16 -2
- package/templates/Home.md +2 -0
- package/templates/SCHEMA.md +61 -6
- package/templates/extensions/agents/.gitkeep +0 -0
- package/templates/extensions/commands/.gitkeep +0 -0
- package/templates/extensions/hooks/.gitkeep +0 -0
- package/templates/extensions/skills/.gitkeep +0 -0
- package/templates/gitignore +5 -0
- package/templates/hot.md +2 -0
- package/templates/hypo-config.md +1 -1
- package/templates/hypo-guide.md +42 -2
- package/templates/hypo-help.md +1 -1
- package/templates/pages/observability/_index.md +77 -0
- package/templates/projects/_template/index.md +2 -2
- package/templates/projects/_template/prd.md +1 -1
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* hypo-auto-minimal-crystallize.mjs — Stop hook (fix #27 PR-C, ADR 0022 Layer 3)
|
|
4
|
+
*
|
|
5
|
+
* Last hook in the Stop chain: a final-line defense that blocks `Stop` when
|
|
6
|
+
* the current session performed mutation work but never produced a verified
|
|
7
|
+
* session-close. Forces Claude to run minimal session-close before the
|
|
8
|
+
* conversation context evaporates.
|
|
9
|
+
*
|
|
10
|
+
* Decision flow (see ADR 0022 amendment 2026-05-19 Q1+Q2 + 2nd amendment Q-close-gate):
|
|
11
|
+
*
|
|
12
|
+
* 1. stop_hook_active === true → continue (loop guard; PoC 2026-05-14)
|
|
13
|
+
* 2. wiki absent → continue (fail-open)
|
|
14
|
+
* 3. transcript has zero Edit/Write/MultiEdit/NotebookEdit tool_use
|
|
15
|
+
* → continue (substantial-session gate)
|
|
16
|
+
* 4. no recent user close-intent → continue (close-intent gate, see below)
|
|
17
|
+
* 5. readSessionClosedMarker(session_id) valid
|
|
18
|
+
* → continue (close already verified)
|
|
19
|
+
* 6. otherwise → decision:block
|
|
20
|
+
*
|
|
21
|
+
* Close-intent gate (added after PR-C dogfooding revealed every-turn block —
|
|
22
|
+
* codex 2-worker debate 2026-05-19, both REQUEST_CHANGES). Stop fires after
|
|
23
|
+
* EVERY assistant turn, not at session end; blocking on "mutation + no marker"
|
|
24
|
+
* alone nags the user on every turn of a long mutating session. ADR 0022's
|
|
25
|
+
* real intent is "block when the session is ENDING and close is incomplete".
|
|
26
|
+
* We approximate the end signal by reusing isClosePattern() over recent
|
|
27
|
+
* user-message text (the same low-false-positive signal PreCompact uses):
|
|
28
|
+
* only block when the user actually signalled wrap-up ("이만 마치자",
|
|
29
|
+
* "오늘 여기까지", "wrap up", "session close"). last_assistant_message is NOT
|
|
30
|
+
* used — "커밋했습니다"/"작업 완료" type phrases produce false positives.
|
|
31
|
+
*
|
|
32
|
+
* The hook NEVER writes the marker — even in the loop-guard branch. Writer
|
|
33
|
+
* authority lives in `scripts/crystallize.mjs` (`--apply-session-close
|
|
34
|
+
* --session-id=X` or standalone `--mark-session-closed --session-id=X`),
|
|
35
|
+
* which gates the write on sessionCloseFileStatus.ok. Doing the write here
|
|
36
|
+
* would let a Claude that ignored the block (did other work, hit Stop again)
|
|
37
|
+
* silently get a marker without performing the close — exactly the failure
|
|
38
|
+
* mode the per-session marker was introduced to prevent.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import { existsSync } from 'fs';
|
|
42
|
+
import { join } from 'path';
|
|
43
|
+
import {
|
|
44
|
+
HYPO_DIR,
|
|
45
|
+
PKG_ROOT,
|
|
46
|
+
hasMutatingTranscriptActivity,
|
|
47
|
+
readSessionClosedMarker,
|
|
48
|
+
extractUserMessages,
|
|
49
|
+
isClosePattern,
|
|
50
|
+
isGateSkipped,
|
|
51
|
+
} from './hypo-shared.mjs';
|
|
52
|
+
|
|
53
|
+
function emitContinue() {
|
|
54
|
+
console.log(JSON.stringify({ continue: true, suppressOutput: true }));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function emitBlock(sessionId) {
|
|
58
|
+
// One-line, skill-first. /hypo:crystallize is the documented session-close
|
|
59
|
+
// alias; passing --session-id there writes the per-session marker that clears
|
|
60
|
+
// this block. CLI fallback + bypass live in commands/crystallize.md, not here
|
|
61
|
+
// — keep the Stop reason terse so the actionable instruction stands out.
|
|
62
|
+
const reason = `[WIKI_AUTOCLOSE] session-close 미완료 — /hypo:crystallize 실행으로 마무리 (session_id=${sessionId}).`;
|
|
63
|
+
console.log(
|
|
64
|
+
JSON.stringify({
|
|
65
|
+
decision: 'block',
|
|
66
|
+
reason,
|
|
67
|
+
stopReason: 'session-close incomplete (fix #27 PR-C / ADR 0022 Layer 3)',
|
|
68
|
+
}),
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
let raw = '';
|
|
73
|
+
process.stdin.setEncoding('utf-8');
|
|
74
|
+
process.stdin.on('data', (chunk) => (raw += chunk));
|
|
75
|
+
process.stdin.on('end', () => {
|
|
76
|
+
try {
|
|
77
|
+
let payload = {};
|
|
78
|
+
try {
|
|
79
|
+
payload = JSON.parse(raw) || {};
|
|
80
|
+
} catch (err) {
|
|
81
|
+
// Any malformed payload is fail-open — we never want a parse error to
|
|
82
|
+
// strand Claude in a blocked Stop with no recovery context.
|
|
83
|
+
process.stderr.write(
|
|
84
|
+
`[hypo-auto-minimal-crystallize] error: ${err?.message ?? String(err)}\n`,
|
|
85
|
+
);
|
|
86
|
+
emitContinue();
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// 1. loop guard. NEVER write marker here (see file header).
|
|
91
|
+
if (payload.stop_hook_active === true) {
|
|
92
|
+
emitContinue();
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (isGateSkipped()) {
|
|
97
|
+
emitContinue();
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// 2. wiki absent → can't enforce anything meaningful.
|
|
102
|
+
if (!existsSync(HYPO_DIR)) {
|
|
103
|
+
emitContinue();
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const sessionId = payload.session_id || payload.sessionId || null;
|
|
108
|
+
const transcriptPath = payload.transcript_path || payload.transcriptPath || null;
|
|
109
|
+
|
|
110
|
+
// 3. substantial-session gate. Read-only / Q&A sessions skip the block.
|
|
111
|
+
if (!hasMutatingTranscriptActivity(transcriptPath)) {
|
|
112
|
+
emitContinue();
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// 4. close-intent gate. Stop fires every turn; only nudge when the user
|
|
117
|
+
// actually signalled session wrap-up. Without this, a long mutating
|
|
118
|
+
// session is blocked on every turn (PR-C dogfooding regression).
|
|
119
|
+
const userText = transcriptPath ? extractUserMessages(transcriptPath) : '';
|
|
120
|
+
if (!isClosePattern(userText)) {
|
|
121
|
+
emitContinue();
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// 5. close already verified for this session_id.
|
|
126
|
+
if (sessionId && readSessionClosedMarker(HYPO_DIR, sessionId)) {
|
|
127
|
+
emitContinue();
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// 6. block — but only when we have a session_id to address the recovery
|
|
132
|
+
// instruction to. Without one, the marker contract can't be honored, so
|
|
133
|
+
// failing-open is safer than blocking forever.
|
|
134
|
+
if (!sessionId) {
|
|
135
|
+
emitContinue();
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
emitBlock(sessionId);
|
|
140
|
+
} catch (err) {
|
|
141
|
+
// Fail-open on any unexpected error.
|
|
142
|
+
process.stderr.write(`[hypo-auto-minimal-crystallize] error: ${err?.message ?? String(err)}\n`);
|
|
143
|
+
emitContinue();
|
|
144
|
+
}
|
|
145
|
+
});
|
|
@@ -10,13 +10,14 @@ import { HYPO_DIR, loadHypoIgnore, isIgnored } from './hypo-shared.mjs';
|
|
|
10
10
|
|
|
11
11
|
let input = {};
|
|
12
12
|
try {
|
|
13
|
-
const raw = await new Promise(r => {
|
|
13
|
+
const raw = await new Promise((r) => {
|
|
14
14
|
let d = '';
|
|
15
|
-
process.stdin.on('data', c => d += c);
|
|
15
|
+
process.stdin.on('data', (c) => (d += c));
|
|
16
16
|
process.stdin.on('end', () => r(d));
|
|
17
17
|
});
|
|
18
18
|
input = JSON.parse(raw);
|
|
19
|
-
} catch {
|
|
19
|
+
} catch (err) {
|
|
20
|
+
process.stderr.write(`[hypo-auto-stage] error: ${err?.message ?? String(err)}\n`);
|
|
20
21
|
console.log(JSON.stringify({ continue: true, suppressOutput: true }));
|
|
21
22
|
process.exit(0);
|
|
22
23
|
}
|
|
@@ -2,12 +2,13 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* hypo-compact-guard.mjs — UserPromptSubmit hook
|
|
4
4
|
*
|
|
5
|
-
* Scope: detects "/compact" typed in chat only.
|
|
5
|
+
* Scope: detects "/compact" or "/clear" typed in chat only (ADR 0022 Layer 2, fix #25).
|
|
6
6
|
* The CLI built-in /compact does NOT fire UserPromptSubmit — use personal-wiki-check.mjs
|
|
7
|
-
* (PreCompact hook) as the hard gate for that path.
|
|
7
|
+
* (PreCompact hook) as the hard gate for that path. /clear has no PreCompact event, so
|
|
8
|
+
* this hook is the only chat-side gate that can prompt session-close before context wipe.
|
|
8
9
|
*
|
|
9
10
|
* Behavior: if session close is incomplete → instruct Claude to run session close
|
|
10
|
-
* immediately before /compact.
|
|
11
|
+
* immediately before /compact or /clear.
|
|
11
12
|
*/
|
|
12
13
|
|
|
13
14
|
import {
|
|
@@ -15,26 +16,31 @@ import {
|
|
|
15
16
|
hypoIsClean,
|
|
16
17
|
hotMdIsClean,
|
|
17
18
|
readChecklist,
|
|
18
|
-
|
|
19
|
+
isClearCommand,
|
|
20
|
+
isCompactOrClearCommand,
|
|
19
21
|
isGateSkipped,
|
|
20
22
|
} from './hypo-shared.mjs';
|
|
21
23
|
|
|
22
24
|
let input = '';
|
|
23
25
|
process.stdin.setEncoding('utf-8');
|
|
24
|
-
process.stdin.on('data', chunk => {
|
|
26
|
+
process.stdin.on('data', (chunk) => {
|
|
27
|
+
input += chunk;
|
|
28
|
+
});
|
|
25
29
|
process.stdin.on('end', () => {
|
|
26
30
|
try {
|
|
27
|
-
const data
|
|
31
|
+
const data = JSON.parse(input);
|
|
28
32
|
const prompt = (data.prompt || '').trim();
|
|
29
33
|
|
|
30
|
-
if (!
|
|
34
|
+
if (!isCompactOrClearCommand(prompt) || isGateSkipped()) {
|
|
31
35
|
console.log(JSON.stringify({ continue: true, suppressOutput: true }));
|
|
32
36
|
return;
|
|
33
37
|
}
|
|
34
38
|
|
|
39
|
+
const detected = isClearCommand(prompt) ? '/clear' : '/compact';
|
|
40
|
+
|
|
35
41
|
const hasSession = lastSubstantialOpIsSession();
|
|
36
|
-
const gitStatus
|
|
37
|
-
const hotStatus
|
|
42
|
+
const gitStatus = hypoIsClean();
|
|
43
|
+
const hotStatus = hotMdIsClean();
|
|
38
44
|
|
|
39
45
|
if (hasSession && gitStatus.clean && hotStatus.clean) {
|
|
40
46
|
console.log(JSON.stringify({ continue: true, suppressOutput: true }));
|
|
@@ -42,30 +48,33 @@ process.stdin.on('end', () => {
|
|
|
42
48
|
}
|
|
43
49
|
|
|
44
50
|
const reasons = [
|
|
45
|
-
!hasSession
|
|
51
|
+
!hasSession ? 'session log entry missing' : '',
|
|
46
52
|
!gitStatus.clean ? gitStatus.reason : '',
|
|
47
53
|
!hotStatus.clean ? hotStatus.reason : '',
|
|
48
54
|
].filter(Boolean);
|
|
49
55
|
|
|
50
|
-
const today
|
|
56
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
51
57
|
const checklist = readChecklist(today);
|
|
52
|
-
const body
|
|
58
|
+
const body = checklist
|
|
53
59
|
? `Checklist:\n${checklist}`
|
|
54
60
|
: 'See hypo-guide.md for the session-close checklist.';
|
|
55
61
|
|
|
56
|
-
console.log(
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
62
|
+
console.log(
|
|
63
|
+
JSON.stringify({
|
|
64
|
+
continue: true,
|
|
65
|
+
additionalContext: [
|
|
66
|
+
`[WIKI_AUTOCLOSE] ${detected} detected — session close incomplete (${reasons.join(', ')}).`,
|
|
67
|
+
`Do NOT wait for user input. Run wiki session close NOW, then retry ${detected}.`,
|
|
68
|
+
``,
|
|
69
|
+
body,
|
|
70
|
+
``,
|
|
71
|
+
`To bypass: set HYPO_SKIP_GATE=1`,
|
|
72
|
+
].join('\n'),
|
|
73
|
+
}),
|
|
74
|
+
);
|
|
75
|
+
} catch (err) {
|
|
68
76
|
// Fail-open: any parse/runtime error must not block the user's prompt.
|
|
77
|
+
process.stderr.write(`[hypo-compact-guard] error: ${err?.message ?? String(err)}\n`);
|
|
69
78
|
console.log(JSON.stringify({ continue: true, suppressOutput: true }));
|
|
70
79
|
}
|
|
71
80
|
});
|
|
@@ -6,21 +6,41 @@
|
|
|
6
6
|
* project hot.md. Skips if still within the same project subtree.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { readFileSync, existsSync, readdirSync, statSync } from 'fs';
|
|
9
|
+
import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from 'fs';
|
|
10
10
|
import { homedir } from 'os';
|
|
11
11
|
import { join } from 'path';
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
HYPO_DIR,
|
|
14
|
+
buildOutput,
|
|
15
|
+
loadHypoIgnore,
|
|
16
|
+
isIgnored,
|
|
17
|
+
sessionMarkerPath,
|
|
18
|
+
shouldSuggestProjectCreation,
|
|
19
|
+
buildProjectSuggestionLine,
|
|
20
|
+
recordSuggestionCooldown,
|
|
21
|
+
} from './hypo-shared.mjs';
|
|
13
22
|
|
|
14
23
|
const PROJECTS_DIR = join(HYPO_DIR, 'projects');
|
|
15
|
-
const GLOBAL_HOT
|
|
16
|
-
const MAX_CHARS
|
|
24
|
+
const GLOBAL_HOT = join(HYPO_DIR, 'hot.md');
|
|
25
|
+
const MAX_CHARS = 3000;
|
|
26
|
+
|
|
27
|
+
// Privacy guard: a .hypoignore-matched hot.md must not be
|
|
28
|
+
// re-emitted into additionalContext on cwd change.
|
|
29
|
+
function readIfNotIgnored(path, patterns) {
|
|
30
|
+
if (!path) return null;
|
|
31
|
+
if (patterns.length > 0 && isIgnored(path, HYPO_DIR, patterns)) return null;
|
|
32
|
+
return readFileSync(path, 'utf-8').slice(0, MAX_CHARS);
|
|
33
|
+
}
|
|
17
34
|
|
|
18
35
|
function parseFrontmatterField(content, key) {
|
|
19
36
|
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
20
37
|
if (!match) return null;
|
|
21
|
-
const line = match[1].split('\n').find(l => l.startsWith(`${key}:`));
|
|
38
|
+
const line = match[1].split('\n').find((l) => l.startsWith(`${key}:`));
|
|
22
39
|
if (!line) return null;
|
|
23
|
-
return line
|
|
40
|
+
return line
|
|
41
|
+
.slice(key.length + 1)
|
|
42
|
+
.trim()
|
|
43
|
+
.replace(/^['"]|['"]$/g, '');
|
|
24
44
|
}
|
|
25
45
|
|
|
26
46
|
function findProjectHot(cwd) {
|
|
@@ -30,7 +50,7 @@ function findProjectHot(cwd) {
|
|
|
30
50
|
if (!statSync(projDir).isDirectory()) continue;
|
|
31
51
|
const indexPath = join(projDir, 'index.md');
|
|
32
52
|
if (!existsSync(indexPath)) continue;
|
|
33
|
-
const content
|
|
53
|
+
const content = readFileSync(indexPath, 'utf-8');
|
|
34
54
|
const workingDir = parseFrontmatterField(content, 'working_dir');
|
|
35
55
|
if (!workingDir) continue;
|
|
36
56
|
const resolved = workingDir.startsWith('~/')
|
|
@@ -38,7 +58,13 @@ function findProjectHot(cwd) {
|
|
|
38
58
|
: workingDir;
|
|
39
59
|
if (cwd === resolved || cwd.startsWith(resolved + '/')) {
|
|
40
60
|
const hotPath = join(projDir, 'hot.md');
|
|
41
|
-
|
|
61
|
+
const statePath = join(projDir, 'session-state.md');
|
|
62
|
+
return {
|
|
63
|
+
proj,
|
|
64
|
+
hotPath: existsSync(hotPath) ? hotPath : null,
|
|
65
|
+
statePath: existsSync(statePath) ? statePath : null,
|
|
66
|
+
resolved,
|
|
67
|
+
};
|
|
42
68
|
}
|
|
43
69
|
}
|
|
44
70
|
return null;
|
|
@@ -46,14 +72,17 @@ function findProjectHot(cwd) {
|
|
|
46
72
|
|
|
47
73
|
let raw = '';
|
|
48
74
|
process.stdin.setEncoding('utf-8');
|
|
49
|
-
process.stdin.on('data', chunk => raw += chunk);
|
|
75
|
+
process.stdin.on('data', (chunk) => (raw += chunk));
|
|
50
76
|
process.stdin.on('end', () => {
|
|
51
77
|
try {
|
|
52
78
|
let data = {};
|
|
53
|
-
try {
|
|
79
|
+
try {
|
|
80
|
+
data = JSON.parse(raw);
|
|
81
|
+
} catch {}
|
|
54
82
|
|
|
55
83
|
const newCwd = data.new_cwd || data.new_directory || data.cwd || process.cwd();
|
|
56
84
|
const oldCwd = data.old_cwd || data.old_directory || data.previous_cwd || '';
|
|
85
|
+
const sessionId = data.session_id || 'default';
|
|
57
86
|
|
|
58
87
|
// Skip re-injection if still in the same project
|
|
59
88
|
const oldHit = oldCwd ? findProjectHot(oldCwd) : null;
|
|
@@ -64,28 +93,82 @@ process.stdin.on('end', () => {
|
|
|
64
93
|
return;
|
|
65
94
|
}
|
|
66
95
|
|
|
96
|
+
const ignorePatterns = loadHypoIgnore(HYPO_DIR);
|
|
97
|
+
|
|
67
98
|
if (newHit) {
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
)
|
|
99
|
+
const fromFile = readIfNotIgnored(newHit.hotPath, ignorePatterns);
|
|
100
|
+
const content = fromFile ?? '(no hot.md yet — will be created at session close)';
|
|
101
|
+
// fix #13: arm the first-prompt marker so the NEXT user prompt re-triggers
|
|
102
|
+
// hypo-first-prompt, which forces a "Resuming <project>" summary line.
|
|
103
|
+
// Only arm when real hot content was actually injected — if hot.md is
|
|
104
|
+
// missing or .hypoignore'd (fromFile null), there is nothing for the LLM
|
|
105
|
+
// to summarize, so forcing "Resuming" would be empty noise (codex review).
|
|
106
|
+
if (fromFile) {
|
|
107
|
+
try {
|
|
108
|
+
writeFileSync(
|
|
109
|
+
sessionMarkerPath(sessionId),
|
|
110
|
+
JSON.stringify({
|
|
111
|
+
proj: newHit.proj,
|
|
112
|
+
hotPath: newHit.hotPath,
|
|
113
|
+
statePath: newHit.statePath,
|
|
114
|
+
hasSnapshot: true,
|
|
115
|
+
source: 'cwd-change',
|
|
116
|
+
ts: Date.now(),
|
|
117
|
+
}),
|
|
118
|
+
);
|
|
119
|
+
} catch (err) {
|
|
120
|
+
process.stderr.write(
|
|
121
|
+
`[hypo-cwd-change] marker write failed: ${err?.message ?? String(err)}\n`,
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
console.log(
|
|
126
|
+
JSON.stringify(
|
|
127
|
+
buildOutput(`[WIKI: cwd changed → project=${newHit.proj}]\n\n${content}`, {
|
|
128
|
+
continue: true,
|
|
129
|
+
suppressOutput: true,
|
|
130
|
+
}),
|
|
131
|
+
),
|
|
132
|
+
);
|
|
74
133
|
return;
|
|
75
134
|
}
|
|
76
135
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
136
|
+
// MISS: cwd matches no project. fix #23 / ADR 0023 — offer to create one
|
|
137
|
+
// when the trigger conditions hold. Same nudge-only model as session-start.
|
|
138
|
+
let suggestPrefix = '';
|
|
139
|
+
if (shouldSuggestProjectCreation(newCwd, HYPO_DIR)) {
|
|
140
|
+
const suggestLine = buildProjectSuggestionLine(newCwd);
|
|
141
|
+
suggestPrefix = `${suggestLine}\n\n`;
|
|
142
|
+
recordSuggestionCooldown(HYPO_DIR, newCwd);
|
|
143
|
+
process.stderr.write(`\n\x1b[33m${suggestLine}\x1b[0m\n`);
|
|
80
144
|
}
|
|
81
145
|
|
|
82
|
-
const globalContent =
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
));
|
|
146
|
+
const globalContent = existsSync(GLOBAL_HOT)
|
|
147
|
+
? readIfNotIgnored(GLOBAL_HOT, ignorePatterns)
|
|
148
|
+
: null;
|
|
86
149
|
|
|
150
|
+
if (!globalContent) {
|
|
151
|
+
if (suggestPrefix) {
|
|
152
|
+
console.log(
|
|
153
|
+
JSON.stringify(
|
|
154
|
+
buildOutput(suggestPrefix.trimEnd(), { continue: true, suppressOutput: true }),
|
|
155
|
+
),
|
|
156
|
+
);
|
|
157
|
+
} else {
|
|
158
|
+
console.log(JSON.stringify({ continue: true, suppressOutput: true }));
|
|
159
|
+
}
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
console.log(
|
|
163
|
+
JSON.stringify(
|
|
164
|
+
buildOutput(
|
|
165
|
+
`${suggestPrefix}[WIKI: cwd changed → no project match, injecting global hot]\n\n${globalContent}`,
|
|
166
|
+
{ continue: true, suppressOutput: true },
|
|
167
|
+
),
|
|
168
|
+
),
|
|
169
|
+
);
|
|
87
170
|
} catch (err) {
|
|
88
|
-
process.stderr.write(`[
|
|
171
|
+
process.stderr.write(`[hypo-cwd-change] error: ${err?.message ?? String(err)}\n`);
|
|
89
172
|
console.log(JSON.stringify({ continue: true, suppressOutput: true }));
|
|
90
173
|
}
|
|
91
174
|
});
|
|
@@ -8,17 +8,19 @@
|
|
|
8
8
|
|
|
9
9
|
import { readFileSync, existsSync } from 'fs';
|
|
10
10
|
import { join } from 'path';
|
|
11
|
-
import { HYPO_DIR } from './hypo-shared.mjs';
|
|
11
|
+
import { HYPO_DIR, loadHypoIgnore, isIgnored } from './hypo-shared.mjs';
|
|
12
12
|
|
|
13
13
|
const MAX_CHARS = 2000;
|
|
14
14
|
|
|
15
15
|
let raw = '';
|
|
16
16
|
process.stdin.setEncoding('utf-8');
|
|
17
|
-
process.stdin.on('data', chunk => raw += chunk);
|
|
17
|
+
process.stdin.on('data', (chunk) => (raw += chunk));
|
|
18
18
|
process.stdin.on('end', () => {
|
|
19
19
|
try {
|
|
20
20
|
let data = {};
|
|
21
|
-
try {
|
|
21
|
+
try {
|
|
22
|
+
data = JSON.parse(raw);
|
|
23
|
+
} catch {}
|
|
22
24
|
|
|
23
25
|
const filePath = data.file_path || data.path || '';
|
|
24
26
|
|
|
@@ -27,6 +29,15 @@ process.stdin.on('end', () => {
|
|
|
27
29
|
return;
|
|
28
30
|
}
|
|
29
31
|
|
|
32
|
+
// Privacy guard: refuse to inject
|
|
33
|
+
// .hypoignore-matched paths. Without this, `.env*` or other secrets under
|
|
34
|
+
// HYPO_DIR are re-emitted as additionalContext to the Claude provider.
|
|
35
|
+
const patterns = loadHypoIgnore(HYPO_DIR);
|
|
36
|
+
if (patterns.length > 0 && isIgnored(filePath, HYPO_DIR, patterns)) {
|
|
37
|
+
console.log(JSON.stringify({ continue: true, suppressOutput: true }));
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
30
41
|
if (!existsSync(filePath)) {
|
|
31
42
|
console.log(JSON.stringify({ continue: true, suppressOutput: true }));
|
|
32
43
|
return;
|
|
@@ -35,13 +46,15 @@ process.stdin.on('end', () => {
|
|
|
35
46
|
const content = readFileSync(filePath, 'utf-8').slice(0, MAX_CHARS);
|
|
36
47
|
const relPath = filePath.replace(HYPO_DIR + '/', '');
|
|
37
48
|
|
|
38
|
-
console.log(
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
49
|
+
console.log(
|
|
50
|
+
JSON.stringify({
|
|
51
|
+
continue: true,
|
|
52
|
+
suppressOutput: true,
|
|
53
|
+
additionalContext: `[WIKI FILE UPDATED: ${relPath}]\n\n${content}`,
|
|
54
|
+
}),
|
|
55
|
+
);
|
|
56
|
+
} catch (err) {
|
|
57
|
+
process.stderr.write(`[hypo-file-watch] error: ${err?.message ?? String(err)}\n`);
|
|
45
58
|
console.log(JSON.stringify({ continue: true, suppressOutput: true }));
|
|
46
59
|
}
|
|
47
60
|
});
|
|
@@ -2,31 +2,33 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* hypo-first-prompt.mjs — UserPromptSubmit hook
|
|
4
4
|
*
|
|
5
|
-
* Consumes the marker written by
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* Consumes the marker written by hypo-session-start.mjs (source omitted /
|
|
6
|
+
* 'session-start') or hypo-cwd-change.mjs (source 'cwd-change', fix #13).
|
|
7
|
+
* On the FIRST user prompt after the marker is written, FORCES a one-line
|
|
8
|
+
* resume summary into the reply (fix #3 — the old "answer only if related"
|
|
9
|
+
* conditional is removed; the line is injected unconditionally).
|
|
8
10
|
*
|
|
9
|
-
* hot.md content is NOT re-injected here —
|
|
10
|
-
*
|
|
11
|
+
* hot.md / session-state.md content is NOT re-injected here — the upstream
|
|
12
|
+
* hook already placed it in additionalContext. This hook only forces the LLM
|
|
13
|
+
* to lead with the summary line drawn from that context.
|
|
11
14
|
* Marker expires after 10 minutes.
|
|
12
15
|
*/
|
|
13
16
|
|
|
14
17
|
import { readFileSync, unlinkSync, existsSync } from 'fs';
|
|
15
|
-
import {
|
|
16
|
-
import { join } from 'path';
|
|
17
|
-
import { buildOutput } from './hypo-shared.mjs';
|
|
18
|
+
import { buildOutput, sessionMarkerPath } from './hypo-shared.mjs';
|
|
18
19
|
|
|
19
20
|
const MARKER_TTL = 10 * 60 * 1000; // 10 min
|
|
20
21
|
|
|
21
22
|
let raw = '';
|
|
22
23
|
process.stdin.setEncoding('utf-8');
|
|
23
|
-
process.stdin.on('data', chunk => raw += chunk);
|
|
24
|
+
process.stdin.on('data', (chunk) => (raw += chunk));
|
|
24
25
|
process.stdin.on('end', () => {
|
|
25
26
|
try {
|
|
26
27
|
let data = {};
|
|
27
|
-
try {
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
try {
|
|
29
|
+
data = JSON.parse(raw);
|
|
30
|
+
} catch {}
|
|
31
|
+
const MARKER_FILE = sessionMarkerPath(data.session_id);
|
|
30
32
|
|
|
31
33
|
if (!existsSync(MARKER_FILE)) {
|
|
32
34
|
console.log(JSON.stringify({ continue: true, suppressOutput: true }));
|
|
@@ -34,26 +36,38 @@ process.stdin.on('end', () => {
|
|
|
34
36
|
}
|
|
35
37
|
|
|
36
38
|
const marker = JSON.parse(readFileSync(MARKER_FILE, 'utf-8'));
|
|
37
|
-
const age
|
|
39
|
+
const age = Date.now() - (marker.ts || 0);
|
|
38
40
|
|
|
39
|
-
try {
|
|
41
|
+
try {
|
|
42
|
+
unlinkSync(MARKER_FILE);
|
|
43
|
+
} catch {}
|
|
40
44
|
|
|
41
45
|
if (age > MARKER_TTL) {
|
|
42
46
|
console.log(JSON.stringify({ continue: true, suppressOutput: true }));
|
|
43
47
|
return;
|
|
44
48
|
}
|
|
45
49
|
|
|
46
|
-
const hasSnapshot
|
|
50
|
+
const hasSnapshot = marker.hasSnapshot ?? (marker.hotPath && existsSync(marker.hotPath));
|
|
47
51
|
const snapshotNote = hasSnapshot ? '' : ' (no snapshot yet — first session)';
|
|
52
|
+
// fix #13: a cwd-change re-trigger says "Resuming"; a fresh session start
|
|
53
|
+
// (default source) says "Previously working on".
|
|
54
|
+
const verb = marker.source === 'cwd-change' ? 'Resuming' : 'Previously working on';
|
|
48
55
|
|
|
49
|
-
console.log(
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
56
|
+
console.log(
|
|
57
|
+
JSON.stringify(
|
|
58
|
+
buildOutput(
|
|
59
|
+
`[WIKI SESSION START: project=${marker.proj}${snapshotNote}]\n` +
|
|
60
|
+
`Before addressing the user's message, lead your FIRST reply with exactly one line:\n` +
|
|
61
|
+
`"${verb} ${marker.proj}: <one-line summary>. Continue with <next task>?"\n` +
|
|
62
|
+
`Draw <one-line summary> and <next task> from the [HOT] / [SESSION STATE] ` +
|
|
63
|
+
`context already injected this session. Inject this line unconditionally — ` +
|
|
64
|
+
`even if the user's first message is unrelated or a simple question — then answer normally.`,
|
|
65
|
+
{ continue: true, suppressOutput: true },
|
|
66
|
+
),
|
|
67
|
+
),
|
|
68
|
+
);
|
|
69
|
+
} catch (err) {
|
|
70
|
+
process.stderr.write(`[hypo-first-prompt] error: ${err?.message ?? String(err)}\n`);
|
|
57
71
|
console.log(JSON.stringify({ continue: true, suppressOutput: true }));
|
|
58
72
|
}
|
|
59
73
|
});
|
|
@@ -14,8 +14,8 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
|
|
|
14
14
|
import { join } from 'path';
|
|
15
15
|
import { HYPO_DIR, computeSessionGrowth, formatGrowthMetrics } from './hypo-shared.mjs';
|
|
16
16
|
|
|
17
|
-
const HOT_PATH
|
|
18
|
-
const GROWTH_CACHE
|
|
17
|
+
const HOT_PATH = join(HYPO_DIR, 'hot.md');
|
|
18
|
+
const GROWTH_CACHE = join(HYPO_DIR, '.cache', 'last-session-growth.json');
|
|
19
19
|
|
|
20
20
|
function parseFrontmatter(content) {
|
|
21
21
|
const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
@@ -56,10 +56,12 @@ function rebuild() {
|
|
|
56
56
|
|
|
57
57
|
const today = new Date().toISOString().slice(0, 10);
|
|
58
58
|
|
|
59
|
-
const tableRows = rows
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
59
|
+
const tableRows = rows
|
|
60
|
+
.map(({ name, slug }) => {
|
|
61
|
+
const date = getProjectDate(slug) || today;
|
|
62
|
+
return `| ${name} | ${date} | [[projects/${slug}/hot]] |`;
|
|
63
|
+
})
|
|
64
|
+
.join('\n');
|
|
63
65
|
|
|
64
66
|
const canonical = `---
|
|
65
67
|
title: Hot Cache — Pointer
|
|
@@ -92,7 +94,7 @@ ${tableRows}
|
|
|
92
94
|
function emitGrowth() {
|
|
93
95
|
if (!existsSync(HYPO_DIR)) return;
|
|
94
96
|
const stats = computeSessionGrowth(HYPO_DIR);
|
|
95
|
-
const line
|
|
97
|
+
const line = formatGrowthMetrics('stop', stats);
|
|
96
98
|
if (line) process.stderr.write(`${line}\n`);
|
|
97
99
|
try {
|
|
98
100
|
mkdirSync(join(HYPO_DIR, '.cache'), { recursive: true });
|
|
@@ -100,7 +102,17 @@ function emitGrowth() {
|
|
|
100
102
|
} catch {}
|
|
101
103
|
}
|
|
102
104
|
|
|
103
|
-
try {
|
|
104
|
-
|
|
105
|
+
try {
|
|
106
|
+
rebuild();
|
|
107
|
+
} catch (err) {
|
|
108
|
+
process.stderr.write(`[hypo-hot-rebuild] error: ${err?.message ?? String(err)}\n`);
|
|
109
|
+
}
|
|
110
|
+
try {
|
|
111
|
+
emitGrowth();
|
|
112
|
+
} catch (err) {
|
|
113
|
+
process.stderr.write(`[hypo-hot-rebuild] error: ${err?.message ?? String(err)}\n`);
|
|
114
|
+
}
|
|
105
115
|
|
|
106
|
-
try {
|
|
116
|
+
try {
|
|
117
|
+
console.log(JSON.stringify({ continue: true, suppressOutput: true }));
|
|
118
|
+
} catch {}
|