oh-my-design-cli 1.7.2 → 1.8.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.
@@ -48,7 +48,8 @@ function parseOmdMeta(block) {
48
48
 
49
49
  /**
50
50
  * Parse the canonical preferences.md text into structured entries.
51
- * Returns [{ heading, scope, status, timestamp(ms|NaN), confidence, raw }].
51
+ * Returns [{ heading, scope, status, timestamp(ms|NaN), confidence, body, raw }].
52
+ * `body` is the free text after the omd-meta block (the preference itself).
52
53
  * Robust to a missing/garbled meta block (entry is skipped, never throws).
53
54
  */
54
55
  function parsePreferences(text) {
@@ -62,12 +63,20 @@ function parsePreferences(text) {
62
63
  const meta = parseOmdMeta(section);
63
64
  if (!meta) continue;
64
65
  const tsRaw = meta.timestamp || '';
66
+ // Body = everything after the heading line minus the omd-meta block.
67
+ const body = section
68
+ .split('\n')
69
+ .slice(1)
70
+ .join('\n')
71
+ .replace(/```omd-meta\s*\n[\s\S]*?\n```/, '')
72
+ .trim();
65
73
  entries.push({
66
74
  heading,
67
75
  scope: meta.scope || '',
68
76
  status: meta.status || 'pending',
69
77
  confidence: meta.confidence || '',
70
78
  timestamp: tsRaw ? new Date(tsRaw).getTime() : NaN,
79
+ body,
71
80
  raw: meta,
72
81
  });
73
82
  }
@@ -0,0 +1,118 @@
1
+ #!/usr/bin/env node
2
+ // Shared writer for .omd/preferences.md — appends entries in the EXACT
3
+ // canonical format the omd:remember skill writes (skills/omd-remember/SKILL.md).
4
+ // The remember format is the single source of truth; this writer must stay
5
+ // byte-compatible with what lib/preferences-parser.cjs reads:
6
+ //
7
+ // ## <ISO timestamp> — <slug>
8
+ //
9
+ // ```omd-meta
10
+ // id: pref_<base36 timestamp>_<8 hex chars>
11
+ // timestamp: <ISO timestamp>
12
+ // scope: <scope>
13
+ // signal: ambient
14
+ // confidence: inferred
15
+ // status: pending
16
+ // source_agent: claude-code
17
+ // source_context: "src/components/Button.tsx"
18
+ // ```
19
+ //
20
+ // <body>
21
+
22
+ 'use strict';
23
+
24
+ const fs = require('node:fs');
25
+ const path = require('node:path');
26
+ const crypto = require('node:crypto');
27
+ const { parsePreferences } = require('./preferences-parser.cjs');
28
+
29
+ // Same header omd:remember creates on first write (SKILL.md Step 5).
30
+ const FILE_HEADER =
31
+ '---\n' +
32
+ 'schema: omd.preferences/v1\n' +
33
+ 'design_md_hash_at_creation:\n' +
34
+ '---\n' +
35
+ '\n' +
36
+ '# Preference Log\n';
37
+
38
+ /** Slug rule from omd:remember SKILL.md Step 4. */
39
+ function slugify(s) {
40
+ return (
41
+ String(s || '')
42
+ .toLowerCase()
43
+ .replace(/[^a-z0-9]+/g, '-')
44
+ .replace(/^-+|-+$/g, '')
45
+ .slice(0, 40) || 'entry'
46
+ );
47
+ }
48
+
49
+ /** Normalize a body for dedup comparison (case/whitespace-insensitive). */
50
+ function normalizeBody(s) {
51
+ return String(s || '').toLowerCase().replace(/\s+/g, ' ').trim();
52
+ }
53
+
54
+ /**
55
+ * Append a canonical entry to <dir>/.omd/preferences.md.
56
+ * Creates the file (with its frontmatter header) if missing.
57
+ * Dedup: if an existing entry has the same scope AND the same normalized body
58
+ * within the last 24h, skip — returns { written: false, reason: 'duplicate' }.
59
+ */
60
+ function appendEntry({ dir, scope, signal, confidence, body, sourceAgent, sourceContext }) {
61
+ if (!dir || !scope || !body) {
62
+ return { written: false, reason: 'invalid-args', id: '' };
63
+ }
64
+ const prefPath = path.join(dir, '.omd', 'preferences.md');
65
+ let text = '';
66
+ try {
67
+ if (fs.existsSync(prefPath)) text = fs.readFileSync(prefPath, 'utf8');
68
+ } catch {
69
+ // unreadable → treat as missing (best-effort, never throw)
70
+ }
71
+
72
+ const now = Date.now();
73
+ const norm = normalizeBody(body);
74
+ for (const e of parsePreferences(text)) {
75
+ if (e.scope !== scope) continue;
76
+ if (normalizeBody(e.body) !== norm) continue;
77
+ if (!Number.isNaN(e.timestamp) && now - e.timestamp <= 24 * 3600 * 1000) {
78
+ return { written: false, reason: 'duplicate', id: e.raw.id || '' };
79
+ }
80
+ }
81
+
82
+ const ts = new Date(now).toISOString();
83
+ // id rule from omd:remember SKILL.md Step 3: pref_<base36 ts>_<8 hex chars>.
84
+ const id =
85
+ 'pref_' + now.toString(36) + '_' + crypto.randomBytes(4).toString('hex');
86
+ const metaLines = [
87
+ `id: ${id}`,
88
+ `timestamp: ${ts}`,
89
+ `scope: ${scope}`,
90
+ `signal: ${signal || 'ambient'}`,
91
+ `confidence: ${confidence || 'inferred'}`,
92
+ 'status: pending',
93
+ `source_agent: ${sourceAgent || 'claude-code'}`,
94
+ ];
95
+ if (sourceContext) {
96
+ metaLines.push(`source_context: ${JSON.stringify(sourceContext)}`);
97
+ }
98
+ const entry = [
99
+ `## ${ts} — ${slugify(body)}`,
100
+ '',
101
+ '```omd-meta',
102
+ ...metaLines,
103
+ '```',
104
+ '',
105
+ String(body).trim(),
106
+ '',
107
+ ].join('\n');
108
+
109
+ if (!text) {
110
+ fs.mkdirSync(path.dirname(prefPath), { recursive: true });
111
+ text = FILE_HEADER;
112
+ }
113
+ if (!text.endsWith('\n')) text += '\n';
114
+ fs.writeFileSync(prefPath, text + '\n' + entry);
115
+ return { written: true, id };
116
+ }
117
+
118
+ module.exports = { appendEntry };
@@ -1,7 +1,19 @@
1
1
  #!/usr/bin/env node
2
2
  // PostToolUse hook — runs after Edit/Write on .tsx/.jsx/.css/.scss files.
3
- // Detects if the change introduced a hex/spacing value that's NOT in
4
- // DESIGN.md tokens, and surfaces a one-line suggestion to capture as preference.
3
+ // Detects if the change introduced a hex / radius / motion-duration value
4
+ // that's NOT in DESIGN.md, surfaces a one-line suggestion to capture as
5
+ // preference, AND persists the drift to .omd/preferences.md as an ambient
6
+ // inferred entry (issue #24 — alert + record).
7
+ //
8
+ // Detection axes (high-precision only — each axis needs a parsable DESIGN.md
9
+ // scale, otherwise it is skipped silently):
10
+ // - color: hex literals vs DESIGN.md hexes (token-system DESIGN.md → skip)
11
+ // - radius: rounded-{none,sm,md,lg,xl,2xl,3xl,full} / border-radius values
12
+ // vs the DESIGN.md radius scale
13
+ // - motion: duration-{n} / `duration: <n>ms` vs the DESIGN.md motion section
14
+ // Spacing and voice are DEFERRED per issue #24 — gap-/p-/m- token drift and
15
+ // copy-edit keyword detection are too low-precision against prose DESIGN.md
16
+ // content to record without flooding preferences.md with false positives.
5
17
  //
6
18
  // Hook input shape (Claude Code hook contract):
7
19
  // stdin = JSON with toolName / args / etc.
@@ -10,10 +22,23 @@
10
22
 
11
23
  const fs = require('node:fs');
12
24
  const path = require('node:path');
25
+ const { appendEntry } = require('./lib/preferences-writer.cjs');
13
26
 
14
27
  const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
15
28
  const designMd = path.join(projectDir, 'DESIGN.md');
16
29
 
30
+ // Tailwind rounded-* scale → px (default theme).
31
+ const TW_RADIUS = {
32
+ none: 0,
33
+ sm: 2,
34
+ md: 6,
35
+ lg: 8,
36
+ xl: 12,
37
+ '2xl': 16,
38
+ '3xl': 24,
39
+ full: 9999,
40
+ };
41
+
17
42
  let input = '';
18
43
  process.stdin.setEncoding('utf8');
19
44
  process.stdin.on('data', (c) => (input += c));
@@ -32,7 +57,9 @@ process.stdin.on('end', () => {
32
57
  // a fallback for other channels / older payloads.
33
58
  const toolInput = payload.tool_input || payload.toolInput || {};
34
59
  const filePath = toolInput.file_path || toolInput.filePath || '';
35
- if (!/\.(tsx|jsx|ts|js|css|scss)$/i.test(filePath)) {
60
+ // .html/.vue/.svelte included — omd:harness/apply emit html prototypes,
61
+ // which is where most generated-UI drift actually lives.
62
+ if (!/\.(tsx|jsx|ts|js|css|scss|html|vue|svelte)$/i.test(filePath)) {
36
63
  process.exit(0);
37
64
  }
38
65
 
@@ -52,46 +79,208 @@ process.stdin.on('end', () => {
52
79
  return s;
53
80
  }
54
81
 
82
+ const designText = fs.existsSync(designMd)
83
+ ? (() => {
84
+ try {
85
+ return fs.readFileSync(designMd, 'utf8');
86
+ } catch {
87
+ return '';
88
+ }
89
+ })()
90
+ : '';
91
+
92
+ // ---- axis: color (hex) -------------------------------------------------
55
93
  // Extract hexes from new content
56
94
  const rawHexes = newText.match(/#[0-9a-f]{3,8}\b/gi) || [];
57
95
  const hexes = [...new Set(rawHexes.map(normHex))];
58
- if (hexes.length === 0) process.exit(0);
59
96
 
60
97
  // Read DESIGN.md hexes — also handle CSS vars and oklch (Tailwind v4) by
61
98
  // signaling N/A when DESIGN.md is purely token-driven (no inline hex).
62
99
  let designHexes = new Set();
63
100
  let designUsesTokenSystem = false;
64
- if (fs.existsSync(designMd)) {
65
- try {
66
- const text = fs.readFileSync(designMd, 'utf8');
67
- const lower = text.toLowerCase();
68
- for (const h of (lower.match(/#[0-9a-f]{3,8}\b/g) || [])) {
69
- designHexes.add(normHex(h));
101
+ if (designText) {
102
+ const lower = designText.toLowerCase();
103
+ for (const h of (lower.match(/#[0-9a-f]{3,8}\b/g) || [])) {
104
+ designHexes.add(normHex(h));
105
+ }
106
+ // If DESIGN.md mostly uses oklch() / CSS vars / token names, hex-only
107
+ // comparison is unreliable — skip warning to avoid noise.
108
+ const hexCount = designHexes.size;
109
+ const oklchCount = (lower.match(/oklch\(/g) || []).length;
110
+ const cssVarCount = (lower.match(/var\(--/g) || []).length;
111
+ if ((oklchCount + cssVarCount) > hexCount * 2) {
112
+ designUsesTokenSystem = true;
113
+ }
114
+ }
115
+
116
+ const introducedHexes = designUsesTokenSystem
117
+ ? [] // skip — too noisy
118
+ : hexes.filter((h) => !designHexes.has(h));
119
+
120
+ // ---- axis: radius ------------------------------------------------------
121
+ // DESIGN.md radius scale: px values on lines mentioning radius/rounded,
122
+ // plus the frontmatter `rounded: { sm: 2, md: 4 }` token block (bare = px).
123
+ const designRadii = new Set();
124
+ for (const line of designText.split('\n')) {
125
+ if (!/radius|rounded/i.test(line)) continue;
126
+ for (const m of line.matchAll(/(\d+(?:\.\d+)?)px/g)) {
127
+ designRadii.add(parseFloat(m[1]));
128
+ }
129
+ const fm = /rounded:\s*\{([^}]*)\}/.exec(line);
130
+ if (fm) {
131
+ for (const m of fm[1].matchAll(/:\s*(\d+(?:\.\d+)?)/g)) {
132
+ designRadii.add(parseFloat(m[1]));
70
133
  }
71
- // If DESIGN.md mostly uses oklch() / CSS vars / token names, hex-only
72
- // comparison is unreliable — skip warning to avoid noise.
73
- const hexCount = designHexes.size;
74
- const oklchCount = (lower.match(/oklch\(/g) || []).length;
75
- const cssVarCount = (lower.match(/var\(--/g) || []).length;
76
- if ((oklchCount + cssVarCount) > hexCount * 2) {
77
- designUsesTokenSystem = true;
134
+ }
135
+ if (/\bfull\b|\b999\d*\b/i.test(line)) designRadii.add(9999);
136
+ }
137
+
138
+ const introducedRadii = [];
139
+ if (designRadii.size > 0) {
140
+ // no parsable scale → skip axis silently
141
+ const seen = new Set();
142
+ for (const m of newText.matchAll(
143
+ /\brounded-(none|sm|md|lg|xl|2xl|3xl|full)\b/g,
144
+ )) {
145
+ const px = TW_RADIUS[m[1]];
146
+ const label = `rounded-${m[1]}(${px}px)`;
147
+ if (!designRadii.has(px) && !seen.has(label)) {
148
+ seen.add(label);
149
+ introducedRadii.push(label);
150
+ }
151
+ }
152
+ // kebab-case CSS (`border-radius: 9999px`) AND camelCase JSX inline style
153
+ // (`borderRadius: "9999px"`) — quoted values included.
154
+ for (const m of newText.matchAll(
155
+ /border-?[rR]adius:\s*["']?(\d+(?:\.\d+)?)(px|rem)/g,
156
+ )) {
157
+ const px = m[2] === 'rem' ? parseFloat(m[1]) * 16 : parseFloat(m[1]);
158
+ const label = `border-radius:${m[1]}${m[2]}`;
159
+ // ≥999px is the "pill" idiom — match it to a 9999/full scale entry.
160
+ const effective = px >= 999 ? 9999 : px;
161
+ if (!designRadii.has(effective) && !seen.has(label)) {
162
+ seen.add(label);
163
+ introducedRadii.push(label);
164
+ }
165
+ }
166
+ }
167
+
168
+ // ---- axis: motion ------------------------------------------------------
169
+ // DESIGN.md motion scale: all <n>ms values inside the Motion/Easing section
170
+ // (heading match → until the next `## `). No section → skip axis silently.
171
+ const designDurations = new Set();
172
+ const motionSection = /^##+ .*(motion|easing).*$/im.exec(designText);
173
+ if (motionSection) {
174
+ const rest = designText.slice(motionSection.index + motionSection[0].length);
175
+ const end = rest.search(/^## /m);
176
+ const section = end >= 0 ? rest.slice(0, end) : rest;
177
+ for (const m of section.matchAll(/(\d+)ms\b/g)) {
178
+ designDurations.add(parseInt(m[1], 10));
179
+ }
180
+ }
181
+
182
+ const introducedDurations = [];
183
+ if (designDurations.size > 0) {
184
+ const seen = new Set();
185
+ // Tailwind duration-{n} — n is milliseconds.
186
+ for (const m of newText.matchAll(/\bduration-(\d+)\b/g)) {
187
+ const n = parseInt(m[1], 10);
188
+ const label = `duration-${m[1]}(${n}ms)`;
189
+ if (!designDurations.has(n) && !seen.has(label)) {
190
+ seen.add(label);
191
+ introducedDurations.push(label);
192
+ }
193
+ }
194
+ // CSS / JS object literal — `duration: 300ms`, `transition-duration: 300ms`,
195
+ // and camelCase JSX `transitionDuration: "300ms"` (quoted values included).
196
+ for (const m of newText.matchAll(/[dD]uration:\s*["']?(\d+)\s*ms\b/g)) {
197
+ const n = parseInt(m[1], 10);
198
+ const label = `duration:${n}ms`;
199
+ if (!designDurations.has(n) && !seen.has(label)) {
200
+ seen.add(label);
201
+ introducedDurations.push(label);
78
202
  }
79
- } catch {
80
- // ignore
81
203
  }
82
204
  }
83
205
 
84
- if (designUsesTokenSystem) process.exit(0); // skip — too noisy
85
- const introduced = hexes.filter((h) => !designHexes.has(h));
86
- if (introduced.length === 0) process.exit(0);
206
+ if (
207
+ introducedHexes.length === 0 &&
208
+ introducedRadii.length === 0 &&
209
+ introducedDurations.length === 0
210
+ ) {
211
+ process.exit(0);
212
+ }
87
213
 
88
- const lines = [
89
- '',
90
- 'OMD WATCH:',
91
- `방금 ${path.basename(filePath)} 에 DESIGN.md에 없는 색이 들어갔어요: ${introduced.slice(0, 3).join(', ')}`,
214
+ // ---- alert (kept — alert + record) --------------------------------------
215
+ const base = path.basename(filePath);
216
+ const lines = ['', 'OMD WATCH:'];
217
+ if (introducedHexes.length > 0) {
218
+ lines.push(
219
+ `방금 ${base} 에 DESIGN.md에 없는 색이 들어갔어요: ${introducedHexes.slice(0, 3).join(', ')}`,
220
+ );
221
+ }
222
+ if (introducedRadii.length > 0) {
223
+ lines.push(
224
+ `방금 ${base} 에 DESIGN.md radius 스케일에 없는 값이 들어갔어요: ${introducedRadii.slice(0, 3).join(', ')}`,
225
+ );
226
+ }
227
+ if (introducedDurations.length > 0) {
228
+ lines.push(
229
+ `방금 ${base} 에 DESIGN.md motion 스케일에 없는 duration이 들어갔어요: ${introducedDurations.slice(0, 3).join(', ')}`,
230
+ );
231
+ }
232
+ lines.push(
92
233
  '의도된 거면 \`omd remember "<설명>" --context "' + filePath + '"\` 으로 preference 캡처 추천.',
93
- '',
94
- ];
234
+ );
235
+ lines.push('');
236
+
237
+ // ---- record (ambient persistence, issue #24) -----------------------------
238
+ // Guards: never record while editing DESIGN.md / DESIGN_DEPRECATED.md,
239
+ // anything under .omd/ or .claude/ (or the .codex/ mirror), or non-UI files
240
+ // (.ts only when the content is JSX-ish).
241
+ const normPath = filePath.replace(/\\/g, '/');
242
+ const isProtectedFile =
243
+ /(^|\/)(DESIGN|DESIGN_DEPRECATED)\.md$/i.test(normPath) ||
244
+ /(^|\/)\.(omd|claude|codex)\//.test(normPath);
245
+ const isUiFile =
246
+ /\.(tsx|jsx|html|css|vue|svelte)$/i.test(normPath) ||
247
+ (/\.ts$/i.test(normPath) && /<[A-Za-z][^>]*>/.test(newText));
248
+ if (!isProtectedFile && isUiFile) {
249
+ try {
250
+ if (introducedHexes.length > 0 && designHexes.size > 0) {
251
+ appendEntry({
252
+ dir: projectDir,
253
+ scope: 'color',
254
+ signal: 'ambient',
255
+ confidence: 'inferred',
256
+ sourceContext: filePath,
257
+ body: `Introduced off-palette color(s) ${introducedHexes.slice(0, 3).join(', ')} in ${normPath} — not in DESIGN.md`,
258
+ });
259
+ }
260
+ if (introducedRadii.length > 0) {
261
+ appendEntry({
262
+ dir: projectDir,
263
+ scope: 'visualTheme',
264
+ signal: 'ambient',
265
+ confidence: 'inferred',
266
+ sourceContext: filePath,
267
+ body: `Introduced off-scale border radius ${introducedRadii.slice(0, 3).join(', ')} in ${normPath} — not in DESIGN.md radius scale`,
268
+ });
269
+ }
270
+ if (introducedDurations.length > 0) {
271
+ appendEntry({
272
+ dir: projectDir,
273
+ scope: 'motion',
274
+ signal: 'ambient',
275
+ confidence: 'inferred',
276
+ sourceContext: filePath,
277
+ body: `Introduced off-scale motion duration ${introducedDurations.slice(0, 3).join(', ')} in ${normPath} — not in DESIGN.md motion scale`,
278
+ });
279
+ }
280
+ } catch {
281
+ // recording is best-effort — the alert must still go out
282
+ }
283
+ }
95
284
 
96
285
  // PostToolUse hook contract: additionalContext must be nested under
97
286
  // hookSpecificOutput — a top-level { additionalContext } is dropped.
@@ -104,4 +293,3 @@ process.stdin.on('end', () => {
104
293
  }),
105
294
  );
106
295
  });
107
-
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  // Stop hook — at session end, run fold-in algorithm on .omd/preferences.md.
3
- // If proposals exceed threshold, append a note to .omd/timeline.md so the
4
- // next SessionStart hook surfaces it as "fold-in proposals ready: N".
3
+ // If proposals exceed threshold, append a note to .omd/timeline.md AND write
4
+ // .omd/foldin-proposal.json so the next SessionStart hook instructs the agent
5
+ // to ask via AskUserQuestion (hooks can't render UI — the agent layer does).
5
6
 
6
7
  'use strict';
7
8
 
@@ -13,6 +14,7 @@ const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
13
14
  const preferencesMd = path.join(projectDir, '.omd', 'preferences.md');
14
15
  const timelineMd = path.join(projectDir, '.omd', 'timeline.md');
15
16
  const configJson = path.join(projectDir, '.omd', 'config.json');
17
+ const proposalJson = path.join(projectDir, '.omd', 'foldin-proposal.json');
16
18
 
17
19
  if (!fs.existsSync(preferencesMd)) process.exit(0);
18
20
 
@@ -49,7 +51,7 @@ for (const entry of parsePreferences(prefText)) {
49
51
  // from confidence (explicit statements weigh more than inferred).
50
52
  const importance = entry.confidence === 'inferred' ? 2 : 4;
51
53
  const list = byScope.get(entry.scope) || [];
52
- list.push({ ts, importance });
54
+ list.push({ ts, importance, id: entry.raw.id || '', body: entry.body || '' });
53
55
  byScope.set(entry.scope, list);
54
56
  }
55
57
 
@@ -64,7 +66,18 @@ for (const [scope, entries] of byScope.entries()) {
64
66
  const recency = Math.exp(-daysSince / 7);
65
67
  const score = entries.length * importanceAvg * recency * 10;
66
68
  if (score >= config.fold_in_score_threshold) {
67
- proposals.push({ scope, count: entries.length, score: Math.round(score) });
69
+ // One-line summary from the latest entry's body (first non-empty line).
70
+ const latest = entries.reduce((m, e) => (e.ts > m.ts ? e : m), entries[0]);
71
+ const summary =
72
+ (latest.body.split('\n').find((l) => l.trim()) || '').trim();
73
+ proposals.push({
74
+ scope,
75
+ count: entries.length,
76
+ score: Math.round(score),
77
+ entry_ids: entries.map((e) => e.id).filter(Boolean),
78
+ summary,
79
+ latestTs: lastTs,
80
+ });
68
81
  }
69
82
  }
70
83
 
@@ -76,7 +89,11 @@ const block =
76
89
  `## ${ts} — fold_in_proposal\n\n` +
77
90
  `${proposals.length} fold-in proposals ready (top: ${proposals[0].scope}, score ${proposals[0].score}).\n\n` +
78
91
  '```json\n' +
79
- JSON.stringify(proposals.slice(0, 5), null, 2) +
92
+ JSON.stringify(
93
+ proposals.slice(0, 5).map(({ scope, count, score }) => ({ scope, count, score })),
94
+ null,
95
+ 2,
96
+ ) +
80
97
  '\n```\n\n';
81
98
 
82
99
  if (!fs.existsSync(path.dirname(timelineMd))) {
@@ -88,6 +105,45 @@ if (!fs.existsSync(timelineMd)) {
88
105
  fs.appendFileSync(timelineMd, block);
89
106
  }
90
107
 
108
+ // Write the structured proposal for the next SessionStart (auto-fold gate,
109
+ // issue #23). Overwrite any prior proposal UNLESS it is snoozed and no scope
110
+ // gained a new entry since snoozed_at — don't re-ask on every session.
111
+ let writeProposal = true;
112
+ try {
113
+ const prior = JSON.parse(fs.readFileSync(proposalJson, 'utf8'));
114
+ if (prior && prior.status === 'snoozed' && prior.snoozed_at) {
115
+ const snoozedAt = new Date(prior.snoozed_at).getTime();
116
+ if (
117
+ !Number.isNaN(snoozedAt) &&
118
+ proposals.every((p) => p.latestTs <= snoozedAt)
119
+ ) {
120
+ writeProposal = false; // still snoozed, nothing new — leave it alone
121
+ }
122
+ }
123
+ } catch {
124
+ // no prior proposal / malformed → overwrite
125
+ }
126
+ if (writeProposal) {
127
+ fs.writeFileSync(
128
+ proposalJson,
129
+ JSON.stringify(
130
+ {
131
+ created_at: new Date().toISOString(),
132
+ status: 'proposed',
133
+ scopes: proposals.map(({ scope, count, score, entry_ids, summary }) => ({
134
+ scope,
135
+ count,
136
+ score,
137
+ entry_ids,
138
+ summary,
139
+ })),
140
+ },
141
+ null,
142
+ 2,
143
+ ) + '\n',
144
+ );
145
+ }
146
+
91
147
  // Optional: print one-line confirmation to stdout
92
148
  process.stdout.write(JSON.stringify({}) || '');
93
149
 
@@ -13,6 +13,7 @@ const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
13
13
  const stateMd = path.join(projectDir, '.omd', 'state.md');
14
14
  const timelineMd = path.join(projectDir, '.omd', 'timeline.md');
15
15
  const preferencesMd = path.join(projectDir, '.omd', 'preferences.md');
16
+ const proposalJson = path.join(projectDir, '.omd', 'foldin-proposal.json');
16
17
 
17
18
  function safeRead(p) {
18
19
  try {
@@ -44,6 +45,43 @@ if (fs.existsSync(stateMd)) {
44
45
  }
45
46
  }
46
47
 
48
+ // Auto-fold gate (issue #23): a "proposed" fold-in proposal instructs the
49
+ // agent to ask the user via AskUserQuestion — hooks can't render UI, the
50
+ // agent executes the question at session start. Snoozed/applied → silent.
51
+ let proposal = null;
52
+ try {
53
+ proposal = JSON.parse(fs.readFileSync(proposalJson, 'utf8'));
54
+ } catch {
55
+ // missing / malformed → no proposal
56
+ }
57
+ if (
58
+ proposal &&
59
+ proposal.status === 'proposed' &&
60
+ Array.isArray(proposal.scopes) &&
61
+ proposal.scopes.length > 0
62
+ ) {
63
+ const items = proposal.scopes
64
+ .map((s) => `${s.scope}: ${s.summary || '(no summary)'} (${s.count}×)`)
65
+ .join('; ');
66
+ const perScope =
67
+ proposal.scopes.length <= 3
68
+ ? ` / per-scope picks (${proposal.scopes.map((s) => s.scope).join(', ')})`
69
+ : '';
70
+ lines.push('## OMD FOLD-IN PROPOSAL');
71
+ lines.push('');
72
+ lines.push(`${proposal.scopes.length} scope(s) recurred: ${items}.`);
73
+ lines.push(
74
+ `Ask the user via AskUserQuestion (one question, options: '전부 반영'${perScope} / '나중에') whether to fold these into DESIGN.md.`,
75
+ );
76
+ lines.push(
77
+ "On approve: run the omd:learn skill for the approved scopes, then set .omd/foldin-proposal.json status to 'applied'.",
78
+ );
79
+ lines.push(
80
+ "On decline: set status to 'snoozed' and add snoozed_at (ISO timestamp).",
81
+ );
82
+ lines.push('');
83
+ }
84
+
47
85
  if (fs.existsSync(timelineMd)) {
48
86
  const text = safeRead(timelineMd) || '';
49
87
  const blocks = text.split(/^## /m).slice(1).slice(-3);
@@ -60,6 +98,16 @@ if (fs.existsSync(timelineMd)) {
60
98
  }
61
99
 
62
100
  if (lines.length > 0) {
63
- process.stdout.write(JSON.stringify({ additionalContext: lines.join('\n') }));
101
+ // SessionStart contract: structured context must sit under hookSpecificOutput
102
+ // — a top-level { additionalContext } parses as JSON and is silently dropped
103
+ // (same class as the post-edit-watch / skill-activation fixes).
104
+ process.stdout.write(
105
+ JSON.stringify({
106
+ hookSpecificOutput: {
107
+ hookEventName: 'SessionStart',
108
+ additionalContext: lines.join('\n'),
109
+ },
110
+ }),
111
+ );
64
112
  }
65
113
 
package/README.ja.md CHANGED
@@ -50,7 +50,7 @@ npx oh-my-design-cli install-skills
50
50
 
51
51
  ## パッケージの中身
52
52
 
53
- **16 スキル · 16 サブエージェント · 221 の検証済みリファレンス · 活性化 hooks** — 上記コマンド 1 回ですべてインストールされます。
53
+ **17 スキル · 16 サブエージェント · 221 の検証済みリファレンス · 活性化 hooks** — 上記コマンド 1 回ですべてインストールされます。
54
54
 
55
55
  すべてのリファレンスは `oh-my-design.kr/design-systems/<id>.md` から raw markdown としても取得でき、エージェントが直接 fetch できます。スキル・エージェントごとの詳細リファレンス: **[oh-my-design.kr/docs](https://oh-my-design.kr/docs)**。
56
56
 
package/README.ko.md CHANGED
@@ -74,9 +74,9 @@ Toss가 아니어도 됩니다 — `Stripe-style`, `Linear-clone B2B SaaS`, `Kar
74
74
 
75
75
  ## 패키지 구성
76
76
 
77
- **16 스킬 · 16 서브에이전트 · 221 검증된 레퍼런스 · 활성화 hooks** — 위 명령어 하나로 전부 설치됩니다.
77
+ **17 스킬 · 16 서브에이전트 · 221 검증된 레퍼런스 · 활성화 hooks** — 위 명령어 하나로 전부 설치됩니다.
78
78
 
79
- - **스킬** — core flow (`omd:init` / `omd:apply` / `omd:harness` / `omd:sync` / `omd:remember` / `omd:learn`), 라이브 캡처 + 에셋 (`omd:reference-capture` / `omd:asset-fetch` / `omd:experiment-gallery`), v0.2 agent layer (`omd:orchestrator` / `omd:kr-writer` / `omd:locale-adapter` / `omd:designer-review` / `omd:final-qa` / `omd:codex-image`), 그리고 터미널에서 claude.ai/design을 구동하는 단독 스킬 `claude-design`.
79
+ - **스킬** — core flow (`omd:init` / `omd:apply` / `omd:harness` / `omd:sync` / `omd:remember` / `omd:learn` / `omd:taste` — "내 취향 보여줘" 한마디로 루프가 배운 것·대기 중·보류된 것을 한 뷰로), 라이브 캡처 + 에셋 (`omd:reference-capture` / `omd:asset-fetch` / `omd:experiment-gallery`), v0.2 agent layer (`omd:orchestrator` / `omd:kr-writer` / `omd:locale-adapter` / `omd:designer-review` / `omd:final-qa` / `omd:codex-image`), 그리고 터미널에서 claude.ai/design을 구동하는 단독 스킬 `claude-design`.
80
80
  - **서브에이전트** — `omd-master` + 15 스페셜리스트 (UX 리서치, UI 생성, 에셋 큐레이션, 마이크로카피, a11y 감사, 페르소나 테스트, 비평, …).
81
81
  - **레퍼런스** — 221개 실제 기업 `DESIGN.md`, 전부 라이브 소스 대조 검증. 모든 레퍼런스는 `oh-my-design.kr/design-systems/<id>.md`에서 raw markdown으로도 제공되어 에이전트가 직접 fetch할 수 있습니다.
82
82
  - **Hooks** — UserPromptSubmit / SessionStart / PostToolUse 활성화 — 슬래시 명령 없이 자연어만으로 스킬이 발동합니다.
package/README.md CHANGED
@@ -74,9 +74,9 @@ The default install targets every detected agent; pass `--agent <name>` to insta
74
74
 
75
75
  ## What's inside
76
76
 
77
- **16 skills · 16 sub-agents · 221 verified references · activation hooks** — installed by the one command above.
77
+ **17 skills · 16 sub-agents · 221 verified references · activation hooks** — installed by the one command above.
78
78
 
79
- - **Skills** — core flow (`omd:init` / `omd:apply` / `omd:harness` / `omd:sync` / `omd:remember` / `omd:learn`), live capture + assets (`omd:reference-capture` / `omd:asset-fetch` / `omd:experiment-gallery`), the v0.2 agent layer (`omd:orchestrator` / `omd:kr-writer` / `omd:locale-adapter` / `omd:designer-review` / `omd:final-qa` / `omd:codex-image`), plus the standalone `claude-design` skill that drives claude.ai/design from your terminal.
79
+ - **Skills** — core flow (`omd:init` / `omd:apply` / `omd:harness` / `omd:sync` / `omd:remember` / `omd:learn` / `omd:taste` — say "what are my preferences" to see everything the loop has learned, pending, or snoozed), live capture + assets (`omd:reference-capture` / `omd:asset-fetch` / `omd:experiment-gallery`), the v0.2 agent layer (`omd:orchestrator` / `omd:kr-writer` / `omd:locale-adapter` / `omd:designer-review` / `omd:final-qa` / `omd:codex-image`), plus the standalone `claude-design` skill that drives claude.ai/design from your terminal.
80
80
  - **Sub-agents** — `omd-master` + 15 specialists (UX research, UI generation, asset curation, microcopy, a11y audit, persona testing, critique, …).
81
81
  - **References** — 221 real-company `DESIGN.md` files, each verified against live sources. Every reference is also served as raw markdown at `oh-my-design.kr/design-systems/<id>.md`, so agents can fetch it directly.
82
82
  - **Hooks** — UserPromptSubmit / SessionStart / PostToolUse activation so the skills trigger on natural language, not just slash commands.
package/README.zh-TW.md CHANGED
@@ -50,7 +50,7 @@ npx oh-my-design-cli install-skills
50
50
 
51
51
  ## 套件內容
52
52
 
53
- **16 個 skills · 16 個子代理 · 221 個經驗證的參考 · 活性化 hooks** — 上述一個指令全部安裝完成。
53
+ **17 個 skills · 16 個子代理 · 221 個經驗證的參考 · 活性化 hooks** — 上述一個指令全部安裝完成。
54
54
 
55
55
  每個參考也以 raw markdown 形式提供於 `oh-my-design.kr/design-systems/<id>.md`,代理可以直接抓取。完整的 skill 與 agent 參考文件: **[oh-my-design.kr/docs](https://oh-my-design.kr/docs)**。
56
56