hypomnema 1.3.1 → 1.3.3

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.
Files changed (65) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +2 -2
  3. package/README.ko.md +11 -10
  4. package/README.md +11 -10
  5. package/commands/audit.md +1 -1
  6. package/commands/crystallize.md +11 -11
  7. package/commands/doctor.md +1 -1
  8. package/commands/feedback.md +2 -2
  9. package/commands/graph.md +1 -1
  10. package/commands/ingest.md +1 -1
  11. package/commands/init.md +1 -1
  12. package/commands/lint.md +1 -1
  13. package/commands/query.md +1 -1
  14. package/commands/rename.md +79 -0
  15. package/commands/resume.md +2 -2
  16. package/commands/stats.md +1 -1
  17. package/commands/uninstall.md +1 -1
  18. package/commands/upgrade.md +1 -1
  19. package/commands/verify.md +1 -1
  20. package/docs/ARCHITECTURE.md +3 -3
  21. package/docs/CONTRIBUTING.md +2 -2
  22. package/hooks/hypo-auto-commit.mjs +8 -24
  23. package/hooks/hypo-auto-minimal-crystallize.mjs +57 -11
  24. package/hooks/hypo-compact-guard.mjs +6 -3
  25. package/hooks/hypo-cwd-change.mjs +1 -1
  26. package/hooks/hypo-first-prompt.mjs +2 -2
  27. package/hooks/hypo-hot-rebuild.mjs +14 -1
  28. package/hooks/hypo-personal-check.mjs +91 -179
  29. package/hooks/hypo-pre-commit.mjs +1 -1
  30. package/hooks/hypo-session-end.mjs +1 -1
  31. package/hooks/hypo-session-start.mjs +7 -7
  32. package/hooks/hypo-shared.mjs +1082 -89
  33. package/hooks/hypo-web-fetch-ingest.mjs +2 -2
  34. package/hooks/version-check-fetch.mjs +2 -8
  35. package/hooks/version-check.mjs +18 -0
  36. package/package.json +3 -2
  37. package/scripts/check-tracker-ids.mjs +329 -0
  38. package/scripts/crystallize.mjs +322 -110
  39. package/scripts/doctor.mjs +6 -9
  40. package/scripts/feedback-sync.mjs +1 -1
  41. package/scripts/init.mjs +1 -1
  42. package/scripts/install-git-hooks.mjs +75 -40
  43. package/scripts/lib/check-tracker-ids.mjs +140 -0
  44. package/scripts/lib/design-history-stale.mjs +59 -14
  45. package/scripts/lib/extensions.mjs +4 -4
  46. package/scripts/lib/fix-manifest.mjs +2 -2
  47. package/scripts/lib/fix-status-verify.mjs +5 -4
  48. package/scripts/lib/plugin-detect.mjs +15 -6
  49. package/scripts/lib/project-create.mjs +1 -1
  50. package/scripts/lint.mjs +63 -8
  51. package/scripts/rename.mjs +836 -0
  52. package/scripts/resume.mjs +75 -19
  53. package/scripts/uninstall.mjs +1 -1
  54. package/scripts/upgrade.mjs +26 -25
  55. package/skills/crystallize/SKILL.md +12 -9
  56. package/skills/graph/SKILL.md +1 -1
  57. package/skills/ingest/SKILL.md +1 -1
  58. package/skills/lint/SKILL.md +1 -1
  59. package/skills/query/SKILL.md +1 -1
  60. package/skills/verify/SKILL.md +1 -1
  61. package/templates/SCHEMA.md +2 -2
  62. package/templates/hypo-config.md +2 -2
  63. package/templates/hypo-guide.md +22 -1
  64. package/templates/hypo-help.md +1 -0
  65. package/templates/projects/_template/index.md +1 -1
