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.
@@ -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/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));
@@ -28,15 +53,17 @@ process.stdin.on('end', () => {
28
53
  if (!['Edit', 'Write', 'MultiEdit'].includes(toolName)) {
29
54
  process.exit(0);
30
55
  }
31
- const filePath = payload.toolInput?.file_path || payload.toolInput?.filePath || '';
32
- if (!/\.(tsx|jsx|ts|js|css|scss)$/i.test(filePath)) {
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 (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);
78
164
  }
79
- } catch {
80
- // ignore
81
165
  }
82
166
  }
83
167
 
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);
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 lines = [
89
- '',
90
- 'OMD WATCH:',
91
- `방금 ${path.basename(filePath)} DESIGN.md에 없는 색이 들어갔어요: ${introduced.slice(0, 3).join(', ')}`,
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
- // Hook contract: write to additionalContext via JSON stdout
97
- process.stdout.write(JSON.stringify({ additionalContext: lines.join('\n') }));
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 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
 
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 pending entries (very rough)
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 block of blocks) {
44
- const tsMatch = /^- ts:\s*(.+)$/m.exec(block);
45
- const scopeMatch = /^- scope:\s*(.+)$/m.exec(block);
46
- const statusMatch = /^- status:\s*(\w+)$/m.exec(block);
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
- const scope = scopeMatch[1];
53
- const importance = parseInt(importanceMatch?.[1] || '3', 10);
54
- const list = byScope.get(scope) || [];
55
- list.push({ ts, importance });
56
- byScope.set(scope, list);
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
- 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
+ });
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(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
+ ) +
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