oh-my-design-cli 1.7.1 → 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.
- package/.claude/hooks/lib/preferences-parser.cjs +91 -0
- package/.claude/hooks/lib/preferences-writer.cjs +118 -0
- package/.claude/hooks/post-edit-watch.cjs +232 -36
- package/.claude/hooks/session-end-foldin.cjs +72 -19
- package/.claude/hooks/session-state-loader.cjs +52 -3
- package/.claude/hooks/skill-activation.cjs +40 -7
- package/README.ja.md +32 -103
- package/README.ko.md +45 -206
- package/README.md +33 -151
- package/README.zh-TW.md +32 -103
- package/agents/AGENT.md +3 -3
- package/agents/omd-master.md +16 -7
- package/agents/omd-microcopy.md +1 -1
- package/agents/omd-ux-engineer.md +9 -7
- package/agents/omd-ux-researcher.md +1 -1
- package/agents/omd-ux-writer.md +1 -1
- package/dist/bin/oh-my-design.js +3 -3
- package/dist/bin/oh-my-design.js.map +1 -1
- package/dist/{install-skills-YYHEC4CS.js → install-skills-7UUDOLG2.js} +152 -20
- package/dist/install-skills-7UUDOLG2.js.map +1 -0
- package/package.json +3 -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 +30 -9
- package/skills/omd-init/SKILL.md +52 -6
- package/skills/omd-kr-writer/SKILL.md +73 -3
- package/skills/omd-learn/SKILL.md +20 -0
- package/skills/omd-reference-capture/SKILL.md +15 -4
- package/skills/omd-taste/SKILL.md +79 -0
- package/dist/install-skills-YYHEC4CS.js.map +0 -1
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Shared parser for .omd/preferences.md — the CANONICAL format written by the
|
|
3
|
+
// omd:remember skill (skills/omd-remember/SKILL.md). The remember format is the
|
|
4
|
+
// single source of truth; these hooks read it, they never change it.
|
|
5
|
+
//
|
|
6
|
+
// Canonical entry shape:
|
|
7
|
+
//
|
|
8
|
+
// ## 2026-04-30T17:48:00.000Z — ctas-never-uppercase
|
|
9
|
+
//
|
|
10
|
+
// ```omd-meta
|
|
11
|
+
// id: pref_lqxk2_a3f9c1d4
|
|
12
|
+
// timestamp: 2026-04-30T17:48:00.000Z
|
|
13
|
+
// scope: components.button
|
|
14
|
+
// signal: user-statement
|
|
15
|
+
// confidence: explicit
|
|
16
|
+
// status: pending
|
|
17
|
+
// source_agent: claude-code
|
|
18
|
+
// source_context: "src/components/Button.tsx"
|
|
19
|
+
// ```
|
|
20
|
+
//
|
|
21
|
+
// CTAs are never uppercase
|
|
22
|
+
//
|
|
23
|
+
// Entries are `## <heading>` sections; metadata lives in a fenced ```omd-meta
|
|
24
|
+
// block of `key: value` lines (NOT dash-lists, NOT `### ` headings).
|
|
25
|
+
|
|
26
|
+
'use strict';
|
|
27
|
+
|
|
28
|
+
/** Parse the key/value lines inside an ```omd-meta fenced block. */
|
|
29
|
+
function parseOmdMeta(block) {
|
|
30
|
+
const m = /```omd-meta\s*\n([\s\S]*?)\n```/.exec(block);
|
|
31
|
+
if (!m) return null;
|
|
32
|
+
const meta = {};
|
|
33
|
+
for (const line of m[1].split('\n')) {
|
|
34
|
+
const kv = /^\s*([a-z_]+):\s*(.*)$/i.exec(line);
|
|
35
|
+
if (!kv) continue;
|
|
36
|
+
let val = kv[2].trim();
|
|
37
|
+
// Strip surrounding quotes (source_context is JSON.stringify'd).
|
|
38
|
+
if (
|
|
39
|
+
(val.startsWith('"') && val.endsWith('"')) ||
|
|
40
|
+
(val.startsWith("'") && val.endsWith("'"))
|
|
41
|
+
) {
|
|
42
|
+
val = val.slice(1, -1);
|
|
43
|
+
}
|
|
44
|
+
meta[kv[1].toLowerCase()] = val;
|
|
45
|
+
}
|
|
46
|
+
return meta;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Parse the canonical preferences.md text into structured entries.
|
|
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).
|
|
53
|
+
* Robust to a missing/garbled meta block (entry is skipped, never throws).
|
|
54
|
+
*/
|
|
55
|
+
function parsePreferences(text) {
|
|
56
|
+
if (!text || typeof text !== 'string') return [];
|
|
57
|
+
// Split on `## ` headings (entry boundary). slice(1) drops the file
|
|
58
|
+
// frontmatter + `# Preference Log` preamble before the first entry.
|
|
59
|
+
const sections = text.split(/^## /m).slice(1);
|
|
60
|
+
const entries = [];
|
|
61
|
+
for (const section of sections) {
|
|
62
|
+
const heading = section.split('\n', 1)[0].trim();
|
|
63
|
+
const meta = parseOmdMeta(section);
|
|
64
|
+
if (!meta) continue;
|
|
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();
|
|
73
|
+
entries.push({
|
|
74
|
+
heading,
|
|
75
|
+
scope: meta.scope || '',
|
|
76
|
+
status: meta.status || 'pending',
|
|
77
|
+
confidence: meta.confidence || '',
|
|
78
|
+
timestamp: tsRaw ? new Date(tsRaw).getTime() : NaN,
|
|
79
|
+
body,
|
|
80
|
+
raw: meta,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
return entries;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Count entries with `status: pending`. */
|
|
87
|
+
function countPending(text) {
|
|
88
|
+
return parsePreferences(text).filter((e) => e.status === 'pending').length;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
module.exports = { parsePreferences, parseOmdMeta, countPending };
|
|
@@ -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));
|
|
@@ -28,15 +53,17 @@ process.stdin.on('end', () => {
|
|
|
28
53
|
if (!['Edit', 'Write', 'MultiEdit'].includes(toolName)) {
|
|
29
54
|
process.exit(0);
|
|
30
55
|
}
|
|
31
|
-
|
|
32
|
-
|
|
56
|
+
// Claude Code sends snake_case `tool_input`; keep camelCase `toolInput` as
|
|
57
|
+
// a fallback for other channels / older payloads.
|
|
58
|
+
const toolInput = payload.tool_input || payload.toolInput || {};
|
|
59
|
+
const filePath = toolInput.file_path || toolInput.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)) {
|
|
33
63
|
process.exit(0);
|
|
34
64
|
}
|
|
35
65
|
|
|
36
|
-
const newText =
|
|
37
|
-
payload.toolInput?.content ||
|
|
38
|
-
payload.toolInput?.new_string ||
|
|
39
|
-
'';
|
|
66
|
+
const newText = toolInput.content || toolInput.new_string || '';
|
|
40
67
|
if (!newText) process.exit(0);
|
|
41
68
|
|
|
42
69
|
// Normalize a hex to canonical 6-char lowercase form (#abc → #aabbcc)
|
|
@@ -52,48 +79,217 @@ 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);
|
|
78
164
|
}
|
|
79
|
-
} catch {
|
|
80
|
-
// ignore
|
|
81
165
|
}
|
|
82
166
|
}
|
|
83
167
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
+
}
|
|
87
181
|
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (
|
|
207
|
+
introducedHexes.length === 0 &&
|
|
208
|
+
introducedRadii.length === 0 &&
|
|
209
|
+
introducedDurations.length === 0
|
|
210
|
+
) {
|
|
211
|
+
process.exit(0);
|
|
212
|
+
}
|
|
213
|
+
|
|
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('');
|
|
95
236
|
|
|
96
|
-
//
|
|
97
|
-
|
|
98
|
-
|
|
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
|
+
}
|
|
99
284
|
|
|
285
|
+
// PostToolUse hook contract: additionalContext must be nested under
|
|
286
|
+
// hookSpecificOutput — a top-level { additionalContext } is dropped.
|
|
287
|
+
process.stdout.write(
|
|
288
|
+
JSON.stringify({
|
|
289
|
+
hookSpecificOutput: {
|
|
290
|
+
hookEventName: 'PostToolUse',
|
|
291
|
+
additionalContext: lines.join('\n'),
|
|
292
|
+
},
|
|
293
|
+
}),
|
|
294
|
+
);
|
|
295
|
+
});
|
|
@@ -1,17 +1,20 @@
|
|
|
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
|
|
|
8
9
|
const fs = require('node:fs');
|
|
9
10
|
const path = require('node:path');
|
|
11
|
+
const { parsePreferences } = require('./lib/preferences-parser.cjs');
|
|
10
12
|
|
|
11
13
|
const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
12
14
|
const preferencesMd = path.join(projectDir, '.omd', 'preferences.md');
|
|
13
15
|
const timelineMd = path.join(projectDir, '.omd', 'timeline.md');
|
|
14
16
|
const configJson = path.join(projectDir, '.omd', 'config.json');
|
|
17
|
+
const proposalJson = path.join(projectDir, '.omd', 'foldin-proposal.json');
|
|
15
18
|
|
|
16
19
|
if (!fs.existsSync(preferencesMd)) process.exit(0);
|
|
17
20
|
|
|
@@ -34,26 +37,22 @@ try {
|
|
|
34
37
|
process.exit(0);
|
|
35
38
|
}
|
|
36
39
|
|
|
37
|
-
// Parse
|
|
38
|
-
const blocks = prefText.split(/^### /m).slice(1);
|
|
40
|
+
// Parse the CANONICAL omd:remember format (## heading + ```omd-meta block).
|
|
39
41
|
const now = Date.now();
|
|
40
42
|
const windowMs = config.recurrence_window_days * 24 * 3600 * 1000;
|
|
41
43
|
const byScope = new Map();
|
|
42
44
|
|
|
43
|
-
for (const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
const
|
|
47
|
-
const importanceMatch = /^- importance:\s*([1-5])$/m.exec(block);
|
|
48
|
-
if ((statusMatch?.[1] || 'pending') !== 'pending') continue;
|
|
49
|
-
if (!tsMatch || !scopeMatch) continue;
|
|
50
|
-
const ts = new Date(tsMatch[1]).getTime();
|
|
45
|
+
for (const entry of parsePreferences(prefText)) {
|
|
46
|
+
if (entry.status !== 'pending') continue;
|
|
47
|
+
if (!entry.scope) continue;
|
|
48
|
+
const ts = entry.timestamp;
|
|
51
49
|
if (Number.isNaN(ts) || now - ts > windowMs) continue;
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
const
|
|
55
|
-
list.
|
|
56
|
-
|
|
50
|
+
// The canonical format carries no numeric importance; derive a 1-5 weight
|
|
51
|
+
// from confidence (explicit statements weigh more than inferred).
|
|
52
|
+
const importance = entry.confidence === 'inferred' ? 2 : 4;
|
|
53
|
+
const list = byScope.get(entry.scope) || [];
|
|
54
|
+
list.push({ ts, importance, id: entry.raw.id || '', body: entry.body || '' });
|
|
55
|
+
byScope.set(entry.scope, list);
|
|
57
56
|
}
|
|
58
57
|
|
|
59
58
|
const proposals = [];
|
|
@@ -67,7 +66,18 @@ for (const [scope, entries] of byScope.entries()) {
|
|
|
67
66
|
const recency = Math.exp(-daysSince / 7);
|
|
68
67
|
const score = entries.length * importanceAvg * recency * 10;
|
|
69
68
|
if (score >= config.fold_in_score_threshold) {
|
|
70
|
-
|
|
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
|
+
});
|
|
71
81
|
}
|
|
72
82
|
}
|
|
73
83
|
|
|
@@ -79,7 +89,11 @@ const block =
|
|
|
79
89
|
`## ${ts} — fold_in_proposal\n\n` +
|
|
80
90
|
`${proposals.length} fold-in proposals ready (top: ${proposals[0].scope}, score ${proposals[0].score}).\n\n` +
|
|
81
91
|
'```json\n' +
|
|
82
|
-
JSON.stringify(
|
|
92
|
+
JSON.stringify(
|
|
93
|
+
proposals.slice(0, 5).map(({ scope, count, score }) => ({ scope, count, score })),
|
|
94
|
+
null,
|
|
95
|
+
2,
|
|
96
|
+
) +
|
|
83
97
|
'\n```\n\n';
|
|
84
98
|
|
|
85
99
|
if (!fs.existsSync(path.dirname(timelineMd))) {
|
|
@@ -91,6 +105,45 @@ if (!fs.existsSync(timelineMd)) {
|
|
|
91
105
|
fs.appendFileSync(timelineMd, block);
|
|
92
106
|
}
|
|
93
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
|
+
|
|
94
147
|
// Optional: print one-line confirmation to stdout
|
|
95
148
|
process.stdout.write(JSON.stringify({}) || '');
|
|
96
149
|
|