@monoes/monodesign 1.2.20 → 1.2.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/README.md +2 -2
  2. package/cli/engine/cli/main.mjs +21 -1
  3. package/cli/engine/design-system-checks.mjs +381 -0
  4. package/cli/engine/design-system-parse.mjs +433 -0
  5. package/cli/engine/design-system.mjs +20 -736
  6. package/cli/engine/detect-antipatterns-browser.js +28 -5
  7. package/cli/engine/detect-antipatterns.mjs +1 -0
  8. package/cli/engine/engines/regex/detect-text-matchers.mjs +371 -0
  9. package/cli/engine/engines/regex/detect-text-run.mjs +186 -0
  10. package/cli/engine/engines/regex/detect-text.mjs +11 -523
  11. package/cli/engine/engines/static-html/css-cascade-color.mjs +219 -0
  12. package/cli/engine/engines/static-html/css-cascade-static-data.mjs +138 -0
  13. package/cli/engine/engines/static-html/css-cascade-static-dom.mjs +132 -0
  14. package/cli/engine/engines/static-html/css-cascade-static-parse.mjs +393 -0
  15. package/cli/engine/engines/static-html/css-cascade.mjs +24 -837
  16. package/cli/engine/engines/static-html/detect-html.mjs +6 -1
  17. package/cli/engine/findings-group.mjs +94 -0
  18. package/cli/engine/fix/index.mjs +1 -26
  19. package/cli/engine/fix/unified-diff.mjs +107 -0
  20. package/cli/engine/rules/checks-background.mjs +17 -3
  21. package/cli/engine/rules/checks-element.mjs +2 -0
  22. package/cli/engine/rules/checks-pure.mjs +9 -2
  23. package/cli/lib/monodesign-config-consent.mjs +93 -0
  24. package/cli/lib/monodesign-config-core.mjs +37 -0
  25. package/cli/lib/monodesign-config-detection.mjs +133 -0
  26. package/cli/lib/monodesign-config-filter.mjs +198 -0
  27. package/cli/lib/monodesign-config-ignore-value.mjs +212 -0
  28. package/cli/lib/monodesign-config.mjs +21 -623
  29. package/package.json +2 -2
  30. package/skill/scripts/context-fields.mjs +65 -0
  31. package/skill/scripts/context-resolve.mjs +425 -0
  32. package/skill/scripts/context-update.mjs +151 -0
  33. package/skill/scripts/context-utils.mjs +68 -0
  34. package/skill/scripts/context-workspace.mjs +245 -0
  35. package/skill/scripts/context.mjs +17 -891
  36. package/skill/scripts/hook-admin-config.mjs +152 -0
  37. package/skill/scripts/hook-admin-ignore.mjs +131 -0
  38. package/skill/scripts/hook-admin-manifest.mjs +292 -0
  39. package/skill/scripts/hook-admin.mjs +8 -541
  40. package/skill/scripts/hook-lib-cache.mjs +145 -0
  41. package/skill/scripts/hook-lib-config.mjs +292 -0
  42. package/skill/scripts/hook-lib-detector.mjs +72 -0
  43. package/skill/scripts/hook-lib-events.mjs +182 -0
  44. package/skill/scripts/hook-lib-findings.mjs +201 -0
  45. package/skill/scripts/hook-lib-ignore.mjs +205 -0
  46. package/skill/scripts/hook-lib-render.mjs +273 -0
  47. package/skill/scripts/hook-lib-run.mjs +281 -0
  48. package/skill/scripts/hook-lib-scan-targets.mjs +128 -0
  49. package/skill/scripts/hook-lib.mjs +71 -1747
  50. package/skill/scripts/lib/design-parser-colors.mjs +182 -0
  51. package/skill/scripts/lib/design-parser-extract.mjs +333 -0
  52. package/skill/scripts/lib/design-parser-sections.mjs +214 -0
  53. package/skill/scripts/lib/design-parser-yaml.mjs +135 -0
  54. package/skill/scripts/lib/design-parser.mjs +10 -783
  55. package/skill/scripts/live/manual-apply-chunk.mjs +151 -0
  56. package/skill/scripts/live/manual-apply-compact.mjs +122 -0
  57. package/skill/scripts/live/manual-apply-evidence.mjs +115 -0
  58. package/skill/scripts/live/manual-apply-log.mjs +179 -0
  59. package/skill/scripts/live/manual-apply-transaction.mjs +122 -0
  60. package/skill/scripts/live/manual-apply.mjs +65 -640
  61. package/skill/scripts/live/svelte-component-accept.mjs +248 -0
  62. package/skill/scripts/live/svelte-component-css.mjs +230 -0
  63. package/skill/scripts/live/svelte-component-markup.mjs +104 -0
  64. package/skill/scripts/live/svelte-component-scaffold.mjs +118 -0
  65. package/skill/scripts/live/svelte-component-session.mjs +158 -0
  66. package/skill/scripts/live/svelte-component.mjs +32 -792
  67. package/skill/scripts/live-accept-mutate.mjs +152 -0
  68. package/skill/scripts/live-accept-parse.mjs +370 -0
  69. package/skill/scripts/live-accept-scrub.mjs +59 -0
  70. package/skill/scripts/live-accept-search.mjs +58 -0
  71. package/skill/scripts/live-accept.mjs +15 -631
  72. package/skill/scripts/live-commit-manual-edits-paths.mjs +27 -0
  73. package/skill/scripts/live-commit-manual-edits-repair.mjs +166 -0
  74. package/skill/scripts/live-commit-manual-edits-rollback.mjs +231 -0
  75. package/skill/scripts/live-commit-manual-edits-utils.mjs +167 -0
  76. package/skill/scripts/live-commit-manual-edits-verify.mjs +356 -0
  77. package/skill/scripts/live-commit-manual-edits.mjs +24 -881
  78. package/skill/scripts/live-copy-edit-agent.mjs +16 -551
  79. package/skill/scripts/live-copy-edit-compact.mjs +137 -0
  80. package/skill/scripts/live-copy-edit-post-apply.mjs +175 -0
  81. package/skill/scripts/live-copy-edit-runners.mjs +297 -0
  82. package/skill/scripts/live-inject-csp.mjs +132 -0
  83. package/skill/scripts/live-inject-files.mjs +229 -0
  84. package/skill/scripts/live-inject-tag.mjs +114 -0
  85. package/skill/scripts/live-inject.mjs +13 -440
  86. package/skill/scripts/live-server-poll.mjs +232 -0
  87. package/skill/scripts/live-server-routes.mjs +449 -0
  88. package/skill/scripts/live-server-state.mjs +361 -0
  89. package/skill/scripts/live-server.mjs +10 -946
  90. package/skill/scripts/live-wrap-css.mjs +132 -0
  91. package/skill/scripts/live-wrap-manual-edit.mjs +153 -0
  92. package/skill/scripts/live-wrap-search.mjs +247 -0
  93. package/skill/scripts/live-wrap.mjs +21 -484
  94. package/skill/scripts/palette-seeds.mjs +401 -0
  95. package/skill/scripts/palette.mjs +1 -395
