@monoes/monodesign 1.1.1 → 1.2.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/cli/bin/cli.js +5 -0
- package/cli/engine/browser/injected/index.mjs +25 -0
- package/cli/engine/detect-antipatterns-browser.js +356 -0
- package/cli/engine/engines/regex/detect-text.mjs +63 -0
- package/cli/engine/engines/static-html/detect-html.mjs +19 -0
- package/cli/engine/fix/css-regions.mjs +181 -0
- package/cli/engine/fix/fixers.mjs +373 -0
- package/cli/engine/fix/index.mjs +426 -0
- package/cli/engine/registry/antipatterns.mjs +49 -0
- package/cli/engine/rules/checks.mjs +292 -0
- package/package.json +2 -2
- package/skill/SKILL.src.md +1 -1
- package/skill/reference/antipatterns-catalog.md +47 -2
- package/skill/reference/critique.md +1 -1
- package/skill/reference/live.md +0 -8
- package/skill/reference/polish.md +2 -2
- package/skill/scripts/context-signals.mjs +39 -5
- package/skill/scripts/critique-storage.mjs +175 -1
- package/skill/scripts/live/session-store.mjs +5 -1
- package/src/rules.ts +60 -3
|
@@ -18,6 +18,13 @@
|
|
|
18
18
|
* node critique-storage.mjs write <slug> <snapshot-body-file>
|
|
19
19
|
* node critique-storage.mjs latest <slug>
|
|
20
20
|
* node critique-storage.mjs trend <slug> [limit]
|
|
21
|
+
* node critique-storage.mjs recall <slug> [limit]
|
|
22
|
+
*
|
|
23
|
+
* `write` also best-effort mirrors a compact record of the snapshot into
|
|
24
|
+
* monomind's persistent memory (namespace `design-critique`) so design
|
|
25
|
+
* health survives across sessions. The mirror is fire-and-forget: if the
|
|
26
|
+
* monomind CLI is not installed it is skipped silently, and it can be
|
|
27
|
+
* disabled outright with MONODESIGN_NO_MEMORY=1.
|
|
21
28
|
*
|
|
22
29
|
* Note: there is intentionally no `ignore` subcommand. ignore.md is a plain
|
|
23
30
|
* markdown file; the model reads it directly with its file-read tool. This
|
|
@@ -27,6 +34,7 @@
|
|
|
27
34
|
|
|
28
35
|
import fs from 'node:fs';
|
|
29
36
|
import path from 'node:path';
|
|
37
|
+
import { spawnSync } from 'node:child_process';
|
|
30
38
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
31
39
|
import { getCritiqueDir } from './lib/monodesign-paths.mjs';
|
|
32
40
|
|
|
@@ -178,6 +186,164 @@ export function readTrend(slug, { limit = 5, cwd = process.cwd() } = {}) {
|
|
|
178
186
|
return slice.map((file) => parseFrontmatter(fs.readFileSync(file, 'utf-8')));
|
|
179
187
|
}
|
|
180
188
|
|
|
189
|
+
// ---- Monomind memory mirror --------------------------------------------
|
|
190
|
+
|
|
191
|
+
const asFiniteNumber = (v) => {
|
|
192
|
+
const n = Number(v);
|
|
193
|
+
return Number.isFinite(n) ? n : null;
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Locate a locally installed monomind CLI bin by climbing from `cwd`.
|
|
198
|
+
* Returns null when none is installed — the caller then tries
|
|
199
|
+
* `npx --no-install`, which also fails fast (and silently) when absent.
|
|
200
|
+
*/
|
|
201
|
+
function resolveMonomindBin(cwd) {
|
|
202
|
+
const name = process.platform === 'win32' ? 'monomind.cmd' : 'monomind';
|
|
203
|
+
let dir = path.resolve(cwd);
|
|
204
|
+
for (;;) {
|
|
205
|
+
const bin = path.join(dir, 'node_modules', '.bin', name);
|
|
206
|
+
try {
|
|
207
|
+
fs.accessSync(bin, fs.constants.X_OK);
|
|
208
|
+
return bin;
|
|
209
|
+
} catch { /* keep climbing */ }
|
|
210
|
+
const parent = path.dirname(dir);
|
|
211
|
+
if (parent === dir) return null;
|
|
212
|
+
dir = parent;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Best-effort mirror of a snapshot's structured metadata into monomind's
|
|
218
|
+
* persistent memory (namespace `design-critique`, key `<project>-<slug>`,
|
|
219
|
+
* upserted), so design health is queryable across sessions via
|
|
220
|
+
* `monomind memory search`. Never throws and never fails the critique flow:
|
|
221
|
+
* if the monomind CLI is unavailable the call is skipped silently.
|
|
222
|
+
* Kill-switch: MONODESIGN_NO_MEMORY=1.
|
|
223
|
+
*/
|
|
224
|
+
export function mirrorToMemory({ slug, meta = {}, filePath, cwd = process.cwd(), now = new Date(), env = process.env }) {
|
|
225
|
+
if (!slug) return { mirrored: false, reason: 'no-slug' };
|
|
226
|
+
if (env.MONODESIGN_NO_MEMORY === '1') return { mirrored: false, reason: 'disabled' };
|
|
227
|
+
try {
|
|
228
|
+
const record = {
|
|
229
|
+
score: asFiniteNumber(meta.total_score ?? meta.score),
|
|
230
|
+
p0: asFiniteNumber(meta.p0_count ?? meta.p0),
|
|
231
|
+
p1: asFiniteNumber(meta.p1_count ?? meta.p1),
|
|
232
|
+
date: now.toISOString(),
|
|
233
|
+
slug,
|
|
234
|
+
path: filePath ? path.relative(cwd, filePath) : null,
|
|
235
|
+
};
|
|
236
|
+
const project = kebab(path.basename(path.resolve(cwd))) || 'project';
|
|
237
|
+
const key = `${project}-${slug}`;
|
|
238
|
+
const argv = [
|
|
239
|
+
'memory', 'store',
|
|
240
|
+
'--key', key,
|
|
241
|
+
'--value', JSON.stringify(record),
|
|
242
|
+
'--namespace', 'design-critique',
|
|
243
|
+
'--upsert',
|
|
244
|
+
];
|
|
245
|
+
const bin = resolveMonomindBin(cwd);
|
|
246
|
+
const opts = { cwd, env, stdio: 'ignore', timeout: 15000 };
|
|
247
|
+
const run = bin
|
|
248
|
+
? spawnSync(bin, argv, opts)
|
|
249
|
+
: spawnSync('npx', ['--no-install', 'monomind', ...argv], { ...opts, shell: process.platform === 'win32' });
|
|
250
|
+
if (run.error || run.status !== 0) return { mirrored: false, reason: 'cli-unavailable' };
|
|
251
|
+
return { mirrored: true, key };
|
|
252
|
+
} catch {
|
|
253
|
+
return { mirrored: false, reason: 'error' };
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ---- Recall (latest + trend + open items, one compact block) -----------
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Extract open issue titles tagged [P0] / [P1] from a snapshot body.
|
|
261
|
+
* Matches the critique report's "**[P?] What**: ..." lines, but tolerates
|
|
262
|
+
* loose formatting: any line containing `[P0]`/`[P1]` counts, with the
|
|
263
|
+
* title taken from the text after the tag (markdown emphasis stripped).
|
|
264
|
+
*/
|
|
265
|
+
export function extractIssueLines(body, { max = 8 } = {}) {
|
|
266
|
+
const issues = { p0: [], p1: [] };
|
|
267
|
+
if (!body || typeof body !== 'string') return issues;
|
|
268
|
+
// Drop frontmatter so metadata lines never masquerade as issues.
|
|
269
|
+
const text = body.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, '');
|
|
270
|
+
for (const line of text.split(/\r?\n/)) {
|
|
271
|
+
const m = line.match(/\[(P0|P1)\]\s*(.*)/i);
|
|
272
|
+
if (!m) continue;
|
|
273
|
+
let title = m[2];
|
|
274
|
+
// "**[P0] Title**: explanation" → keep just the title.
|
|
275
|
+
const boldColon = title.indexOf('**:');
|
|
276
|
+
if (boldColon !== -1) title = title.slice(0, boldColon);
|
|
277
|
+
title = title.replace(/\*+/g, '').replace(/^[-–—\s]+/, '').replace(/[:\s]+$/, '').trim();
|
|
278
|
+
if (!title) continue;
|
|
279
|
+
const bucket = m[1].toUpperCase() === 'P0' ? issues.p0 : issues.p1;
|
|
280
|
+
if (bucket.length < max && !bucket.includes(title)) bucket.push(title);
|
|
281
|
+
}
|
|
282
|
+
return issues;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Direction of the last (up to) 3 finite scores: improving | declining |
|
|
287
|
+
* flat, or null when fewer than 2 scores exist.
|
|
288
|
+
*/
|
|
289
|
+
export function trendDirection(scores) {
|
|
290
|
+
const s = (scores || []).map(asFiniteNumber).filter((n) => n !== null).slice(-3);
|
|
291
|
+
if (s.length < 2) return null;
|
|
292
|
+
if (s[s.length - 1] > s[0]) return 'improving';
|
|
293
|
+
if (s[s.length - 1] < s[0]) return 'declining';
|
|
294
|
+
return 'flat';
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Combined latest + trend + open-issue view for `slug`, or null when no
|
|
299
|
+
* snapshot exists. This backs the `recall` subcommand that polish/critique
|
|
300
|
+
* consume instead of separate latest+trend calls.
|
|
301
|
+
*/
|
|
302
|
+
export function readRecall(slug, { limit = 5, cwd = process.cwd() } = {}) {
|
|
303
|
+
const latest = readLatestSnapshot(slug, { cwd });
|
|
304
|
+
if (!latest) return null;
|
|
305
|
+
const trend = readTrend(slug, { limit, cwd });
|
|
306
|
+
const scores = trend.map((t) => asFiniteNumber(t.total_score ?? t.score));
|
|
307
|
+
return {
|
|
308
|
+
slug,
|
|
309
|
+
latest,
|
|
310
|
+
trend,
|
|
311
|
+
scores,
|
|
312
|
+
direction: trendDirection(scores),
|
|
313
|
+
issues: extractIssueLines(latest.body),
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Render a recall result as a compact markdown block. */
|
|
318
|
+
export function formatRecall(recall, { cwd = process.cwd() } = {}) {
|
|
319
|
+
const { slug, latest, trend, scores, direction, issues } = recall;
|
|
320
|
+
const meta = latest.meta;
|
|
321
|
+
const score = asFiniteNumber(meta.total_score ?? meta.score);
|
|
322
|
+
const p0 = asFiniteNumber(meta.p0_count ?? meta.p0);
|
|
323
|
+
const p1 = asFiniteNumber(meta.p1_count ?? meta.p1);
|
|
324
|
+
const lines = [`## Design health: \`${slug}\``];
|
|
325
|
+
lines.push(
|
|
326
|
+
`- Latest score: ${score !== null ? `${score}/40` : 'n/a'}`
|
|
327
|
+
+ ` (P0: ${p0 ?? '?'}, P1: ${p1 ?? '?'})`
|
|
328
|
+
+ (meta.timestamp ? ` — ${meta.timestamp}` : ''),
|
|
329
|
+
);
|
|
330
|
+
const arrows = scores.map((n) => (n === null ? '?' : n)).join(' → ');
|
|
331
|
+
lines.push(`- Trend (last ${trend.length}): ${arrows}${direction ? ` (${direction})` : ''}`);
|
|
332
|
+
if (issues.p0.length) {
|
|
333
|
+
lines.push('- Open P0:');
|
|
334
|
+
for (const t of issues.p0) lines.push(` - ${t}`);
|
|
335
|
+
}
|
|
336
|
+
if (issues.p1.length) {
|
|
337
|
+
lines.push('- Open P1:');
|
|
338
|
+
for (const t of issues.p1) lines.push(` - ${t}`);
|
|
339
|
+
}
|
|
340
|
+
if (!issues.p0.length && !issues.p1.length) {
|
|
341
|
+
lines.push('- No open P0/P1 lines found in the latest snapshot.');
|
|
342
|
+
}
|
|
343
|
+
lines.push(`- Snapshot: ${path.relative(cwd, latest.path)}`);
|
|
344
|
+
return lines.join('\n');
|
|
345
|
+
}
|
|
346
|
+
|
|
181
347
|
// ---- CLI ---------------------------------------------------------------
|
|
182
348
|
|
|
183
349
|
function main(argv) {
|
|
@@ -202,6 +368,8 @@ function main(argv) {
|
|
|
202
368
|
try { meta = JSON.parse(metaArg); } catch { /* ignore */ }
|
|
203
369
|
}
|
|
204
370
|
const out = writeSnapshot({ slug, meta, body: raw });
|
|
371
|
+
// Best-effort cross-session mirror; must never break the critique flow.
|
|
372
|
+
mirrorToMemory({ slug, meta, filePath: out });
|
|
205
373
|
process.stdout.write(`${out}\n`);
|
|
206
374
|
return;
|
|
207
375
|
}
|
|
@@ -216,8 +384,14 @@ function main(argv) {
|
|
|
216
384
|
process.stdout.write(JSON.stringify(rows, null, 2) + '\n');
|
|
217
385
|
return;
|
|
218
386
|
}
|
|
387
|
+
case 'recall': {
|
|
388
|
+
const recall = readRecall(args[0], { limit: args[1] ? Number(args[1]) : 5 });
|
|
389
|
+
if (!recall) { process.exit(2); }
|
|
390
|
+
process.stdout.write(`${formatRecall(recall)}\n`);
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
219
393
|
default:
|
|
220
|
-
process.stderr.write('usage: critique-storage.mjs <slug|write|latest|trend> [args]\n');
|
|
394
|
+
process.stderr.write('usage: critique-storage.mjs <slug|write|latest|trend|recall> [args]\n');
|
|
221
395
|
process.exit(1);
|
|
222
396
|
}
|
|
223
397
|
}
|
|
@@ -2,7 +2,11 @@ import fs from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { getLegacyLiveSessionsDir, getLiveSessionsDir } from '../lib/monodesign-paths.mjs';
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
// steer_done is terminal too: a steer session has no pending work after the
|
|
6
|
+
// agent's reply, and leaving it "active" makes live-status suggest recovery
|
|
7
|
+
// for finished steers forever (the browser's trailing steer checkpoint would
|
|
8
|
+
// also re-stamp phase "steer"; terminal phases ignore late checkpoints).
|
|
9
|
+
const COMPLETED_PHASES = new Set(['completed', 'discarded', 'steer_done']);
|
|
6
10
|
|
|
7
11
|
export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {}) {
|
|
8
12
|
const rootDir = getLiveSessionsDir(cwd);
|
package/src/rules.ts
CHANGED
|
@@ -1,20 +1,58 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Shared design rules — the invariants every monodesign generation must satisfy.
|
|
3
3
|
* These mirror the "Absolute bans" and "Shared design laws" in the skill.
|
|
4
|
+
*
|
|
5
|
+
* Where a ban has a corresponding mechanical detector rule in the engine
|
|
6
|
+
* registry (cli/engine/registry/antipatterns.mjs), its id and description are
|
|
7
|
+
* pulled from that registry at load time instead of being hand-copied here —
|
|
8
|
+
* one source of truth, no drift between "what generation avoids" and "what
|
|
9
|
+
* detection flags". Bans with no `engineRuleId` are composition/judgment
|
|
10
|
+
* calls the detector cannot mechanically check (holistic layout patterns);
|
|
11
|
+
* they exist here for generation-time guidance only.
|
|
4
12
|
*/
|
|
5
13
|
|
|
6
|
-
|
|
14
|
+
// Typed as plain string so tsc emits a true dynamic import instead of trying
|
|
15
|
+
// to type-resolve the untyped .mjs engine module (matches antipatterns.ts).
|
|
16
|
+
const REGISTRY_SPECIFIER: string = '../cli/engine/registry/antipatterns.mjs';
|
|
17
|
+
|
|
18
|
+
interface EngineRuleEntry {
|
|
19
|
+
id: string;
|
|
20
|
+
name: string;
|
|
21
|
+
description: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
let registryPromise: Promise<EngineRuleEntry[] | null> | undefined;
|
|
25
|
+
|
|
26
|
+
async function loadRegistry(): Promise<EngineRuleEntry[] | null> {
|
|
27
|
+
registryPromise ??= import(REGISTRY_SPECIFIER).then(
|
|
28
|
+
(mod) => (mod as { ANTIPATTERNS: EngineRuleEntry[] }).ANTIPATTERNS,
|
|
29
|
+
() => null,
|
|
30
|
+
);
|
|
31
|
+
return registryPromise;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface AbsoluteBan {
|
|
35
|
+
id: string;
|
|
36
|
+
rule: string;
|
|
37
|
+
description: string;
|
|
38
|
+
/** Engine registry rule id this ban is mechanically detected by, if any. */
|
|
39
|
+
engineRuleId?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const ABSOLUTE_BANS_STATIC: AbsoluteBan[] = [
|
|
7
43
|
{
|
|
8
|
-
id: 'side-
|
|
44
|
+
id: 'side-tab',
|
|
9
45
|
rule: 'No side-stripe borders',
|
|
10
46
|
description:
|
|
11
47
|
'border-left or border-right > 1px as a colored accent on cards/list items/callouts is never intentional. Rewrite with full borders, background tints, leading numbers/icons, or nothing.',
|
|
48
|
+
engineRuleId: 'side-tab',
|
|
12
49
|
},
|
|
13
50
|
{
|
|
14
51
|
id: 'gradient-text',
|
|
15
52
|
rule: 'No gradient text',
|
|
16
53
|
description:
|
|
17
54
|
'background-clip:text with a gradient is decorative and never meaningful. Use a single solid color; emphasis via weight or size.',
|
|
55
|
+
engineRuleId: 'gradient-text',
|
|
18
56
|
},
|
|
19
57
|
{
|
|
20
58
|
id: 'glassmorphism-default',
|
|
@@ -40,7 +78,26 @@ export const ABSOLUTE_BANS = [
|
|
|
40
78
|
description:
|
|
41
79
|
'Modals are usually laziness. Exhaust inline / progressive disclosure alternatives first.',
|
|
42
80
|
},
|
|
43
|
-
]
|
|
81
|
+
];
|
|
82
|
+
|
|
83
|
+
/** Static ban list — id/rule/description as authored here (may drift from the engine registry's wording for bans that also have a detector rule). */
|
|
84
|
+
export const ABSOLUTE_BANS = ABSOLUTE_BANS_STATIC;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Same ban list, with descriptions for detector-backed bans (`engineRuleId`
|
|
88
|
+
* set) replaced by the live text from the engine registry. Falls back to the
|
|
89
|
+
* static description when the registry can't be loaded (e.g. package not
|
|
90
|
+
* fully installed) or the referenced rule id is missing.
|
|
91
|
+
*/
|
|
92
|
+
export async function getAbsoluteBans(): Promise<AbsoluteBan[]> {
|
|
93
|
+
const registry = await loadRegistry();
|
|
94
|
+
if (!registry) return ABSOLUTE_BANS_STATIC;
|
|
95
|
+
const byId = new Map(registry.map((r) => [r.id, r]));
|
|
96
|
+
return ABSOLUTE_BANS_STATIC.map((ban) => {
|
|
97
|
+
const engineRule = ban.engineRuleId ? byId.get(ban.engineRuleId) : undefined;
|
|
98
|
+
return engineRule ? { ...ban, description: engineRule.description } : ban;
|
|
99
|
+
});
|
|
100
|
+
}
|
|
44
101
|
|
|
45
102
|
export const COLOR_STRATEGIES = ['restrained', 'committed', 'full-palette', 'drenched'] as const;
|
|
46
103
|
export type ColorStrategy = (typeof COLOR_STRATEGIES)[number];
|