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.
- package/.claude/hooks/lib/preferences-parser.cjs +10 -1
- package/.claude/hooks/lib/preferences-writer.cjs +118 -0
- package/.claude/hooks/post-edit-watch.cjs +217 -29
- package/.claude/hooks/session-end-foldin.cjs +61 -5
- package/.claude/hooks/session-state-loader.cjs +49 -1
- package/README.ja.md +4 -4
- package/README.ko.md +7 -7
- package/README.md +7 -7
- package/README.zh-TW.md +4 -4
- package/agents/omd-master.md +8 -5
- package/agents/omd-ux-engineer.md +9 -7
- package/agents/omd-ux-writer.md +1 -1
- package/data/reference-fingerprints.json +6140 -5534
- package/dist/bin/oh-my-design.js +1 -1
- package/dist/{install-skills-KDW74C5K.js → install-skills-7UUDOLG2.js} +28 -24
- package/dist/install-skills-7UUDOLG2.js.map +1 -0
- package/package.json +2 -1
- package/skills/omd-designer-review/SKILL.md +34 -0
- package/skills/omd-final-qa/SKILL.md +29 -0
- package/skills/omd-harness/SKILL.md +8 -1
- package/skills/omd-init/SKILL.md +7 -11
- package/skills/omd-kr-writer/SKILL.md +73 -3
- package/skills/omd-learn/SKILL.md +20 -0
- package/skills/omd-reference-capture/SKILL.md +10 -6
- package/skills/omd-taste/SKILL.md +79 -0
- package/web/references/3o3/DESIGN.md +454 -0
- package/web/references/abema/DESIGN.md +535 -0
- package/web/references/accupass/DESIGN.md +494 -0
- package/web/references/adobe/DESIGN.md +458 -0
- package/web/references/danawa/DESIGN.md +477 -0
- package/web/references/dropbox/DESIGN.md +445 -0
- package/web/references/firstory/DESIGN.md +487 -0
- package/web/references/funnow/DESIGN.md +431 -0
- package/web/references/google/DESIGN.md +499 -0
- package/web/references/greenvines/DESIGN.md +454 -0
- package/web/references/greeting/DESIGN.md +462 -0
- package/web/references/imweb/DESIGN.md +460 -0
- package/web/references/laundrygo/DESIGN.md +434 -0
- package/web/references/microsoft/DESIGN.md +508 -0
- package/web/references/miricanvas/DESIGN.md +464 -0
- package/web/references/modusign/DESIGN.md +524 -0
- package/web/references/omnichat/DESIGN.md +499 -0
- package/web/references/postype/DESIGN.md +431 -0
- package/web/references/rakuten/DESIGN.md +440 -0
- package/web/references/ringle/DESIGN.md +444 -0
- package/web/references/salesforce/DESIGN.md +473 -0
- package/web/references/stores/DESIGN.md +447 -0
- package/web/references/teamblind/DESIGN.md +497 -0
- package/web/references/timee/DESIGN.md +444 -0
- package/web/references/wantedly/DESIGN.md +437 -0
- 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/
|
|
4
|
-
// DESIGN.md
|
|
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
|
-
|
|
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 (
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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 (
|
|
85
|
-
|
|
86
|
-
|
|
206
|
+
if (
|
|
207
|
+
introducedHexes.length === 0 &&
|
|
208
|
+
introducedRadii.length === 0 &&
|
|
209
|
+
introducedDurations.length === 0
|
|
210
|
+
) {
|
|
211
|
+
process.exit(0);
|
|
212
|
+
}
|
|
87
213
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
|
4
|
-
// next SessionStart hook
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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>
|
|
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-
|
|
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)で、
|
|
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
|
-
**
|
|
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
|
|