@@ -1,9 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * hypo-auto-minimal-crystallize.mjs — Stop hook (fix #27 PR-C, ADR 0022 Layer 3)
3
+ * hypo-auto-minimal-crystallize.mjs — Stop hook (ADR 0022 Layer 3)
4
4
  *
5
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
6
+ * the current session did substantial work (mutation, or a high-volume
7
+ * read-only investigation — see step 3) but never produced a verified
7
8
  * session-close. Forces Claude to run minimal session-close before the
8
9
  * conversation context evaporates.
9
10
  *
@@ -11,8 +12,10 @@
11
12
  *
12
13
  * 1. stop_hook_active === true → continue (loop guard; PoC 2026-05-14)
13
14
  * 2. wiki absent → continue (fail-open)
14
- * 3. transcript has zero Edit/Write/MultiEdit/NotebookEdit tool_use
15
- * continue (substantial-session gate)
15
+ * 3. not a substantial session → continue (substantial-session gate)
16
+ * substantial = ≥1 mutation tool_use, OR ≥5 read-only investigation
17
+ * calls (Read/Grep/Glob/Bash) — 6a, so read-only review/debug sessions
18
+ * are also nudged to close. Pure Q&A / incidental lookups still skip.
16
19
  * 4. no recent user close-intent → continue (close-intent gate, see below)
17
20
  * 5. readSessionClosedMarker(session_id) valid
18
21
  * → continue (close already verified)
@@ -43,18 +46,19 @@ import { join } from 'path';
43
46
  import {
44
47
  HYPO_DIR,
45
48
  PKG_ROOT,
46
- hasMutatingTranscriptActivity,
49
+ isSubstantialSession,
47
50
  readSessionClosedMarker,
48
51
  extractUserMessages,
49
52
  isClosePattern,
50
53
  isGateSkipped,
54
+ precompactGateStatus,
51
55
  } from './hypo-shared.mjs';
52
56
 
53
57
  function emitContinue() {
54
58
  console.log(JSON.stringify({ continue: true, suppressOutput: true }));
55
59
  }
56
60
 
57
- function emitBlock(sessionId, transcriptPath) {
61
+ function emitBlock(sessionId, transcriptPath, gate = null) {
58
62
  // One-line, skill-first. /hypo:crystallize is the documented session-close
59
63
  // alias; passing --session-id there writes the per-session marker that clears
60
64
  // this block. CLI fallback + bypass live in commands/crystallize.md, not here
@@ -64,12 +68,39 @@ function emitBlock(sessionId, transcriptPath) {
64
68
  // coherence: a marker written without lint would only let Stop pass for
65
69
  // /compact to immediately re-block on the same errors).
66
70
  const transcriptHint = transcriptPath ? ` --transcript-path=${transcriptPath}` : '';
67
- const reason = `[WIKI_AUTOCLOSE] session-close 미완료 — /hypo:crystallize 실행으로 마무리 (session_id=${sessionId}${transcriptHint}).`;
71
+ const markCmd = `crystallize --mark-session-closed --session-id=${sessionId}${transcriptHint}`;
72
+ // The log-only escape hatch for a non-project (wiki/tooling-only)
73
+ // session. Offered ONLY as an explicit alternative when a close blocker is
74
+ // present — never as the default recovery, so a real project session is not
75
+ // taught to bypass the ADR 0043 close invariant (codex design Finding 3).
76
+ const logOnlyCmd = `crystallize --mark-session-closed --log-only --session-id=${sessionId}${transcriptHint}`;
77
+ // ADR 0047: refine the message with the read-only /compact gate result.
78
+ // - gate green → the close is compact-ready and ONLY the marker is missing
79
+ // (the hand-edit close case: files Written + committed directly, bypassing
80
+ // the marker writer). Say so precisely + give the one command, instead of
81
+ // the generic "미완료" that reads as "you never closed".
82
+ // - gate has blockers → surface them so the user fixes the real issue first.
83
+ // - gate null (tooling error/unavailable) → generic message (fail-open).
84
+ let reason;
85
+ if (gate && gate.ok) {
86
+ reason = `[WIKI_AUTOCLOSE] close gate green — only the session-closed marker is missing. Run \`${markCmd}\` to finish (session_id=${sessionId}).`;
87
+ } else if (gate && gate.blockers && gate.blockers.length > 0) {
88
+ const blockers = gate.blockers.map((b) => b.reason).join('; ');
89
+ reason = `[WIKI_AUTOCLOSE] session-close incomplete — resolve: ${blockers}. Then run \`${markCmd}\` (session_id=${sessionId}).`;
90
+ // Only when a project-close blocker is what's holding the session: a
91
+ // non-project session has nothing to close, so offer log-only as the way out
92
+ // (Claude decides whether this session is project-scoped — no auto-attribution).
93
+ if (gate.blockers.some((b) => b.type === 'close')) {
94
+ reason += ` If this was a non-project (wiki/tooling-only) session with no project to close, run \`${logOnlyCmd}\` instead (log-only close, no project attribution).`;
95
+ }
96
+ } else {
97
+ reason = `[WIKI_AUTOCLOSE] session-close 미완료 — /hypo:crystallize 실행으로 마무리 (session_id=${sessionId}${transcriptHint}).`;
98
+ }
68
99
  console.log(
69
100
  JSON.stringify({
70
101
  decision: 'block',
71
102
  reason,
72
- stopReason: 'session-close incomplete (fix #27 PR-C / ADR 0022 Layer 3)',
103
+ stopReason: 'session-close incomplete (ADR 0022 Layer 3)',
73
104
  }),
74
105
  );
75
106
  }
@@ -112,8 +143,10 @@ process.stdin.on('end', () => {
112
143
  const sessionId = payload.session_id || payload.sessionId || null;
113
144
  const transcriptPath = payload.transcript_path || payload.transcriptPath || null;
114
145
 
115
- // 3. substantial-session gate. Read-only / Q&A sessions skip the block.
116
- if (!hasMutatingTranscriptActivity(transcriptPath)) {
146
+ // 3. substantial-session gate. Pure Q&A / incidental-lookup sessions skip
147
+ // the block; mutating sessions AND high-volume read-only investigations
148
+ // (6a) pass through to the close-intent gate.
149
+ if (!isSubstantialSession(transcriptPath)) {
117
150
  emitContinue();
118
151
  return;
119
152
  }
@@ -141,7 +174,20 @@ process.stdin.on('end', () => {
141
174
  return;
142
175
  }
143
176
 
144
- emitBlock(sessionId, transcriptPath);
177
+ // ADR 0047: read-only /compact gate (same precompactGateStatus the real
178
+ // PreCompact hook uses) sharpens the block message — distinguishes "close
179
+ // is compact-ready, only the marker is missing" from "there are real
180
+ // blockers". The hook NEVER writes the marker here (file-header invariant);
181
+ // this is read-only. Any error → null → emitBlock falls back to the generic
182
+ // message (fail-open).
183
+ let gate = null;
184
+ try {
185
+ gate = precompactGateStatus(HYPO_DIR, transcriptPath ? { transcriptPath } : {});
186
+ } catch {
187
+ gate = null;
188
+ }
189
+
190
+ emitBlock(sessionId, transcriptPath, gate);
145
191
  } catch (err) {
146
192
  // Fail-open on any unexpected error.
147
193
  process.stderr.write(`[hypo-auto-minimal-crystallize] error: ${err?.message ?? String(err)}\n`);
@@ -2,7 +2,7 @@
2
2
  /**
3
3
  * hypo-compact-guard.mjs — UserPromptSubmit hook
4
4
  *
5
- * Scope: detects "/compact" or "/clear" typed in chat only (ADR 0022 Layer 2, fix #25).
5
+ * Scope: detects "/compact" or "/clear" typed in chat only (ADR 0022 Layer 2).
6
6
  * The CLI built-in /compact does NOT fire UserPromptSubmit — use personal-wiki-check.mjs
7
7
  * (PreCompact hook) as the hard gate for that path. /clear has no PreCompact event, so
8
8
  * this hook is the only chat-side gate that can prompt session-close before context wipe.
@@ -42,14 +42,17 @@ process.stdin.on('end', () => {
42
42
  const gitStatus = hypoIsClean();
43
43
  const hotStatus = hotMdIsClean();
44
44
 
45
- if (hasSession && gitStatus.clean && hotStatus.clean) {
45
+ // ADR 0056: block on uncommitted (real unsaved work); unpushed commits (ahead)
46
+ // are a soft, auto-synced state and must not block /compact or /clear — mirrors
47
+ // the precompactGateStatus demote so the chat-side gate stays consistent.
48
+ if (hasSession && !gitStatus.uncommitted && hotStatus.clean) {
46
49
  console.log(JSON.stringify({ continue: true, suppressOutput: true }));
47
50
  return;
48
51
  }
49
52
 
50
53
  const reasons = [
51
54
  !hasSession ? 'session log entry missing' : '',
52
- !gitStatus.clean ? gitStatus.reason : '',
55
+ gitStatus.uncommitted ? gitStatus.reason : '',
53
56
  !hotStatus.clean ? hotStatus.reason : '',
54
57
  ].filter(Boolean);
55
58
 
@@ -137,7 +137,7 @@ process.stdin.on('end', () => {
137
137
  return;
138
138
  }
139
139
 
140
- // MISS: cwd matches no project. fix #23 / ADR 0023 — offer to create one
140
+ // MISS: cwd matches no project. ADR 0023 — offer to create one
141
141
  // when the trigger conditions hold. Same nudge-only model as session-start.
142
142
  let suggestPrefix = '';
143
143
  if (shouldSuggestProjectCreation(newCwd, HYPO_DIR)) {
@@ -3,9 +3,9 @@
3
3
  * hypo-first-prompt.mjs — UserPromptSubmit hook
4
4
  *
5
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).
6
+ * 'session-start') or hypo-cwd-change.mjs (source 'cwd-change').
7
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"
8
+ * resume summary into the reply (the old "answer only if related"
9
9
  * conditional is removed; the line is injected unconditionally).
10
10
  *
11
11
  * hot.md / session-state.md content is NOT re-injected here — the upstream
@@ -12,7 +12,12 @@
12
12
 
13
13
  import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
14
14
  import { join } from 'path';
15
- import { HYPO_DIR, computeSessionGrowth, formatGrowthMetrics } from './hypo-shared.mjs';
15
+ import {
16
+ HYPO_DIR,
17
+ computeSessionGrowth,
18
+ formatGrowthMetrics,
19
+ deriveRootLogEntries,
20
+ } from './hypo-shared.mjs';
16
21
 
17
22
  const HOT_PATH = join(HYPO_DIR, 'hot.md');
18
23
  const GROWTH_CACHE = join(HYPO_DIR, '.cache', 'last-session-growth.json');
@@ -107,6 +112,14 @@ try {
107
112
  } catch (err) {
108
113
  process.stderr.write(`[hypo-hot-rebuild] error: ${err?.message ?? String(err)}\n`);
109
114
  }
115
+ // Auto-derive the root log.md session entry from each project's session-log
116
+ // heading (runs AFTER rebuild() so root hot.md is already fresh and isn't itself
117
+ // counted as the project's open gate problem). Best-effort: own try/catch.
118
+ try {
119
+ deriveRootLogEntries(HYPO_DIR);
120
+ } catch (err) {
121
+ process.stderr.write(`[hypo-hot-rebuild] log-derive error: ${err?.message ?? String(err)}\n`);
122
+ }
110
123
  try {
111
124
  emitGrowth();
112
125
  } catch (err) {
@@ -3,8 +3,8 @@
3
3
  * hypo-personal-check.mjs — PreCompact hook
4
4
  *
5
5
  * Hard gate before /compact. Blocks if:
6
- * - the session-close memory files were not updated this session (fix #17:
7
- * session-state.md, project hot.md, root hot.md, session-log, log.md)
6
+ * - the session-close memory files were not updated this session
7
+ * (session-state.md, project hot.md, root hot.md, session-log, log.md)
8
8
  * - wiki git repo has uncommitted/unpushed changes
9
9
  * - hot.md has forbidden structure
10
10
  * - lint blockers exist
@@ -14,7 +14,7 @@
14
14
  * 2. HYPO_SKIP_GATE=1 in a recent *user-role* transcript message
15
15
  * (assistant/tool output is excluded to prevent self-triggering from block reason text)
16
16
  *
17
- * NOTE: capacity bypass (wiki-context-critical.json ≥90%) was REMOVED by fix #26
17
+ * NOTE: capacity bypass (wiki-context-critical.json ≥90%) was REMOVED
18
18
  * (ADR 0022 amendment 2026-05-13). Spec §7.5: even at full context, minimal
19
19
  * session-close is mandatory — auto-bypass on capacity caused silent state loss.
20
20
  */
@@ -26,16 +26,11 @@ import { homedir } from 'os';
26
26
  import {
27
27
  HYPO_DIR,
28
28
  PKG_ROOT,
29
- hypoIsClean,
30
- hotMdIsClean,
31
- sessionCloseFileStatus,
29
+ precompactGateStatus,
32
30
  readChecklist,
33
31
  isGateSkipped,
34
32
  isClosePattern,
35
33
  extractUserMessages,
36
- extractTouchedWikiFiles,
37
- closeFileTargets,
38
- partitionLintScope,
39
34
  } from './hypo-shared.mjs';
40
35
 
41
36
  const WARNING_FILE = join(homedir(), '.claude', 'state', 'wiki-context-warning.json');
@@ -45,14 +40,19 @@ process.stdin.setEncoding('utf-8');
45
40
  process.stdin.on('data', (chunk) => (raw += chunk));
46
41
  process.stdin.on('end', () => {
47
42
  let transcriptPath = null;
43
+ let sessionId = null;
48
44
  try {
49
45
  const input = JSON.parse(raw || '{}');
50
46
  transcriptPath = input.transcript_path ?? null;
47
+ // A log-only marker for this session activates log-only gate
48
+ // semantics (no project attribution) so /compact does not block a closed
49
+ // non-project session on the active/phantom project's files.
50
+ sessionId = input.session_id ?? input.sessionId ?? null;
51
51
  } catch {
52
52
  /* fail-open */
53
53
  }
54
54
 
55
- // ── Capacity bypass (≥90%) REMOVED — fix #26, ADR 0022 amendment 2026-05-13.
55
+ // ── Capacity bypass (≥90%) REMOVED — ADR 0022 amendment 2026-05-13.
56
56
  // Even at full context, minimal session-close is mandatory (spec §7.5).
57
57
  // Bypass paths are now only: HYPO_SKIP_GATE env / HYPO_SKIP_GATE in transcript.
58
58
 
@@ -93,164 +93,82 @@ process.stdin.on('end', () => {
93
93
  // ── Heavy checks ──
94
94
  const today = new Date().toISOString().slice(0, 10);
95
95
 
96
- const gitStatus = hypoIsClean();
97
- const hotStatus = hotMdIsClean();
98
- // strict session-close (steps 1~6 of the 11-step crystallize
99
- // checklist). closeFiles gates the 5 mandatory files (steps 1-4 + log.md);
100
- // open-questions.md (step 5) is conditional ("변경 시") and intentionally
101
- // ungated see hypo-shared.mjs sessionCloseFileStatus and spec §5.2.7.
102
- const closeFiles = sessionCloseFileStatus(HYPO_DIR);
103
- const closeFilesReason = closeFiles.ok
104
- ? ''
105
- : `memory files not updated this session: ${[
106
- ...closeFiles.missing.map((f) => `${f} (missing)`),
107
- ...closeFiles.stale.map((f) => `${f} (stale)`),
108
- ].join(', ')}`;
96
+ // The full PreCompact gate decision, single-sourced (ADR 0046). The SAME
97
+ // function backs `crystallize --check-session-close`, so a green self-check
98
+ // there means this hook will not block. precompactGateStatus runs git-clean +
99
+ // hot.md structure + session-close files (ADR 0043 global invariant) + scoped
100
+ // lint + W8 design-history + feedback projection. The transcript widens the
101
+ // lint scope to this session's edited files (ADR 0041); without one the scope
102
+ // is the mandatory close files. Read-only: pure feedback drift comes back as
103
+ // gate.driftTargets, a self-heal effect requirement we run as --write below.
104
+ let gate;
105
+ try {
106
+ gate = precompactGateStatus(HYPO_DIR, {
107
+ transcriptPath,
108
+ ...(sessionId ? { sessionId } : {}),
109
+ });
110
+ } catch (err) {
111
+ // Defense-in-depth: precompactGateStatus fails open per-check, but if it ever
112
+ // throws, never crash the PreCompact hook — fail open (continue) so a tooling
113
+ // fault can't wedge /compact.
114
+ process.stderr.write(`[hypo-personal-check] error: ${err?.message ?? String(err)}\n`);
115
+ console.log(JSON.stringify({ continue: true, suppressOutput: true }));
116
+ return;
117
+ }
109
118
 
110
- const lintPath = PKG_ROOT ? join(PKG_ROOT, 'scripts', 'lint.mjs') : null;
111
- let lintBlockers = [];
112
- let lintW8 = [];
113
- let lintNotices = []; // pre-existing debt in files this session did not touch
114
- let lintSkipped = false;
115
- if (!lintPath || !existsSync(lintPath)) {
116
- lintSkipped = true;
117
- } else {
118
- try {
119
- const r = spawnSync('node', [lintPath, '--json'], {
120
- encoding: 'utf-8',
121
- cwd: HYPO_DIR,
122
- timeout: 30000,
119
+ // Self-heal pure feedback projection drift (ADR 0045): the one mutation the
120
+ // read-only gate leaves to the caller. Fails CLOSED — if the --write errors we
121
+ // turn the (otherwise non-blocking) drift into a blocker, since real drift is
122
+ // confirmed and silently passing it would defeat the gate. --write only applies
123
+ // when no target conflicts/over-caps (code===0 across ALL targets), so a late
124
+ // race exits non-zero and blocks here. It is semantic-preflight atomic but not
125
+ // filesystem-atomic (concurrent edit / mid-write I/O fault is best-effort) —
126
+ // pre-existing engine behavior, not introduced by the self-heal.
127
+ let feedbackHealed = '';
128
+ if (gate.ok && gate.driftTargets.length > 0) {
129
+ const feedbackPath = PKG_ROOT ? join(PKG_ROOT, 'scripts', 'feedback-sync.mjs') : null;
130
+ const w = feedbackPath
131
+ ? spawnSync(
132
+ process.execPath,
133
+ [
134
+ feedbackPath,
135
+ '--write',
136
+ '--no-input',
137
+ `--hypo-dir=${HYPO_DIR}`,
138
+ `--claude-home=${join(homedir(), '.claude')}`,
139
+ ],
140
+ { encoding: 'utf-8', timeout: 30000 },
141
+ )
142
+ : { status: 1 };
143
+ if (w.error || w.status === null || w.status !== 0) {
144
+ gate.ok = false;
145
+ gate.blockers.push({
146
+ type: 'feedback',
147
+ reason: `feedback projection drift (${gate.driftTargets.join(', ')}) — auto-sync failed; run \`hypomnema feedback-sync --write\` manually`,
123
148
  });
124
- const parsed = JSON.parse(r.stdout || '{}');
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
- }
154
- } catch (err) {
155
- /* fail-open */
156
- process.stderr.write(`[hypo-personal-check] error: ${err?.message ?? String(err)}\n`);
149
+ } else {
150
+ feedbackHealed = `[WIKI CHECK] feedback projection re-synced (${gate.driftTargets.join(', ')}); MEMORY.md body may be unchanged — drift was in the managed block / side-files.`;
157
151
  }
158
152
  }
159
153
 
160
- const lintOk = lintBlockers.length === 0;
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)),
154
+ // Non-blocking heads-up about pre-existing lint / out-of-scope design-history
155
+ // debt in untouched files (other projects / shared pages). Surfaced so it is
156
+ // visible but never blocks compact. (Mirrors the pre-ADR-0046 inline behavior,
157
+ // which lumped out-of-scope W8 into the same lint-notice list.)
158
+ const debtNotices = gate.notices.filter((n) => n.type === 'lint' || n.type === 'design-history');
159
+ let noticeText =
160
+ debtNotices.length > 0
161
+ ? `[WIKI CHECK] ${debtNotices.length} pre-existing lint issue(s) in files this session did not touch (not blocking): ${[
162
+ ...new Set(debtNotices.map((n) => n.reason.replace(/ \([^)]*\)$/, ''))),
168
163
  ]
169
164
  .slice(0, 5)
170
- .join(', ')}${lintNotices.length > 5 ? ', …' : ''} — clean up when convenient.`
165
+ .join(', ')}${debtNotices.length > 5 ? ', …' : ''} — clean up when convenient.`
171
166
  : '';
167
+ // Surface the self-heal so a re-synced projection is not a silent mutation of
168
+ // the user's MEMORY.md / CLAUDE.md (ADR 0045 transparency).
169
+ if (feedbackHealed) noticeText = noticeText ? `${noticeText}\n${feedbackHealed}` : feedbackHealed;
172
170
 
173
- // ── fix #37 Phase C: feedback projection drift (ADR 0031) ──
174
- // Single blocking gate invariant (spec §7.5): integrate into THIS hook, never
175
- // add a separate PreCompact hook. `feedback-sync --check --strict` reports
176
- // projection drift (wiki feedback SoT vs MEMORY / CLAUDE.md learned-behaviors
177
- // projection). `--no-input` keeps this non-TTY hook from ever blocking on a
178
- // prompt, and the engine's skip-MEMORY warning is *soft* (never escalated by
179
- // --strict) so a fresh / external user whose ~/.claude/projects/<id> dir does
180
- // not exist yet is never gated (contract §5 step 4). Fail-open on any spawn
181
- // error, exactly like the lint check above.
182
- const feedbackPath = PKG_ROOT ? join(PKG_ROOT, 'scripts', 'feedback-sync.mjs') : null;
183
- let feedbackOk = true;
184
- let feedbackReason = '';
185
- let feedbackSkipped = false;
186
- if (!feedbackPath || !existsSync(feedbackPath)) {
187
- feedbackSkipped = true;
188
- } else {
189
- try {
190
- const r = spawnSync(
191
- process.execPath,
192
- [
193
- feedbackPath,
194
- '--check',
195
- '--strict',
196
- '--no-input',
197
- '--json',
198
- `--hypo-dir=${HYPO_DIR}`,
199
- `--claude-home=${join(homedir(), '.claude')}`,
200
- ],
201
- { encoding: 'utf-8', timeout: 30000 },
202
- );
203
- if (r.error || r.status === null) {
204
- feedbackSkipped = true; // spawn failure → fail-open (never block on tooling)
205
- } else if (r.status !== 0) {
206
- // exit≠0 alone is ambiguous. A *missing* target file (e.g. a system
207
- // whose ~/.claude/CLAUDE.md was never created) reports buildError +
208
- // exit 1, which is benign — there is nothing to gate. Decide from the
209
- // JSON report's per-target state instead of the raw exit code: block
210
- // ONLY when some target has a genuine, actionable issue (drift,
211
- // conflict, over-cap, or a malformed managed region). buildError is
212
- // never actionable here, so any mix that lacks a real issue fails open
213
- // — including memory:clean + claude:buildError, where the prior
214
- // `every(buildError)` predicate wrongly blocked that case. Mirrors
215
- // doctor's buildError→warn (non-fatal) handling.
216
- let report = null;
217
- try {
218
- report = JSON.parse(r.stdout || '');
219
- } catch {
220
- /* unparseable → fail-open below */
221
- }
222
- const targets = report ? Object.values(report.targets || {}) : [];
223
- const conflicted = targets.some(
224
- (t) =>
225
- t.intruder || t.unpaired || t.outOfContainer || (t.conflicts && t.conflicts.length),
226
- );
227
- const overCap = targets.some((t) => t.overCap);
228
- const drifted = targets.some((t) => t.dirty);
229
- if (!report || !(conflicted || overCap || drifted)) {
230
- feedbackSkipped = true; // missing target / pure warning / unparseable → fail-open
231
- } else {
232
- feedbackOk = false;
233
- feedbackReason = conflicted
234
- ? 'feedback projection conflict (manual edit) — run `hypomnema feedback-sync --import-target-change --from=<memory|claude>`'
235
- : overCap
236
- ? 'feedback projection over cap — demote/archive feedback pages'
237
- : 'feedback projection drift — run `hypomnema feedback-sync --write`';
238
- }
239
- }
240
- } catch (err) {
241
- feedbackSkipped = true;
242
- process.stderr.write(`[hypo-personal-check] error: ${err?.message ?? String(err)}\n`);
243
- }
244
- }
245
-
246
- if (
247
- gitStatus.clean &&
248
- hotStatus.clean &&
249
- lintOk &&
250
- designHistoryOk &&
251
- closeFiles.ok &&
252
- feedbackOk
253
- ) {
171
+ if (gate.ok) {
254
172
  console.log(
255
173
  JSON.stringify(
256
174
  noticeText
@@ -264,13 +182,9 @@ process.stdin.on('end', () => {
264
182
  // ── Bypass 3: HYPO_SKIP_GATE ──
265
183
  if (isGateSkipped()) {
266
184
  const skipped = [
267
- !gitStatus.clean ? gitStatus.reason : '',
268
- !hotStatus.clean ? hotStatus.reason : '',
269
- !closeFiles.ok ? closeFilesReason : '',
270
- !designHistoryOk ? `design-history stale (${lintW8.length})` : '',
271
- !feedbackOk ? feedbackReason : '',
272
- lintSkipped ? 'lint skipped (hypo-pkg.json missing)' : '',
273
- feedbackSkipped ? 'feedback-sync skipped (hypo-pkg.json missing)' : '',
185
+ ...gate.blockers.map((b) => b.reason),
186
+ gate.skipped.lint ? 'lint skipped (hypo-pkg.json missing)' : '',
187
+ gate.skipped.feedback ? 'feedback-sync skipped (hypo-pkg.json missing)' : '',
274
188
  ]
275
189
  .filter(Boolean)
276
190
  .join(', ');
@@ -284,18 +198,12 @@ process.stdin.on('end', () => {
284
198
  }
285
199
 
286
200
  // ── Block ──
201
+ // gate.blockers already carry per-type reasons in the canonical order
202
+ // (git, hot, close, lint, design-history, feedback) — same strings as before
203
+ // ADR 0046, now sourced from the shared gate instead of inline checks.
287
204
  const reasons = [
288
- !gitStatus.clean ? gitStatus.reason : '',
289
- !hotStatus.clean ? hotStatus.reason : '',
290
- !closeFiles.ok ? closeFilesReason : '',
291
- !lintOk
292
- ? `lint blockers: ${[...new Set(lintBlockers.map((b) => b.id || b.file))].join(', ')}`
293
- : '',
294
- !designHistoryOk
295
- ? `design-history stale: ${lintW8.map((w) => w.file.split('/')[1]).join(', ')}`
296
- : '',
297
- !feedbackOk ? feedbackReason : '',
298
- lintSkipped ? 'lint skipped (run `hypomnema init` to enable lint gate)' : '',
205
+ ...gate.blockers.map((b) => b.reason),
206
+ gate.skipped.lint ? 'lint skipped (run `hypomnema init` to enable lint gate)' : '',
299
207
  ].filter(Boolean);
300
208
 
301
209
  const checklist = readChecklist(today);
@@ -304,11 +212,13 @@ process.stdin.on('end', () => {
304
212
  [
305
213
  ` [ ] 0. Read SCHEMA.md + hypo-guide.md (required before wiki work)`,
306
214
  ` [ ] 1. PRD — create projects/<name>/prd.md if missing`,
307
- ` [ ] 2. ADR — decide yes/no on 5 types; if all N, note "no ADR — reason: <why>"`,
215
+ ` [ ] 2. ADR — decide yes/no on 5 types. Design change append to projects/<name>/design-history.md.`,
216
+ ` If none, note the literal marker "ADR 없음 — reason: <why>" in the session-log entry`,
217
+ ` (machine-readable; suppresses the W8 design-history gate for no-design sessions).`,
308
218
  ` [ ] 3. Ingest — if new external knowledge, save to sources/ and ingest`,
309
219
  ` [ ] 4. Pages — extract new concepts/patterns to pages/`,
310
220
  ` [ ] 5. Synthesis — if 3+ cross-page analysis results, save to pages/syntheses/`,
311
- ` [ ] 6. session-log — append to projects/<name>/session-log/YYYY-MM.md`,
221
+ ` [ ] 6. session-log — append to projects/<name>/session-log/YYYY-MM-DD.md (daily shard)`,
312
222
  ` [ ] 7. index.md — update Projects section if needed`,
313
223
  ` [ ] 8. log.md — append ## [${today}] session | <project-name>`,
314
224
  ` [ ] 9. hot.md — update projects/<name>/hot.md (no exceptions)`,
@@ -317,6 +227,8 @@ process.stdin.on('end', () => {
317
227
  ` [ ] 12. lint — run scripts/lint.mjs; fix errors in files YOU touched`,
318
228
  ` (other projects' / shared-page debt is reported as non-blocking notice)`,
319
229
  ` [ ] 13. git commit & push`,
230
+ ` [ ] 14. verify — run \`crystallize.mjs --check-session-close\`; only declare`,
231
+ ` the session closed once it prints "Compact-ready" (= this gate passes).`,
320
232
  ].join('\n');
321
233
 
322
234
  const closeIntentNote = hasCloseIntent
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * hypo-pre-commit.mjs — wiki git pre-commit hook worker (§6.8 fix #24)
3
+ * hypo-pre-commit.mjs — wiki git pre-commit hook worker (§6.8)
4
4
  *
5
5
  * Blocks staged files that match .hypoignore patterns.
6
6
  * Installed by `hypo init` to <wiki>/.git/hooks/pre-commit.
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * hypo-session-end.mjs — SessionEnd hook (fix #25 PR-A2, ADR 0022 Layer 2)
3
+ * hypo-session-end.mjs — SessionEnd hook (ADR 0022 Layer 2)
4
4
  *
5
5
  * `/clear` cannot be blocked: it never fires UserPromptSubmit (Stage 0 PoC,
6
6
  * 2026-05-14). The only intervention point is the SessionEnd(reason='clear')
@@ -176,7 +176,7 @@ function readLastGrowthLine() {
176
176
  }
177
177
 
178
178
  /**
179
- * fix #25 PR-A2 (ADR 0022 amendment 2026-05-14): if the prior session ended
179
+ * ADR 0022 amendment 2026-05-14: if the prior session ended
180
180
  * via `/clear`, hypo-session-end stashed its identity in `.cache/clear-marker.json`.
181
181
  * Read it (with 7-day stale guard), unlink it (one-shot), and return a
182
182
  * `[WIKI_AUTOCLOSE]` recovery line for additionalContext + stderr.
@@ -212,8 +212,8 @@ function gitPull(dir) {
212
212
  }
213
213
 
214
214
  /**
215
- * fix #10: surface unresolved sync failures recorded by a prior session's
216
- * Stop hook (fix #9). The entry is cleared only once this session's pull has
215
+ * Surface unresolved sync failures recorded by a prior session's
216
+ * Stop hook. The entry is cleared only once this session's pull has
217
217
  * succeeded AND there is no unpushed commit left behind by a failed push
218
218
  * (`[ahead N]`).
219
219
  *
@@ -323,7 +323,7 @@ let raw = '';
323
323
  process.stdin.setEncoding('utf-8');
324
324
  process.stdin.on('data', (chunk) => (raw += chunk));
325
325
  process.stdin.on('end', () => {
326
- // ISSUE-5: declared before the try so every emit branch — including the outer
326
+ // Declared before the try so every emit branch — including the outer
327
327
  // catch — carries the same `systemMessage` (the user-visible update/sibling
328
328
  // banner). Reassigned once below after the notices are computed.
329
329
  let outExtra = { continue: true, suppressOutput: true };
@@ -343,14 +343,14 @@ process.stdin.on('end', () => {
343
343
  const clearRecoveryLine = buildClearRecoveryLine(data.source);
344
344
  const updateLine = buildUpdateNotice();
345
345
  const siblingLine = buildSiblingNotice();
346
- // ISSUE-5: the update + stale-sibling banners must reach the USER. On a
346
+ // The update + stale-sibling banners must reach the USER. On a
347
347
  // SessionStart hook that exits 0, stderr is invisible in the normal TUI
348
348
  // (only shown on exit 2 / --verbose) and additionalContext is model-only —
349
349
  // `systemMessage` is the documented user-visible channel. Route those two
350
350
  // banners there. They ALSO stay in noticePrefix → additionalContext below,
351
351
  // so the model and the user start the session looking at the same state.
352
352
  // (The other stderr notices — sync/growth/clear/suggest — are intentionally
353
- // transcript/--verbose only and out of ISSUE-5's scope.)
353
+ // transcript/--verbose only and out of this banner's scope.)
354
354
  const userMessage = [updateLine, siblingLine].filter(Boolean).join('\n\n');
355
355
  if (userMessage) outExtra = { ...outExtra, systemMessage: userMessage };
356
356
  const notices = [syncLine, growthLine, clearRecoveryLine, updateLine, siblingLine].filter(
@@ -417,7 +417,7 @@ process.stdin.on('end', () => {
417
417
  return;
418
418
  }
419
419
 
420
- // MISS: cwd matches no project. fix #23 / ADR 0023 — offer to create one
420
+ // MISS: cwd matches no project. ADR 0023 — offer to create one
421
421
  // when the ADR trigger conditions hold (git repo + project marker + no
422
422
  // cooldown + not previously declined). The actual scaffold is the LLM's
423
423
  // job on a "Y" reply (scripts/lib/project-create.mjs); the hook only nudges.