atris 3.35.0 → 3.37.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.
Files changed (133) hide show
  1. package/AGENTS.md +37 -0
  2. package/README.md +5 -3
  3. package/atris/GETTING_STARTED.md +1 -1
  4. package/atris/atris.md +3 -0
  5. package/atris/policies/day-loop-voice.md +102 -0
  6. package/atris/policies/outbound-artifact-gate.md +2 -0
  7. package/atris/skills/design/SKILL.md +56 -32
  8. package/atris/skills/endgame/SKILL.md +12 -6
  9. package/atris/skills/engines/SKILL.md +22 -4
  10. package/atris/skills/fable-method/SKILL.md +66 -0
  11. package/atris/skills/improve/SKILL.md +65 -45
  12. package/atris/skills/youtube/SKILL.md +10 -1
  13. package/atris.md +2 -0
  14. package/ax +147 -19
  15. package/bin/atris.js +576 -267
  16. package/commands/activate.js +194 -88
  17. package/commands/agents.js +166 -0
  18. package/commands/autoland.js +459 -107
  19. package/commands/autopilot-front.js +20 -2
  20. package/commands/autopilot.js +118 -2
  21. package/commands/avail.js +407 -0
  22. package/commands/bench.js +188 -0
  23. package/commands/brain.js +3 -0
  24. package/commands/brief.js +651 -0
  25. package/commands/business-sync.js +192 -6
  26. package/commands/clean.js +50 -24
  27. package/commands/close.js +1083 -0
  28. package/commands/cloud.js +245 -0
  29. package/commands/compile.js +292 -1
  30. package/commands/computer.js +150 -3
  31. package/commands/dream.js +365 -0
  32. package/commands/drill.js +371 -0
  33. package/commands/engine.js +993 -32
  34. package/commands/experiments.js +28 -0
  35. package/commands/feedback.js +34 -12
  36. package/commands/fleet-report.js +206 -0
  37. package/commands/gm.js +23 -0
  38. package/commands/goal.js +247 -0
  39. package/commands/improve.js +642 -26
  40. package/commands/init.js +72 -44
  41. package/commands/interview.js +67 -1
  42. package/commands/land.js +152 -52
  43. package/commands/lifecycle.js +39 -3
  44. package/commands/log.js +84 -1
  45. package/commands/loops.js +220 -16
  46. package/commands/meet.js +220 -0
  47. package/commands/member.js +511 -34
  48. package/commands/mission.js +3144 -338
  49. package/commands/next.js +137 -0
  50. package/commands/now.js +220 -25
  51. package/commands/one-lap.js +776 -0
  52. package/commands/orb.js +314 -0
  53. package/commands/pack-craft.js +179 -0
  54. package/commands/pack.js +823 -0
  55. package/commands/play.js +3 -2
  56. package/commands/probe.js +30 -3
  57. package/commands/pulse.js +241 -46
  58. package/commands/push.js +260 -82
  59. package/commands/rainmaker.js +49 -0
  60. package/commands/report.js +415 -0
  61. package/commands/scout.js +147 -0
  62. package/commands/search.js +363 -0
  63. package/commands/skill.js +47 -3
  64. package/commands/slop.js +50 -2
  65. package/commands/soul.js +1 -1
  66. package/commands/stream.js +861 -0
  67. package/commands/study.js +693 -0
  68. package/commands/sync.js +67 -54
  69. package/commands/task.js +1346 -117
  70. package/commands/team.js +73 -0
  71. package/commands/verify.js +96 -0
  72. package/commands/watch.js +303 -0
  73. package/commands/wish.js +500 -0
  74. package/commands/workflow.js +11 -5
  75. package/commands/worktree.js +234 -13
  76. package/commands/xp.js +29 -11
  77. package/lib/auto-accept-certified.js +331 -34
  78. package/lib/autoland.js +319 -54
  79. package/lib/ax-auto-lane.js +96 -0
  80. package/lib/bench/context.js +147 -0
  81. package/lib/bench/engines.js +141 -0
  82. package/lib/bench/report.js +140 -0
  83. package/lib/bench/runner.js +512 -0
  84. package/lib/brief-ledger.js +350 -0
  85. package/lib/cloud-mission.js +259 -0
  86. package/lib/codex-flight.js +154 -0
  87. package/lib/default-runner.js +45 -0
  88. package/lib/default-verifier.js +70 -0
  89. package/lib/engine-registry.js +232 -0
  90. package/lib/experiments/daily.js +640 -0
  91. package/lib/fleet.js +2219 -67
  92. package/lib/improve-vitals-html.js +171 -0
  93. package/lib/known-commands.js +58 -0
  94. package/lib/loop-doctor.js +416 -0
  95. package/lib/member-switches.js +144 -0
  96. package/lib/mission-room.js +1 -0
  97. package/lib/mission-root.js +52 -0
  98. package/lib/next-moves.js +327 -10
  99. package/lib/one-lap-validator.js +60 -0
  100. package/lib/orb-context.js +477 -0
  101. package/lib/orb-scorecard.js +224 -0
  102. package/lib/policy-lessons.js +52 -1
  103. package/lib/pulse.js +277 -3
  104. package/lib/receipt-block.js +168 -0
  105. package/lib/receipt-evidence.js +65 -4
  106. package/lib/router-brain.js +352 -0
  107. package/lib/runner-command.js +10 -0
  108. package/lib/self-drive.js +258 -0
  109. package/lib/short-name.js +103 -0
  110. package/lib/spawn-env.js +18 -0
  111. package/lib/state-detection.js +56 -1
  112. package/lib/sync-status.js +59 -0
  113. package/lib/task-db.js +108 -29
  114. package/lib/task-proof.js +23 -1
  115. package/lib/team-presence.js +260 -0
  116. package/lib/tool-result-encode.js +7 -0
  117. package/lib/trust-tiers.js +90 -0
  118. package/lib/usage.js +107 -0
  119. package/lib/voice-gate.js +163 -0
  120. package/lib/wish-audit.js +1368 -0
  121. package/lib/wish-delegate.js +1840 -0
  122. package/lib/wish-design.js +110 -0
  123. package/lib/wish-stats.js +183 -0
  124. package/lib/wish-store.js +354 -0
  125. package/lib/zip.js +221 -0
  126. package/package.json +3 -1
  127. package/templates/loops/atris/loops/LOOPS.md +55 -0
  128. package/templates/loops/atris/loops/TICK.md +24 -0
  129. package/templates/loops/atris/loops/feedback.md +22 -0
  130. package/templates/loops/atris/loops/quality.md +22 -0
  131. package/templates/loops/atris/wiki/systems/loops.md +41 -0
  132. package/utils/api.js +5 -1
  133. package/utils/auth.js +57 -21
