oh-my-design-cli 1.7.2 → 1.8.1

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 (51) hide show
  1. package/.claude/hooks/lib/preferences-parser.cjs +10 -1
  2. package/.claude/hooks/lib/preferences-writer.cjs +118 -0
  3. package/.claude/hooks/post-edit-watch.cjs +217 -29
  4. package/.claude/hooks/session-end-foldin.cjs +61 -5
  5. package/.claude/hooks/session-state-loader.cjs +49 -1
  6. package/README.ja.md +4 -4
  7. package/README.ko.md +7 -7
  8. package/README.md +7 -7
  9. package/README.zh-TW.md +4 -4
  10. package/agents/omd-master.md +8 -5
  11. package/agents/omd-ux-engineer.md +9 -7
  12. package/agents/omd-ux-writer.md +1 -1
  13. package/data/reference-fingerprints.json +6140 -5534
  14. package/dist/bin/oh-my-design.js +1 -1
  15. package/dist/{install-skills-KDW74C5K.js → install-skills-7UUDOLG2.js} +28 -24
  16. package/dist/install-skills-7UUDOLG2.js.map +1 -0
  17. package/package.json +2 -1
  18. package/skills/omd-designer-review/SKILL.md +34 -0
  19. package/skills/omd-final-qa/SKILL.md +29 -0
  20. package/skills/omd-harness/SKILL.md +8 -1
  21. package/skills/omd-init/SKILL.md +7 -11
  22. package/skills/omd-kr-writer/SKILL.md +73 -3
  23. package/skills/omd-learn/SKILL.md +20 -0
  24. package/skills/omd-reference-capture/SKILL.md +10 -6
  25. package/skills/omd-taste/SKILL.md +79 -0
  26. package/web/references/3o3/DESIGN.md +454 -0
  27. package/web/references/abema/DESIGN.md +535 -0
  28. package/web/references/accupass/DESIGN.md +494 -0
  29. package/web/references/adobe/DESIGN.md +458 -0
  30. package/web/references/danawa/DESIGN.md +477 -0
  31. package/web/references/dropbox/DESIGN.md +445 -0
  32. package/web/references/firstory/DESIGN.md +487 -0
  33. package/web/references/funnow/DESIGN.md +431 -0
  34. package/web/references/google/DESIGN.md +499 -0
  35. package/web/references/greenvines/DESIGN.md +454 -0
  36. package/web/references/greeting/DESIGN.md +462 -0
  37. package/web/references/imweb/DESIGN.md +460 -0
  38. package/web/references/laundrygo/DESIGN.md +434 -0
  39. package/web/references/microsoft/DESIGN.md +508 -0
  40. package/web/references/miricanvas/DESIGN.md +464 -0
  41. package/web/references/modusign/DESIGN.md +524 -0
  42. package/web/references/omnichat/DESIGN.md +499 -0
  43. package/web/references/postype/DESIGN.md +431 -0
  44. package/web/references/rakuten/DESIGN.md +440 -0
  45. package/web/references/ringle/DESIGN.md +444 -0
  46. package/web/references/salesforce/DESIGN.md +473 -0
  47. package/web/references/stores/DESIGN.md +447 -0
  48. package/web/references/teamblind/DESIGN.md +497 -0
  49. package/web/references/timee/DESIGN.md +444 -0
  50. package/web/references/wantedly/DESIGN.md +437 -0
  51. package/dist/install-skills-KDW74C5K.js.map +0 -1
@@ -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
@@ -5,7 +5,7 @@
5
5
  <h1 align="center">oh-my-design</h1>
6
6
 
7
7
  <p align="center">
8
- <strong>AI コーディングエージェントのためのスキル駆動デザイン — コマンド 1 回でブートストラップ。</strong>221 社の実在する企業デザインシステム。インストールに AI 呼び出しゼロ。あとはエージェントに話しかけるだけ。
8
+ <strong>AI コーディングエージェントのためのスキル駆動デザイン — コマンド 1 回でブートストラップ。</strong>246 社の実在する企業デザインシステム。インストールに AI 呼び出しゼロ。あとはエージェントに話しかけるだけ。
9
9
  </p>
10
10
 
11
11
  <p align="center">
@@ -13,7 +13,7 @@
13
13
  <a href="https://www.npmjs.com/package/oh-my-design-cli"><img src="https://img.shields.io/npm/dm/oh-my-design-cli?style=flat-square&color=cb3837" alt="npm downloads" /></a>
14
14
  <a href="LICENSE"><img src="https://img.shields.io/github/license/kwakseongjae/oh-my-design?style=flat-square" alt="License" /></a>
15
15
  <a href="https://github.com/kwakseongjae/oh-my-design/stargazers"><img src="https://img.shields.io/github/stars/kwakseongjae/oh-my-design?style=social" alt="GitHub Stars" /></a>
16
- <img src="https://img.shields.io/badge/references-221-7c5cfc?style=flat-square" alt="221 References" />
16
+ <img src="https://img.shields.io/badge/references-246-7c5cfc?style=flat-square" alt="246 References" />
17
17
  <img src="https://img.shields.io/badge/CLI%20commands-1-blue?style=flat-square" alt="One CLI command" />
18
18
  </p>
19
19
 
@@ -25,7 +25,7 @@
25
25
 
26
26
  ## oh-my-design とは?
27
27
 
28
- **oh-my-design (OmD)** は AI コーディングエージェントのためのデザインシステムです。Claude Code / Codex / OpenCode / Cursor を、あなたのブランドを記憶したシニアプロダクトデザイナーに変えます。一度インストールすれば、あとは欲しいものを説明するだけ — コンポーネント、画面、コピー、アセット、チャート — エージェントがプロジェクトのデザインシステムを適用して出力します。`DESIGN.md` がブランド仕様([Google Stitch](https://stitch.withgoogle.com/docs/design-md/overview/) トークン + ブランド哲学レイヤー: Voice / Narrative / Principles / Personas / States / Motion)で、221 社の実在企業の DESIGN.md がパッケージに同梱されています。**API キー不要。外部インフラ不要。すべて既存の CLI セッション内で動作します。**
28
+ **oh-my-design (OmD)** は AI コーディングエージェントのためのデザインシステムです。Claude Code / Codex / OpenCode / Cursor を、あなたのブランドを記憶したシニアプロダクトデザイナーに変えます。一度インストールすれば、あとは欲しいものを説明するだけ — コンポーネント、画面、コピー、アセット、チャート — エージェントがプロジェクトのデザインシステムを適用して出力します。`DESIGN.md` がブランド仕様([Google Stitch](https://stitch.withgoogle.com/docs/design-md/overview/) トークン + ブランド哲学レイヤー: Voice / Narrative / Principles / Personas / States / Motion)で、246 社の実在企業の DESIGN.md がパッケージに同梱されています。**API キー不要。外部インフラ不要。すべて既存の CLI セッション内で動作します。**
29
29
 
30
30
  ## インストール
31
31
 
@@ -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 サブエージェント · 246 の検証済みリファレンス · 活性化 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