@@ -0,0 +1,65 @@
1
+ /**
2
+ * PRODUCT.md field extraction for context.mjs: pulling the `## Register` and
3
+ * `## Platform` section values out of the loaded PRODUCT.md body.
4
+ *
5
+ * Split out of context.mjs. See that file for context.
6
+ */
7
+ import { escapeRegExp } from './context-utils.mjs';
8
+
9
+ /**
10
+ * Read the first non-empty line under a bare `## <heading>` section of
11
+ * PRODUCT.md (e.g. `## Register`, `## Platform`). Returns null when the
12
+ * section is absent. The heading match is exact (`\s*$`) so near-miss
13
+ * headings like `## Register guidelines` don't shadow the real field.
14
+ */
15
+ export function extractSectionValue(product, heading) {
16
+ if (!product) return null;
17
+ const headingRe = new RegExp(`^##\\s+${escapeRegExp(heading)}\\s*$`, 'i');
18
+ const lines = product.split('\n');
19
+ for (let i = 0; i < lines.length; i++) {
20
+ if (headingRe.test(lines[i].trim())) {
21
+ for (let j = i + 1; j < lines.length; j++) {
22
+ const next = lines[j].trim();
23
+ // A new heading before any value means the section is empty.
24
+ if (/^#{1,6}\s/.test(next)) return null;
25
+ if (next) return next;
26
+ }
27
+ }
28
+ }
29
+ return null;
30
+ }
31
+
32
+ /**
33
+ * Pull the register (`brand` or `product`) out of PRODUCT.md by looking
34
+ * for a `## Register` section and reading the first non-empty line that
35
+ * follows it. Returns null when the file is legacy / register-less.
36
+ */
37
+ export function extractRegister(product) {
38
+ const word = (extractSectionValue(product, 'Register') || '').toLowerCase();
39
+ return word === 'brand' || word === 'product' ? word : null;
40
+ }
41
+
42
+ /**
43
+ * Pull the platform (`web`, `ios`, `android`, or `adaptive`) out of PRODUCT.md
44
+ * by looking for a `## Platform` section and reading the first non-empty line
45
+ * that follows it. `adaptive` is for cross-platform apps (Flutter, React
46
+ * Native) that ship both iOS and Android from one codebase; a line that names
47
+ * both targets (e.g. `ios, android`) is also read as `adaptive`. Returns null
48
+ * when the file is legacy / platform-less, which the skill treats as `web`
49
+ * (the default the general rules already assume).
50
+ */
51
+ export function extractPlatform(product) {
52
+ const value = (extractSectionValue(product, 'Platform') || '').toLowerCase();
53
+ if (!value) return null;
54
+ if (value === 'web' || value === 'ios' || value === 'android' || value === 'adaptive') return value;
55
+ // A short list naming both native targets (`ios, android`, `ios and
56
+ // android`) = adaptive. Only list separators and the two platform words may
57
+ // appear; anything else (prose, negations) is unrecognized and falls
58
+ // through to the CLI's WARNING path.
59
+ const tokens = value.split(/[\s,+&/]+/).filter(t => t && t !== 'and');
60
+ if (tokens.length >= 2 && tokens.every(t => t === 'ios' || t === 'android')
61
+ && tokens.includes('ios') && tokens.includes('android')) {
62
+ return 'adaptive';
63
+ }
64
+ return null;
65
+ }
@@ -0,0 +1,425 @@
1
+ /**
2
+ * Project/context resolution for context.mjs: locating PRODUCT.md/DESIGN.md
3
+ * for the active project, walking up to find a monorepo root, and (for the
4
+ * CLI's target-selection prompt) discovering candidate child projects.
5
+ *
6
+ * Split out of context.mjs. See that file for context.
7
+ */
8
+ import fs from 'node:fs';
9
+ import os from 'node:os';
10
+ import path from 'node:path';
11
+ import {
12
+ DESIGN_NAMES,
13
+ FALLBACK_DIRS,
14
+ MONOREPO_FALLBACK_PROJECT_DIRS,
15
+ MONOREPO_MARKER_FILES,
16
+ PRODUCT_NAMES,
17
+ WORKSPACE_DISCOVERY_IGNORED_DIRS,
18
+ firstExisting,
19
+ hasTargetOption,
20
+ isPathInside,
21
+ isPathInsideOrEqual,
22
+ safeRead,
23
+ } from './context-utils.mjs';
24
+ import {
25
+ isExcludedByWorkspacePattern,
26
+ normalizeWorkspacePattern,
27
+ readWorkspacePatterns,
28
+ resolveWorkspaceProjectRoot,
29
+ segmentMatches,
30
+ } from './context-workspace.mjs';
31
+
32
+ export function resolveContextDir(cwd = process.cwd(), options = {}) {
33
+ return resolveContext(cwd, options).contextDir;
34
+ }
35
+
36
+ export function loadContext(cwd = process.cwd(), options = {}) {
37
+ const resolved = resolveContext(cwd, options);
38
+ const absCwd = path.resolve(cwd);
39
+ const productPath = resolved.productPath;
40
+ const designPath = resolved.designPath;
41
+ const product = productPath ? safeRead(productPath) : null;
42
+ const design = designPath ? safeRead(designPath) : null;
43
+ return {
44
+ hasProduct: !!product,
45
+ product,
46
+ productPath: productPath ? path.relative(absCwd, productPath).split(path.sep).join('/') : null,
47
+ hasDesign: !!design,
48
+ design,
49
+ designPath: designPath ? path.relative(absCwd, designPath).split(path.sep).join('/') : null,
50
+ contextDir: resolved.contextDir,
51
+ productContextDir: productPath ? path.dirname(productPath) : null,
52
+ designContextDir: designPath ? path.dirname(designPath) : null,
53
+ projectRoot: resolved.projectRoot,
54
+ repoRoot: resolved.repoRoot,
55
+ isMonorepo: resolved.isMonorepo,
56
+ };
57
+ }
58
+
59
+ function resolveContext(cwd = process.cwd(), options = {}) {
60
+ const absCwd = path.resolve(cwd);
61
+ const project = resolveProject(absCwd, options);
62
+ const projectContextDir = resolveLocalContextDir(project.projectRoot);
63
+ // Per-file inheritance from the repo root whenever the active project is
64
+ // nested below it: monorepo workspace children and explicit-target nested
65
+ // products in ordinary repos behave the same way.
66
+ const rootContextDir = project.repoRoot !== project.projectRoot
67
+ ? resolveLocalContextDir(project.repoRoot)
68
+ : null;
69
+
70
+ let productPath =
71
+ (projectContextDir ? firstExisting(projectContextDir, PRODUCT_NAMES) : null)
72
+ || (rootContextDir ? firstExisting(rootContextDir, PRODUCT_NAMES) : null);
73
+ let designPath =
74
+ (projectContextDir ? firstExisting(projectContextDir, DESIGN_NAMES) : null)
75
+ || (rootContextDir ? firstExisting(rootContextDir, DESIGN_NAMES) : null);
76
+
77
+ let envContextDir = null;
78
+ if (!productPath && !designPath) {
79
+ envContextDir = resolveEnvContextDir(absCwd);
80
+ if (envContextDir) {
81
+ productPath = firstExisting(envContextDir, PRODUCT_NAMES);
82
+ designPath = firstExisting(envContextDir, DESIGN_NAMES);
83
+ }
84
+ }
85
+
86
+ return {
87
+ contextDir: productPath
88
+ ? path.dirname(productPath)
89
+ : designPath
90
+ ? path.dirname(designPath)
91
+ : envContextDir || project.projectRoot,
92
+ productPath,
93
+ designPath,
94
+ projectRoot: project.projectRoot,
95
+ repoRoot: project.repoRoot,
96
+ isMonorepo: project.isMonorepo,
97
+ targetDir: project.targetDir,
98
+ };
99
+ }
100
+
101
+ export function resolveProjectRoot(cwd = process.cwd(), options = {}) {
102
+ return resolveProject(cwd, options).projectRoot;
103
+ }
104
+
105
+ export function resolveTargetSelection(cwd = process.cwd(), options = {}) {
106
+ if (hasTargetOption(options)) return null;
107
+ const project = resolveProject(cwd);
108
+ if (
109
+ !project.isMonorepo
110
+ || !project.projectRoot
111
+ || !project.repoRoot
112
+ || path.resolve(project.projectRoot) !== path.resolve(project.repoRoot)
113
+ ) {
114
+ return null;
115
+ }
116
+ const targetCandidates = discoverTargetCandidates(project.repoRoot);
117
+ // No discoverable child apps (e.g. `workspaces: ["."]`, a root-only workspace,
118
+ // or a marker file with no apps/packages children): there is nothing to choose,
119
+ // so treat the repo root as the active project rather than blocking on an empty
120
+ // selection prompt that the user cannot answer.
121
+ if (targetCandidates.length === 0) return null;
122
+ return {
123
+ targetPath: null,
124
+ projectRoot: project.projectRoot,
125
+ repoRoot: project.repoRoot,
126
+ targetCandidates,
127
+ };
128
+ }
129
+
130
+ function resolveProject(cwd = process.cwd(), options = {}) {
131
+ const absCwd = path.resolve(cwd);
132
+ const targetDir = resolveTargetDir(absCwd, options);
133
+ let repoRoot = findMonorepoRoot(targetDir);
134
+ if (!repoRoot && targetDir !== absCwd) {
135
+ const cwdRepoRoot = findMonorepoRoot(absCwd);
136
+ if (cwdRepoRoot && isPathInside(targetDir, cwdRepoRoot)) {
137
+ repoRoot = cwdRepoRoot;
138
+ }
139
+ }
140
+ if (!repoRoot) {
141
+ return {
142
+ targetDir,
143
+ projectRoot: nearestTargetContextRoot(absCwd, targetDir) || absCwd,
144
+ repoRoot: absCwd,
145
+ isMonorepo: false,
146
+ };
147
+ }
148
+ return {
149
+ targetDir,
150
+ projectRoot: resolveWorkspaceProjectRoot(repoRoot, targetDir) || repoRoot,
151
+ repoRoot,
152
+ isMonorepo: true,
153
+ };
154
+ }
155
+
156
+ function resolveLocalContextDir(root) {
157
+ if (firstExisting(root, [...PRODUCT_NAMES, ...DESIGN_NAMES])) {
158
+ return root;
159
+ }
160
+ for (const rel of FALLBACK_DIRS) {
161
+ const candidate = path.resolve(root, rel);
162
+ if (firstExisting(candidate, [...PRODUCT_NAMES, ...DESIGN_NAMES])) {
163
+ return candidate;
164
+ }
165
+ }
166
+ return null;
167
+ }
168
+
169
+ function resolveEnvContextDir(cwd) {
170
+ const envDir = process.env.MONODESIGN_CONTEXT_DIR;
171
+ if (!envDir?.trim()) return null;
172
+ const trimmed = envDir.trim();
173
+ return path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed);
174
+ }
175
+
176
+ function resolveTargetDir(cwd, options = {}) {
177
+ const targetPath = options && typeof options === 'object' ? options.targetPath : null;
178
+ if (!targetPath || !String(targetPath).trim()) return cwd;
179
+ const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
180
+ try {
181
+ const stat = fs.statSync(abs);
182
+ return stat.isDirectory() ? abs : path.dirname(abs);
183
+ } catch {
184
+ return path.extname(abs) ? path.dirname(abs) : abs;
185
+ }
186
+ }
187
+
188
+ function findMonorepoRoot(startDir) {
189
+ let dir = path.resolve(startDir);
190
+ const homeDir = path.resolve(os.homedir());
191
+ while (true) {
192
+ if (dir === homeDir) return null;
193
+ // isMonorepoRoot is checked before hasGitBoundary on purpose: a workspace
194
+ // root that also carries its own .git is still recognized. The trade-off is
195
+ // deliberate — a directory with a monorepo *marker* but no workspace patterns
196
+ // and no apps/packages children is not a monorepo root, so its .git stops
197
+ // traversal and a further-up root is not searched. The nested .git is treated
198
+ // as an independent project boundary, which is the intended isolation.
199
+ if (isMonorepoRoot(dir)) return dir;
200
+ if (hasGitBoundary(dir)) return null;
201
+ const parent = path.dirname(dir);
202
+ if (parent === dir) return null;
203
+ dir = parent;
204
+ }
205
+ }
206
+
207
+ function isMonorepoRoot(dir) {
208
+ if (readWorkspacePatterns(dir).some((pattern) => !normalizeWorkspacePattern(pattern).startsWith('!'))) return true;
209
+ if (!MONOREPO_MARKER_FILES.some((file) => fs.existsSync(path.join(dir, file)))) return false;
210
+ return hasFallbackWorkspaceChildren(dir);
211
+ }
212
+
213
+ function hasGitBoundary(dir) {
214
+ return fs.existsSync(path.join(dir, '.git'));
215
+ }
216
+
217
+ function hasFallbackWorkspaceChildren(dir) {
218
+ for (const name of MONOREPO_FALLBACK_PROJECT_DIRS) {
219
+ const base = path.join(dir, name);
220
+ let entries;
221
+ try {
222
+ entries = fs.readdirSync(base, { withFileTypes: true });
223
+ } catch {
224
+ continue;
225
+ }
226
+ if (entries.some((entry) => entry.isDirectory() && !isIgnoredWorkspaceDiscoveryDir(entry.name))) return true;
227
+ }
228
+ return false;
229
+ }
230
+
231
+ function discoverTargetCandidates(repoRoot) {
232
+ const roots = new Map();
233
+ const patterns = readWorkspacePatterns(repoRoot);
234
+ for (const pattern of patterns) {
235
+ for (const root of discoverRootsForPattern(repoRoot, pattern)) {
236
+ roots.set(path.relative(repoRoot, root).split(path.sep).join('/'), root);
237
+ }
238
+ }
239
+ if (MONOREPO_MARKER_FILES.some((file) => fs.existsSync(path.join(repoRoot, file)))) {
240
+ for (const name of MONOREPO_FALLBACK_PROJECT_DIRS) {
241
+ const base = path.join(repoRoot, name);
242
+ let entries;
243
+ try {
244
+ entries = fs.readdirSync(base, { withFileTypes: true });
245
+ } catch {
246
+ continue;
247
+ }
248
+ for (const entry of entries) {
249
+ if (!entry.isDirectory() || isIgnoredWorkspaceDiscoveryDir(entry.name)) continue;
250
+ const root = path.join(base, entry.name);
251
+ roots.set(path.relative(repoRoot, root).split(path.sep).join('/'), root);
252
+ }
253
+ }
254
+ }
255
+ return [...roots.entries()]
256
+ .filter(([rel]) => rel && !rel.startsWith('..'))
257
+ // Honor negated workspace patterns (e.g. "!packages/internal"). resolveWorkspaceProjectRoot
258
+ // sends an excluded package back to the repo root, so an excluded folder must not appear as a
259
+ // selectable target — choosing it would silently resolve to the root instead.
260
+ .filter(([rel]) => !isExcludedByWorkspacePattern(rel.split('/').filter(Boolean), patterns))
261
+ .sort(([a], [b]) => a.localeCompare(b))
262
+ .map(([rel, root]) => {
263
+ const targetExample = findTargetExample(repoRoot, root);
264
+ return {
265
+ name: path.basename(root),
266
+ path: rel,
267
+ targetExample,
268
+ ...resolveCandidateContextSummary(repoRoot, root, targetExample),
269
+ };
270
+ });
271
+ }
272
+
273
+ function resolveCandidateContextSummary(repoRoot, projectRoot, targetPath) {
274
+ const ctx = resolveContext(repoRoot, { targetPath });
275
+ return {
276
+ productStatus: contextSourceStatus(ctx.productPath, repoRoot, projectRoot),
277
+ productPath: contextSourcePath(ctx.productPath, repoRoot),
278
+ designStatus: contextSourceStatus(ctx.designPath, repoRoot, projectRoot),
279
+ designPath: contextSourcePath(ctx.designPath, repoRoot),
280
+ };
281
+ }
282
+
283
+ // Selection candidates surface one of four statuses: 'child' (a canonical
284
+ // PRODUCT.md/DESIGN.md directly in the app root), 'inherited' (resolved from the
285
+ // repo root in a monorepo), 'missing' (no file found), and 'fallback'. 'fallback'
286
+ // intentionally covers two non-canonical locations: a file inside the project
287
+ // root but in a subdirectory (FALLBACK_DIRS, e.g. `.agents/context/`), and a file
288
+ // outside both the project and repo roots (MONODESIGN_CONTEXT_DIR override).
289
+ function contextSourceStatus(filePath, repoRoot, projectRoot) {
290
+ if (!filePath) return 'missing';
291
+ const absPath = path.resolve(filePath);
292
+ const absProjectRoot = path.resolve(projectRoot);
293
+ const absRepoRoot = path.resolve(repoRoot);
294
+ if (isPathInsideOrEqual(absPath, absProjectRoot)) {
295
+ return path.dirname(absPath) === absProjectRoot ? 'child' : 'fallback';
296
+ }
297
+ if (absProjectRoot !== absRepoRoot && isPathInsideOrEqual(absPath, absRepoRoot)) {
298
+ return 'inherited';
299
+ }
300
+ return 'fallback';
301
+ }
302
+
303
+ function contextSourcePath(filePath, repoRoot) {
304
+ if (!filePath) return null;
305
+ const rel = path.relative(repoRoot, filePath).split(path.sep).join('/');
306
+ if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) {
307
+ return rel;
308
+ }
309
+ return filePath;
310
+ }
311
+
312
+ function discoverRootsForPattern(repoRoot, rawPattern) {
313
+ const pattern = normalizeWorkspacePattern(rawPattern);
314
+ if (!pattern || pattern.startsWith('!')) return [];
315
+ const segments = pattern.split('/').filter(Boolean);
316
+ if (!segments.length) return [];
317
+ const firstGlobIndex = segments.findIndex((segment) => segment.includes('*'));
318
+ const literalPrefix = firstGlobIndex === -1 ? segments : segments.slice(0, firstGlobIndex);
319
+ const base = path.join(repoRoot, ...literalPrefix);
320
+ if (!fs.existsSync(base)) return [];
321
+ if (segments.includes('**')) {
322
+ const packageRoots = [];
323
+ walkDirs(base, (dir) => {
324
+ if (dir !== base && isCandidateProjectRoot(dir)) packageRoots.push(dir);
325
+ });
326
+ if (packageRoots.length) return packageRoots;
327
+ return directChildDirs(base);
328
+ }
329
+ return expandSimplePattern(repoRoot, segments);
330
+ }
331
+
332
+ function expandSimplePattern(repoRoot, patternSegments, index = 0, current = repoRoot) {
333
+ if (index >= patternSegments.length) return fs.existsSync(current) ? [current] : [];
334
+ const segment = patternSegments[index];
335
+ if (!segment.includes('*')) {
336
+ return expandSimplePattern(repoRoot, patternSegments, index + 1, path.join(current, segment));
337
+ }
338
+ let entries;
339
+ try {
340
+ entries = fs.readdirSync(current, { withFileTypes: true });
341
+ } catch {
342
+ return [];
343
+ }
344
+ const roots = [];
345
+ for (const entry of entries) {
346
+ if (!entry.isDirectory() || isIgnoredWorkspaceDiscoveryDir(entry.name)) continue;
347
+ if (!segmentMatches(segment, entry.name)) continue;
348
+ roots.push(...expandSimplePattern(repoRoot, patternSegments, index + 1, path.join(current, entry.name)));
349
+ }
350
+ return roots;
351
+ }
352
+
353
+ function directChildDirs(dir) {
354
+ try {
355
+ return fs.readdirSync(dir, { withFileTypes: true })
356
+ .filter((entry) => entry.isDirectory() && !isIgnoredWorkspaceDiscoveryDir(entry.name))
357
+ .map((entry) => path.join(dir, entry.name));
358
+ } catch {
359
+ return [];
360
+ }
361
+ }
362
+
363
+ function walkDirs(root, visit) {
364
+ let entries;
365
+ try {
366
+ entries = fs.readdirSync(root, { withFileTypes: true });
367
+ } catch {
368
+ return;
369
+ }
370
+ for (const entry of entries) {
371
+ if (!entry.isDirectory() || isIgnoredWorkspaceDiscoveryDir(entry.name)) continue;
372
+ const dir = path.join(root, entry.name);
373
+ visit(dir);
374
+ walkDirs(dir, visit);
375
+ }
376
+ }
377
+
378
+ function isCandidateProjectRoot(dir) {
379
+ return !!(
380
+ fs.existsSync(path.join(dir, 'package.json'))
381
+ || firstExisting(dir, [...PRODUCT_NAMES, ...DESIGN_NAMES])
382
+ || fs.existsSync(path.join(dir, 'src'))
383
+ || fs.existsSync(path.join(dir, 'app'))
384
+ || fs.existsSync(path.join(dir, 'pages'))
385
+ || fs.existsSync(path.join(dir, 'public'))
386
+ );
387
+ }
388
+
389
+ function isIgnoredWorkspaceDiscoveryDir(name) {
390
+ return name.startsWith('.') || WORKSPACE_DISCOVERY_IGNORED_DIRS.has(name);
391
+ }
392
+
393
+ function findTargetExample(repoRoot, projectRoot) {
394
+ const examples = [
395
+ 'src/App.jsx',
396
+ 'src/App.tsx',
397
+ 'src/main.jsx',
398
+ 'src/main.tsx',
399
+ 'src/index.jsx',
400
+ 'src/index.ts',
401
+ 'app/page.tsx',
402
+ 'pages/index.tsx',
403
+ 'public/index.html',
404
+ ];
405
+ for (const rel of examples) {
406
+ const abs = path.join(projectRoot, rel);
407
+ if (fs.existsSync(abs)) return path.relative(repoRoot, abs).split(path.sep).join('/');
408
+ }
409
+ return path.relative(repoRoot, projectRoot).split(path.sep).join('/');
410
+ }
411
+
412
+ function nearestTargetContextRoot(absCwd, targetDir) {
413
+ if (!isPathInside(targetDir, absCwd)) return null;
414
+ const rootFallbackDirs = FALLBACK_DIRS.map((rel) => path.resolve(absCwd, rel));
415
+ let dir = path.resolve(targetDir);
416
+ while (dir && dir !== absCwd) {
417
+ if (!rootFallbackDirs.includes(dir) && resolveLocalContextDir(dir)) {
418
+ return dir;
419
+ }
420
+ const parent = path.dirname(dir);
421
+ if (parent === dir) break;
422
+ dir = parent;
423
+ }
424
+ return null;
425
+ }
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Skill-version update check for context.mjs's boot output.
3
+ *
4
+ * Split out of context.mjs. See that file for context.
5
+ */
6
+ import fs from 'node:fs';
7
+ import os from 'node:os';
8
+ import path from 'node:path';
9
+ import { fileURLToPath } from 'node:url';
10
+
11
+ // ─── Update check ──────────────────────────────────────────────────────────
12
+ // Piggyback a lightweight skill-version check on the once-per-session boot.
13
+ // When a newer skill ships, append an UPDATE_AVAILABLE directive so the agent
14
+ // can offer `monomind design update`. Everything here is best-effort and
15
+ // silent on failure: a network problem, sandbox, or missing cache must never
16
+ // block context output or print an error.
17
+
18
+ const UPDATE_HOST = (process.env.MONODESIGN_UPDATE_HOST || 'https://github.com/monoes/monomind').replace(/\/$/, '');
19
+ const UPDATE_CACHE_PATH =
20
+ process.env.MONODESIGN_UPDATE_CACHE || path.join(os.homedir(), '.monodesign', 'update-check.json');
21
+ const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // throttle the network poll to once a day
22
+ const RENOTIFY_INTERVAL_MS = 7 * 24 * 60 * 60 * 1000; // don't re-surface the same version for a week
23
+ const FETCH_TIMEOUT_MS = 1200;
24
+
25
+
26
+ /**
27
+ * Read the installed skill's own version from the sibling SKILL.md frontmatter
28
+ * (this file lives at `<skill>/scripts/context.mjs`). Returns null when the
29
+ * frontmatter is missing or unreadable.
30
+ */
31
+ function readLocalSkillVersion() {
32
+ try {
33
+ const here = path.dirname(fileURLToPath(import.meta.url));
34
+ const skillMd = path.join(here, '..', 'SKILL.md');
35
+ const content = fs.readFileSync(skillMd, 'utf-8');
36
+ const match = content.match(/^version:\s*(.+)$/m);
37
+ return match ? match[1].trim().replace(/^["']|["']$/g, '') : null;
38
+ } catch {
39
+ return null;
40
+ }
41
+ }
42
+
43
+ function readUpdateCache() {
44
+ try {
45
+ return JSON.parse(fs.readFileSync(UPDATE_CACHE_PATH, 'utf-8'));
46
+ } catch {
47
+ return {};
48
+ }
49
+ }
50
+
51
+ function writeUpdateCache(cache) {
52
+ try {
53
+ fs.mkdirSync(path.dirname(UPDATE_CACHE_PATH), { recursive: true });
54
+ fs.writeFileSync(UPDATE_CACHE_PATH, JSON.stringify(cache));
55
+ } catch {
56
+ // Best-effort: a read-only home dir just means we re-poll next session.
57
+ }
58
+ }
59
+
60
+ /** Compare dotted numeric versions. Returns >0 when a is newer than b. */
61
+ function compareSemver(a, b) {
62
+ const pa = String(a).split('.').map(n => parseInt(n, 10) || 0);
63
+ const pb = String(b).split('.').map(n => parseInt(n, 10) || 0);
64
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
65
+ const diff = (pa[i] || 0) - (pb[i] || 0);
66
+ if (diff !== 0) return diff;
67
+ }
68
+ return 0;
69
+ }
70
+
71
+ async function fetchLatestSkillVersion() {
72
+ try {
73
+ const res = await fetch(`${UPDATE_HOST}/api/version`, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
74
+ if (!res.ok) return null;
75
+ const data = await res.json();
76
+ return typeof data?.skills === 'string' ? data.skills : null;
77
+ } catch {
78
+ return null; // offline, sandboxed, timed out, or bad JSON: all non-fatal
79
+ }
80
+ }
81
+
82
+ function buildUpdateDirective(localVersion, latestVersion) {
83
+ return (
84
+ `UPDATE_AVAILABLE: A newer Monodesign skill is available ` +
85
+ `(installed v${localVersion}, latest v${latestVersion}). ` +
86
+ `Before continuing, ask the user once: "A newer Monodesign (v${latestVersion}) is available. ` +
87
+ `Update now? It runs \`monomind design update\`." ` +
88
+ `If they agree, run \`monomind design update\` (the update applies to the next session, not this one). ` +
89
+ `Either way, continue the current task without waiting, and do not raise this again.`
90
+ );
91
+ }
92
+
93
+ /**
94
+ * Best-effort update directive for the boot output. Returns a string to append
95
+ * or null. Polls the version endpoint at most once per day (cached globally in
96
+ * the user's home dir) and re-surfaces a given version at most once per week so
97
+ * the agent never nags. Opt out entirely with MONODESIGN_NO_UPDATE_CHECK=1.
98
+ */
99
+ // Read the unified config's top-level `updateCheck` (local overrides shared).
100
+ // Inlined rather than importing hook-lib so the boot path stays lightweight.
101
+ function updateCheckDisabledByConfig(cwd = process.cwd()) {
102
+ let value;
103
+ for (const name of ['config.json', 'config.local.json']) {
104
+ try {
105
+ const raw = JSON.parse(fs.readFileSync(path.join(cwd, '.monodesign', name), 'utf-8'));
106
+ if (raw && typeof raw === 'object' && typeof raw.updateCheck === 'boolean') value = raw.updateCheck;
107
+ } catch { /* missing or malformed: ignore */ }
108
+ }
109
+ return value === false;
110
+ }
111
+
112
+ export async function computeUpdateDirective(now = Date.now()) {
113
+ // Update checking is disabled in monomind: the skill ships with the monomind
114
+ // package and follows monomind's own update flow (`monomind update`), so
115
+ // there is no separate registry endpoint to poll. Kept as a stub so the boot
116
+ // path (and MONODESIGN_NO_UPDATE_CHECK-aware tests) stays unchanged.
117
+ return null;
118
+ // biome-ignore lint/correctness/noUnreachable: disabled on purpose (see above); kept to re-enable
119
+ try {
120
+ if (process.env.MONODESIGN_NO_UPDATE_CHECK) return null;
121
+ if (updateCheckDisabledByConfig()) return null;
122
+ const localVersion = readLocalSkillVersion();
123
+ if (!localVersion) return null;
124
+
125
+ const cache = readUpdateCache();
126
+
127
+ // Poll the network only when the throttle window has elapsed. Stamp
128
+ // lastCheck even on failure so an offline machine doesn't poll every boot.
129
+ if (!cache.lastCheck || now - cache.lastCheck > CHECK_INTERVAL_MS) {
130
+ const latest = await fetchLatestSkillVersion();
131
+ cache.lastCheck = now;
132
+ if (latest) cache.latestVersion = latest;
133
+ writeUpdateCache(cache);
134
+ }
135
+
136
+ const latest = cache.latestVersion;
137
+ if (!latest || compareSemver(latest, localVersion) <= 0) return null;
138
+
139
+ // Anti-nag: surface a given version at most once per RENOTIFY window.
140
+ if (cache.notifiedVersion === latest && cache.notifiedAt && now - cache.notifiedAt < RENOTIFY_INTERVAL_MS) {
141
+ return null;
142
+ }
143
+ cache.notifiedVersion = latest;
144
+ cache.notifiedAt = now;
145
+ writeUpdateCache(cache);
146
+
147
+ return buildUpdateDirective(localVersion, latest);
148
+ } catch {
149
+ return null;
150
+ }
151
+ }