@@ -0,0 +1,363 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ const LAYERS = [
7
+ { key: 'features', dir: path.join('atris', 'features') },
8
+ { key: 'team', dir: path.join('atris', 'team') },
9
+ { key: 'wiki', dir: path.join('atris', 'wiki') },
10
+ { key: 'logs', dir: path.join('atris', 'logs') },
11
+ ];
12
+
13
+ const STOPWORDS = new Set([
14
+ 'a', 'an', 'and', 'are', 'as', 'at', 'for', 'from', 'in', 'is', 'it',
15
+ 'of', 'on', 'or', 'the', 'to', 'where', 'who',
16
+ ]);
17
+
18
+ const SYNONYM_MAP = {
19
+ billing: ['credits', 'stripe', 'subscription', 'connect', 'treasury'],
20
+ payments: ['credits', 'stripe', 'subscription', 'connect', 'treasury'],
21
+ payment: ['credits', 'stripe', 'subscription', 'connect', 'treasury'],
22
+ money: ['credits', 'stripe', 'subscription', 'connect', 'treasury'],
23
+ auth: ['oauth', 'identity', 'jwt', 'security', 'access'],
24
+ login: ['auth', 'oauth', 'identity', 'jwt', 'security', 'access'],
25
+ email: ['mail', 'inbox', 'ses', 'agent-mail', 'atrismail', 'postman'],
26
+ agents: ['agent', 'pulse', 'autopilot', 'fleet', 'member', 'persona', 'architect'],
27
+ database: ['db', 'schema', 'migration', 'supabase', 'sql'],
28
+ db: ['database', 'schema', 'migration', 'supabase', 'sql'],
29
+ slack: ['messaging', 'integration', 'integrations'],
30
+ messaging: ['slack', 'integration', 'integrations'],
31
+ pulse: ['autopilot', 'proactive-ops', 'autonomous'],
32
+ autonomous: ['pulse', 'autopilot', 'proactive-ops'],
33
+ browser: ['scraper', 'automation', 'browser-lead'],
34
+ tools: ['tool', 'toolkit', 'skill', 'skills'],
35
+ toolkit: ['tool', 'tools', 'skill', 'skills'],
36
+ apps: ['app', 'builder', 'workflow', 'create-app', 'app-pm', 'block-builder'],
37
+ workflow: ['app', 'apps', 'builder', 'create-app', 'app-pm', 'block-builder'],
38
+ };
39
+
40
+ function showSearchHelp() {
41
+ console.log('Usage: atris search <keyword> [--raw]');
42
+ console.log('Example: atris search auth');
43
+ console.log(' --raw Print the full file:line dump instead of the compact answer');
44
+ }
45
+
46
+ function normalizeText(value) {
47
+ return String(value || '').toLowerCase().trim();
48
+ }
49
+
50
+ function toRelative(root, filePath) {
51
+ return path.relative(root, filePath).split(path.sep).join('/');
52
+ }
53
+
54
+ function queryTerms(query) {
55
+ const terms = new Set();
56
+ const normalized = normalizeText(query);
57
+ if (normalized) terms.add(normalized);
58
+
59
+ const tokens = normalized
60
+ .split(/[^a-z0-9._/-]+/)
61
+ .map(token => token.trim())
62
+ .filter(token => token && !STOPWORDS.has(token));
63
+
64
+ for (const token of tokens) {
65
+ terms.add(token);
66
+ if (token.endsWith('s') && token.length > 3) {
67
+ terms.add(token.slice(0, -1));
68
+ } else if (token.length > 3) {
69
+ terms.add(`${token}s`);
70
+ }
71
+ for (const synonym of SYNONYM_MAP[token] || []) {
72
+ terms.add(synonym);
73
+ }
74
+ }
75
+
76
+ return Array.from(terms)
77
+ .filter(Boolean)
78
+ .sort((a, b) => b.length - a.length || a.localeCompare(b));
79
+ }
80
+
81
+ function hasTerm(value, terms) {
82
+ const text = normalizeText(value);
83
+ return terms.some(term => text.includes(term));
84
+ }
85
+
86
+ function listMarkdownFiles(rootDir) {
87
+ if (!fs.existsSync(rootDir)) return [];
88
+ const files = [];
89
+
90
+ function walk(dir) {
91
+ let entries;
92
+ try {
93
+ entries = fs.readdirSync(dir, { withFileTypes: true });
94
+ } catch {
95
+ return;
96
+ }
97
+
98
+ entries
99
+ .sort((a, b) => a.name.localeCompare(b.name))
100
+ .forEach((entry) => {
101
+ const fullPath = path.join(dir, entry.name);
102
+ if (entry.isDirectory()) {
103
+ walk(fullPath);
104
+ return;
105
+ }
106
+ if (!entry.isFile()) return;
107
+ if (/\.md$/i.test(entry.name)) files.push(fullPath);
108
+ });
109
+ }
110
+
111
+ walk(rootDir);
112
+ return files;
113
+ }
114
+
115
+ function recencyMs(relativePath, stat) {
116
+ const match = relativePath.match(/(\d{4})-(\d{2})-(\d{2})/);
117
+ if (match) {
118
+ const ms = Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3]));
119
+ if (Number.isFinite(ms)) return ms;
120
+ }
121
+ return Number(stat && stat.mtimeMs) || 0;
122
+ }
123
+
124
+ function scanLayer(root, layer, terms) {
125
+ const dir = path.join(root, layer.dir);
126
+ const result = {
127
+ key: layer.key,
128
+ dir: layer.dir.split(path.sep).join('/'),
129
+ exists: fs.existsSync(dir),
130
+ fileHits: [],
131
+ lineHits: [],
132
+ };
133
+
134
+ for (const filePath of listMarkdownFiles(dir)) {
135
+ let stat;
136
+ let content;
137
+ try {
138
+ stat = fs.statSync(filePath);
139
+ content = fs.readFileSync(filePath, 'utf8');
140
+ } catch {
141
+ continue;
142
+ }
143
+
144
+ const relativePath = toRelative(root, filePath);
145
+ const fileRecency = recencyMs(relativePath, stat);
146
+ const pathMatched = hasTerm(relativePath, terms);
147
+ const fileLineHits = [];
148
+
149
+ content.split(/\r?\n/).forEach((line, index) => {
150
+ if (!hasTerm(line, terms)) return;
151
+ const hit = {
152
+ layer: layer.key,
153
+ file: relativePath,
154
+ line: index + 1,
155
+ content: line.trim(),
156
+ recencyMs: fileRecency,
157
+ pathOnly: false,
158
+ };
159
+ fileLineHits.push(hit);
160
+ result.lineHits.push(hit);
161
+ });
162
+
163
+ if (pathMatched || fileLineHits.length > 0) {
164
+ result.fileHits.push({
165
+ layer: layer.key,
166
+ file: relativePath,
167
+ pathMatched,
168
+ lineHits: fileLineHits,
169
+ recencyMs: fileRecency,
170
+ });
171
+ }
172
+ }
173
+
174
+ return result;
175
+ }
176
+
177
+ function compareByRecency(a, b) {
178
+ return (b.recencyMs || 0) - (a.recencyMs || 0)
179
+ || a.file.localeCompare(b.file)
180
+ || (a.line || 0) - (b.line || 0);
181
+ }
182
+
183
+ function sortedFileHits(layerResult) {
184
+ return [...(layerResult.fileHits || [])].sort(compareByRecency);
185
+ }
186
+
187
+ function sortedLineHits(layerResult) {
188
+ return [...(layerResult.lineHits || [])].sort(compareByRecency);
189
+ }
190
+
191
+ function collectSearchResults(root, query) {
192
+ const terms = queryTerms(query);
193
+ const layers = {};
194
+ for (const layer of LAYERS) {
195
+ layers[layer.key] = scanLayer(root, layer, terms);
196
+ }
197
+ return { query, terms, layers };
198
+ }
199
+
200
+ function featurePath(file) {
201
+ const parts = file.split('/');
202
+ const index = parts.indexOf('features');
203
+ const slug = index >= 0 ? parts[index + 1] : null;
204
+ if (slug && slug !== 'README.md') return `atris/features/${slug}/`;
205
+ return file;
206
+ }
207
+
208
+ function ownerPath(root, file) {
209
+ const parts = file.split('/');
210
+ const index = parts.indexOf('team');
211
+ const member = index >= 0 && parts[index + 1]
212
+ ? `atris/team/${parts[index + 1]}/MEMBER.md`
213
+ : null;
214
+ if (member && fs.existsSync(path.join(root, member))) return member;
215
+ return path.basename(file) === 'MEMBER.md' ? file : null;
216
+ }
217
+
218
+ function selectedFeature(results) {
219
+ const hit = sortedFileHits(results.layers.features)[0];
220
+ return hit ? featurePath(hit.file) : 'none';
221
+ }
222
+
223
+ function selectedOwner(root, results) {
224
+ for (const hit of sortedFileHits(results.layers.team)) {
225
+ const member = ownerPath(root, hit.file);
226
+ if (member) return member;
227
+ }
228
+ return 'none';
229
+ }
230
+
231
+ function selectedWiki(results) {
232
+ const hit = sortedFileHits(results.layers.wiki)[0];
233
+ return hit ? hit.file : 'gap - not indexed yet';
234
+ }
235
+
236
+ function hitCount(layerResult) {
237
+ return (layerResult.fileHits || []).reduce((total, fileHit) => {
238
+ if (fileHit.lineHits.length > 0) return total + fileHit.lineHits.length;
239
+ return total + (fileHit.pathMatched ? 1 : 0);
240
+ }, 0);
241
+ }
242
+
243
+ function totalHitCount(results) {
244
+ return LAYERS.reduce((total, layer) => total + hitCount(results.layers[layer.key]), 0);
245
+ }
246
+
247
+ function truncateLine(value, max = 140) {
248
+ const text = String(value || '').replace(/\s+/g, ' ').trim();
249
+ if (text.length <= max) return text;
250
+ return `${text.slice(0, Math.max(0, max - 3))}...`;
251
+ }
252
+
253
+ function renderCompactSearch(root, results) {
254
+ const lines = [
255
+ `Feature: ${selectedFeature(results)}`,
256
+ `Owner: ${selectedOwner(root, results)}`,
257
+ `Wiki: ${selectedWiki(results)}`,
258
+ ];
259
+
260
+ const journalHits = sortedLineHits(results.layers.logs);
261
+ if (totalHitCount(results) === 0) {
262
+ lines.push('No matches found.');
263
+ }
264
+ if (journalHits.length === 0) {
265
+ lines.push('Journal: none');
266
+ return lines.join('\n');
267
+ }
268
+
269
+ const total = totalHitCount(results);
270
+ const limit = total > 5 ? 3 : 5;
271
+ lines.push(`Found ${journalHits.length} match${journalHits.length === 1 ? '' : 'es'}.`);
272
+ lines.push('Journal:');
273
+ for (const hit of journalHits.slice(0, limit)) {
274
+ lines.push(`- ${hit.file}:${hit.line}: ${truncateLine(hit.content)}`);
275
+ }
276
+ if (journalHits.length > limit) {
277
+ lines.push(`(${journalHits.length - limit} more journal hits; use --raw for full dump)`);
278
+ }
279
+ return lines.join('\n');
280
+ }
281
+
282
+ function rawHits(results) {
283
+ const hits = [];
284
+ for (const layer of LAYERS) {
285
+ for (const fileHit of sortedFileHits(results.layers[layer.key])) {
286
+ if (fileHit.lineHits.length > 0) {
287
+ hits.push(...fileHit.lineHits.sort((a, b) => (a.line || 0) - (b.line || 0)));
288
+ } else if (fileHit.pathMatched) {
289
+ hits.push({
290
+ layer: layer.key,
291
+ file: fileHit.file,
292
+ line: 1,
293
+ content: `(path match) ${fileHit.file}`,
294
+ recencyMs: fileHit.recencyMs,
295
+ pathOnly: true,
296
+ });
297
+ }
298
+ }
299
+ }
300
+ return hits;
301
+ }
302
+
303
+ function renderRawSearch(results) {
304
+ const hits = rawHits(results);
305
+ const lines = [
306
+ `Searching for "${results.query}" in atris/features, atris/team, atris/wiki, atris/logs...`,
307
+ '',
308
+ ];
309
+
310
+ if (hits.length === 0) {
311
+ lines.push('No matches found.');
312
+ return lines.join('\n');
313
+ }
314
+
315
+ lines.push(`Found ${hits.length} match${hits.length === 1 ? '' : 'es'}:`);
316
+ lines.push('');
317
+ for (const hit of hits) {
318
+ lines.push(`${hit.file}:${hit.line}`);
319
+ lines.push(` ${truncateLine(hit.content, 100)}`);
320
+ lines.push('');
321
+ }
322
+ return lines.join('\n').trimEnd();
323
+ }
324
+
325
+ function parseSearchArgs(args) {
326
+ const argv = Array.isArray(args) ? args : [];
327
+ const helpIndex = argv.findIndex(arg => arg === '--help' || arg === '-h');
328
+ const raw = argv.includes('--raw');
329
+ const query = argv.filter(arg => arg !== '--raw' && arg !== '--help' && arg !== '-h').join(' ').trim();
330
+ return { helpIndex, raw, query, argCount: argv.length };
331
+ }
332
+
333
+ function searchCommand(args = [], options = {}) {
334
+ const { helpIndex, raw, query, argCount } = parseSearchArgs(args);
335
+
336
+ if (helpIndex >= 0) {
337
+ showSearchHelp();
338
+ return argCount === 1 ? 0 : 1;
339
+ }
340
+
341
+ if (!query) {
342
+ showSearchHelp();
343
+ return 1;
344
+ }
345
+
346
+ const root = options.root || process.cwd();
347
+ const results = collectSearchResults(root, query);
348
+ if (totalHitCount(results) === 0 && !fs.existsSync(path.join(root, 'atris'))) {
349
+ // Outside a workspace the empty scan rows are noise; lead with the way in.
350
+ console.log('No atris folder here. Run atris init to set one up.');
351
+ return 0;
352
+ }
353
+ console.log(raw ? renderRawSearch(results) : renderCompactSearch(root, results));
354
+ return 0;
355
+ }
356
+
357
+ module.exports = {
358
+ collectSearchResults,
359
+ renderCompactSearch,
360
+ renderRawSearch,
361
+ searchCommand,
362
+ showSearchHelp,
363
+ };
package/commands/skill.js CHANGED
@@ -123,6 +123,52 @@ function findReadableSkills() {
123
123
 
124
124
  // --- Audit Checks ---
125
125
 
126
+ function stripMarkdownCode(content) {
127
+ let fenceLength = 0;
128
+
129
+ return content.split('\n').map((line) => {
130
+ if (fenceLength > 0) {
131
+ const closingFence = line.match(/^ {0,3}(`+)[ \t]*$/);
132
+ if (closingFence && closingFence[1].length >= fenceLength) fenceLength = 0;
133
+ return '';
134
+ }
135
+
136
+ const openingFence = line.match(/^ {0,3}(`{3,})([^`]*)$/);
137
+ if (openingFence) {
138
+ fenceLength = openingFence[1].length;
139
+ return '';
140
+ }
141
+
142
+ let prose = '';
143
+ for (let index = 0; index < line.length;) {
144
+ if (line[index] !== '`') {
145
+ prose += line[index++];
146
+ continue;
147
+ }
148
+
149
+ const start = index;
150
+ while (line[index] === '`') index++;
151
+ const delimiterLength = index - start;
152
+ let closingStart = index;
153
+
154
+ while (closingStart < line.length) {
155
+ closingStart = line.indexOf('`', closingStart);
156
+ if (closingStart === -1) break;
157
+ let closingEnd = closingStart;
158
+ while (line[closingEnd] === '`') closingEnd++;
159
+ if (closingEnd - closingStart === delimiterLength) {
160
+ index = closingEnd;
161
+ break;
162
+ }
163
+ closingStart = closingEnd;
164
+ }
165
+
166
+ if (closingStart === -1) prose += line.slice(start, index);
167
+ }
168
+ return prose;
169
+ }).join('\n');
170
+ }
171
+
126
172
  function runAuditChecks(skill) {
127
173
  const fm = skill.frontmatter || {};
128
174
  const checks = [];
@@ -183,9 +229,7 @@ function runAuditChecks(skill) {
183
229
  });
184
230
 
185
231
  // 7. no XML tags in content (skip placeholders like <name>, <keyword>, code blocks)
186
- const proseContent = skill.content
187
- .replace(/```[\s\S]*?```/g, '') // fenced code blocks
188
- .replace(/`[^`\n]*`/g, ''); // inline code spans
232
+ const proseContent = stripMarkdownCode(skill.content);
189
233
  const xmlMatches = proseContent.match(/<[a-zA-Z][^>]*>/g) || [];
190
234
  // Single-letter tags like <X>/<N> are prose placeholders, not real XML.
191
235
  const placeholders = /^<(name|keyword|placeholder|value|type|path|file|dir|id|url|tag|description|your-|user-|project-|skill-|[a-zA-Z]>)/i;
package/commands/slop.js CHANGED
@@ -32,6 +32,9 @@ const RULES = [
32
32
  { id: 'ai-purple-gradient', sev: 'error',
33
33
  re: /((from|via|to)-(purple|violet|indigo|fuchsia)-\d{2,3}\b|linear-gradient\([^)]*(#6366f1|#8b5cf6|#a855f7|#7c3aed|#4f46e5|\bpurple\b|\bviolet\b|\bindigo\b))/i,
34
34
  why: 'purple/indigo gradient: default "AI startup" palette' },
35
+ { id: 'rainbow-gradient', sev: 'warn',
36
+ re: /\bfrom-([a-z]+)-(?:[3-6]00)\b[^"'`]{0,60}\bto-(?!\1-)([a-z]+)-(?:[3-6]00)\b/i,
37
+ why: 'saturated two-color gradient fill across different hues (pink to orange, blue to cyan): the rainbow CTA reflex; use one solid brand color' },
35
38
  { id: 'ai-indigo-brand', sev: 'warn',
36
39
  re: /(#6366f1|#4f46e5|#4338ca|(?:bg|text|border|from|to|ring)-indigo-(?:500|600|700)\b)/i,
37
40
  why: 'canonical AI indigo used as brand color' },
@@ -44,6 +47,9 @@ const RULES = [
44
47
  { id: 'mega-shadow', sev: 'warn',
45
48
  re: /(shadow-2xl\b|box-shadow:\s*0\s+\d{2,}px)/i,
46
49
  why: 'oversized generic drop shadow (depth-by-blur)' },
50
+ { id: 'colored-glow-shadow', sev: 'warn',
51
+ re: /\bshadow-(?:indigo|violet|purple|fuchsia|blue|sky|cyan|pink|rose|emerald|green|teal)-(?:400|500|600)(?:\/\d{1,3})?\b/i,
52
+ why: 'colored glowing drop shadow under a button (shadow-<hue>-500): the neon-glow AI reflex; keep shadows soft and neutral' },
47
53
  { id: 'side-stripe-card', sev: 'warn',
48
54
  re: /border-(?:left|l)-(?:4|8|\[\d+px\])\b|border-left:\s*[3-9]px\s+solid/i,
49
55
  why: 'accent side-stripe on a card: generated layout reflex' },
@@ -53,6 +59,15 @@ const RULES = [
53
59
  { id: 'pulse-animation', sev: 'warn',
54
60
  re: /animation:[^;]*\binfinite\b|@keyframes\s+(pulse|ping|blink|glow|throb)\b|\banimate-(pulse|ping|bounce)\b/i,
55
61
  why: 'looping pulse/ping/glow animation: distracting live-status reflex' },
62
+ { id: 'hover-lift', sev: 'warn',
63
+ re: /\bhover:(?:-translate-y-(?:0\.5|[1-9]\d?)|scale-1(?:1[0-9]|[2-9]\d))\b/,
64
+ why: 'card lifts/over-grows on hover (translate-y shift or scale past 1.05): the generated-card reflex; keep hover scale subtle and never shift content' },
65
+ { id: 'decorative-blur-blob', sev: 'warn',
66
+ re: /\bblur-3xl\b|filter:\s*blur\(\s*(?:6[4-9]|[7-9]\d|\d{3,})px\)/i,
67
+ why: 'giant blurred gradient orb behind the hero (blur-3xl / 64px+ blur): the AI aurora-blob background reflex' },
68
+ { id: 'dark-hero-gradient', sev: 'warn',
69
+ re: /\b(?:from|via)-(?:slate|gray|zinc|neutral|stone)-9(?:00|50)\b[^"'`]{0,80}\bto-(?:black|(?:slate|gray|zinc|neutral|stone)-950)\b/i,
70
+ why: 'near-black slate/gray hero gradient fading to black: the dark premium SaaS template look' },
56
71
  { id: 'eyebrow-caps', sev: 'warn',
57
72
  re: /text-transform:\s*uppercase\b|\buppercase\b[^"'`]{0,30}tracking-|tracking-[^"'`]{0,30}\buppercase\b/i,
58
73
  why: 'tracked all-caps eyebrow/label: dated reflex; use sentence case' },
@@ -66,6 +81,23 @@ const RULES = [
66
81
  { id: 'hype-copy', sev: 'error',
67
82
  re: /\b(boost your productivity|supercharge|unleash|game[- ]?chang(?:er|ing)|seamlessly|effortlessly|revolutioniz(?:e|ing)|take your .{1,30} to the next level|elevate your|cutting[- ]edge|powered by ai|next[- ]generation)\b/i,
68
83
  why: 'hype/marketing slop phrase: say the specific thing instead' },
84
+ { id: 'kicker-dot-caps', sev: 'error',
85
+ re: /[●◉]\s*[A-Z][A-Z0-9]+(?:\s+[A-Z0-9]+)*\b/,
86
+ notFiles: /\.(md|mdx|txt)$/i, // prose/wireframe docs use ● as radio buttons and transport glyphs
87
+ why: 'dot char + all-caps label: the status-dot eyebrow kicker, the #1 banned hero pattern' },
88
+ ];
89
+
90
+ // Pair rules: two regexes that must BOTH hit within a small line window. Catches
91
+ // composites that span JSX lines (a dot element on one line, its label on the next).
92
+ const PAIR_RULES = [
93
+ { id: 'hero-kicker', sev: 'error', span: 4,
94
+ a: /rounded-full[^"'`]{0,80}\b[wh]-(?:1(?:\.5)?|2(?:\.5)?|3)\b|\b[wh]-(?:1(?:\.5)?|2(?:\.5)?|3)\b[^"'`]{0,80}rounded-full/i,
95
+ b: /font-mono|tracking-(?:wide(?:r|st)?|\[)|text-\[?1[01]px|letter-spacing:\s*0?\.\d/i,
96
+ why: 'tiny status dot next to a small mono/tracked kicker label: banned hero-kicker composite' },
97
+ { id: 'pastel-icon-tile', sev: 'warn', span: 2,
98
+ a: /\bbg-(?:indigo|violet|purple|fuchsia|blue|sky|cyan|teal|emerald|green|lime|amber|orange|rose|pink|red)-(?:50|100)\b/i,
99
+ b: /\brounded(?:-(?:sm|md|lg|xl|2xl))?\b[^"'`]{0,80}\b(?:size-|[wh]-)(?:8|9|10|11|12|14|16)\b|\b(?:size-|[wh]-)(?:8|9|10|11|12|14|16)\b[^"'`]{0,80}\brounded(?:-(?:sm|md|lg|xl|2xl))?\b/i,
100
+ why: 'pastel-tinted rounded icon tile (small light-100 square holding an icon): the generated feature-card reflex' },
69
101
  ];
70
102
 
71
103
  const ICON = { error: '✗', warn: '⚠' }; // ✗ ⚠
@@ -167,7 +199,7 @@ function walk(target, out) {
167
199
  return out;
168
200
  }
169
201
 
170
- function scanFile(file, rules = RULES) {
202
+ function scanFile(file, rules = RULES, pairRules = PAIR_RULES) {
171
203
  const findings = [];
172
204
  let text;
173
205
  try { text = fs.readFileSync(file, 'utf8'); } catch { return findings; }
@@ -175,6 +207,7 @@ function scanFile(file, rules = RULES) {
175
207
  for (let i = 0; i < lines.length; i++) {
176
208
  const line = lines[i];
177
209
  for (const rule of rules) {
210
+ if (rule.notFiles && rule.notFiles.test(file)) continue;
178
211
  const m = rule.re.exec(line);
179
212
  if (m) {
180
213
  findings.push({
@@ -184,6 +217,21 @@ function scanFile(file, rules = RULES) {
184
217
  }
185
218
  }
186
219
  }
220
+ for (const pr of pairRules) {
221
+ for (let i = 0; i < lines.length; i++) {
222
+ if (!pr.a.test(lines[i])) continue;
223
+ const lo = Math.max(0, i - pr.span), hi = Math.min(lines.length - 1, i + pr.span);
224
+ for (let j = lo; j <= hi; j++) {
225
+ if (pr.b.test(lines[j])) {
226
+ findings.push({
227
+ file, line: i + 1, rule: pr.id, sev: pr.sev, why: pr.why,
228
+ snippet: lines[i].trim().slice(0, 48),
229
+ });
230
+ break;
231
+ }
232
+ }
233
+ }
234
+ }
187
235
  return findings;
188
236
  }
189
237
 
@@ -304,4 +352,4 @@ function slopCommand(argv) {
304
352
  return 0;
305
353
  }
306
354
 
307
- module.exports = { slopCommand, detect, scanFile, RULES, loadProjectRules, addProjectRule, gitChangedLines, applyFixes, installHook };
355
+ module.exports = { slopCommand, detect, scanFile, RULES, PAIR_RULES, loadProjectRules, addProjectRule, gitChangedLines, applyFixes, installHook };
package/commands/soul.js CHANGED
@@ -121,7 +121,7 @@ function displaySoul(soul) {
121
121
  const line = '─'.repeat(W);
122
122
 
123
123
  console.log(`\n ┌${line}┐`);
124
- console.log(` │ ${'◉ SOUL — ' + soul.project}${' '.repeat(Math.max(0, W - 10 - soul.project.length))}│`);
124
+ console.log(` │ ${'soul · ' + soul.project}${' '.repeat(Math.max(0, W - 8 - soul.project.length))}│`);
125
125
  console.log(` ├${line}┤`);
126
126
 
127
127
  // Identity