atris 3.30.8 → 3.31.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 (59) hide show
  1. package/AGENTS.md +16 -3
  2. package/FOR_AGENTS.md +81 -0
  3. package/README.md +4 -2
  4. package/atris/atris.md +51 -19
  5. package/atris/skills/README.md +1 -0
  6. package/atris/skills/blocks/SKILL.md +134 -0
  7. package/atris/skills/clawhub/philosophy-of-work/SKILL.md +56 -0
  8. package/atris/skills/youtube/SKILL.md +31 -11
  9. package/atris.md +7 -0
  10. package/ax +367 -93
  11. package/bin/atris.js +317 -155
  12. package/commands/autoland.js +319 -0
  13. package/commands/autopilot.js +94 -4
  14. package/commands/business.js +1 -1
  15. package/commands/clean.js +72 -9
  16. package/commands/codex-goal.js +72 -22
  17. package/commands/computer.js +48 -3
  18. package/commands/gm.js +1 -1
  19. package/commands/harvest.js +179 -0
  20. package/commands/init.js +1 -1
  21. package/commands/land.js +442 -0
  22. package/commands/loop-front.js +122 -4
  23. package/commands/member.js +519 -19
  24. package/commands/mission.js +3659 -282
  25. package/commands/play.js +1 -1
  26. package/commands/pulse.js +65 -7
  27. package/commands/run.js +3 -3
  28. package/commands/strings.js +301 -0
  29. package/commands/task.js +575 -102
  30. package/commands/truth.js +170 -0
  31. package/commands/xp.js +32 -8
  32. package/commands/youtube.js +72 -5
  33. package/decks/README.md +6 -12
  34. package/lib/auto-accept-certified.js +10 -0
  35. package/lib/autoland.js +283 -0
  36. package/lib/context-gatherer.js +0 -8
  37. package/lib/mission-artifact.js +504 -0
  38. package/lib/mission-room.js +846 -0
  39. package/lib/mission-runtime-loop.js +320 -0
  40. package/lib/next-moves.js +212 -6
  41. package/lib/pulse.js +74 -1
  42. package/lib/runner-command.js +20 -8
  43. package/lib/runs-prune.js +242 -0
  44. package/lib/task-proof.js +1 -1
  45. package/package.json +3 -3
  46. package/decks/atris-seed-pitch-v3.json +0 -118
  47. package/decks/atris-seed-pitch-v4-skeleton.json +0 -106
  48. package/decks/atris-seed-pitch-v5.json +0 -109
  49. package/decks/atris-seed-pitch-v6.json +0 -137
  50. package/decks/atris-seed-pitch-v7.json +0 -133
  51. package/decks/mark-pincus-narrative.json +0 -102
  52. package/decks/mark-pincus-sourcery.json +0 -94
  53. package/decks/yash-applied-compute-detailed.json +0 -150
  54. package/decks/yash-applied-compute-generalist.json +0 -82
  55. package/decks/yash-applied-compute-narrative.json +0 -54
  56. package/lib/ax-chat-input.js +0 -164
  57. package/lib/ax-goal.js +0 -307
  58. package/lib/ax-prefs.js +0 -63
  59. package/lib/ax-shimmer.js +0 -63
package/commands/play.js CHANGED
@@ -417,7 +417,7 @@ function modeState(args = []) {
417
417
  prompt: latestMessage(events),
418
418
  } : null,
419
419
  proof_recipe: proofRecipe(),
420
- xp_rule: 'AgentXP lands only after a useful artifact has verifier proof and review accepts it.',
420
+ xp_rule: 'AgentXP is awarded only after a useful artifact has verifier proof and accepted review.',
421
421
  global_sync_rule: AGENTXP_GLOBAL_SYNC_RULE,
422
422
  leaderboard_url: AGENTXP_LEADERBOARD_URL,
423
423
  next_commands: commandList,
package/commands/pulse.js CHANGED
@@ -18,6 +18,7 @@ const os = require('os');
18
18
  const path = require('path');
19
19
  const { spawnSync } = require('child_process');
20
20
  const pulse = require('../lib/pulse');
21
+ const { DEFAULT_CLAUDE_RUNNER_MODEL } = require('../lib/runner-command');
21
22
 
22
23
  function hasFlag(args, name) {
23
24
  return args.includes(name);
@@ -51,6 +52,7 @@ Options:
51
52
  --verify "<cmd>" Verifier for changed-work ticks (default: npm test)
52
53
  --cadence "<cron>" Cron cadence for install
53
54
  --days <n> Auto-expire installed heartbeat after n days
55
+ --hours <n> Auto-expire installed heartbeat after n hours
54
56
  --model <id> Runner model alias/id for installed heartbeat
55
57
  --runner-profile <n> Runner profile for installed heartbeat (e.g. atris-fast)
56
58
  --runner-bin <path> Runner binary for installed heartbeat
@@ -197,6 +199,26 @@ function tickCommand(args, root = process.cwd()) {
197
199
  }
198
200
 
199
201
  const tickIndex = pulse.nextTickIndex(root);
202
+ const signalExitCode = { SIGINT: 130, SIGTERM: 143 };
203
+ let interrupted = false;
204
+ const finishInterrupted = (signal) => {
205
+ if (interrupted) return;
206
+ interrupted = true;
207
+ try {
208
+ pulse.appendPulseReceipt(root, pulse.buildInterruptedPulseReceipt({
209
+ tickIndex,
210
+ signal,
211
+ startedAt,
212
+ prevTickStale: priorStale.stale,
213
+ }));
214
+ } catch {}
215
+ try {
216
+ pulse.releaseLock(root);
217
+ } catch {}
218
+ process.exit(signalExitCode[signal] || 1);
219
+ };
220
+ process.once('SIGINT', finishInterrupted);
221
+ process.once('SIGTERM', finishInterrupted);
200
222
 
201
223
  // 'started' receipt - if the tick dies after this, the orphan surfaces as a ghost.
202
224
  pulse.appendPulseReceipt(root, pulse.buildPulseReceipt({
@@ -260,7 +282,7 @@ function tickCommand(args, root = process.cwd()) {
260
282
  what,
261
283
  changedFiles,
262
284
  elapsedMs,
263
- model: process.env.ATRIS_RUNNER_MODEL || process.env.ATRIS_CLAUDE_MODEL || 'opus',
285
+ model: process.env.ATRIS_RUNNER_MODEL || process.env.ATRIS_CLAUDE_MODEL || DEFAULT_CLAUDE_RUNNER_MODEL,
264
286
  }));
265
287
  scorecardWritten = true;
266
288
  }
@@ -301,6 +323,8 @@ function tickCommand(args, root = process.cwd()) {
301
323
  if (!asJson) process.stdout.write(`pulse tick #${tickIndex} crashed: ${out.error}\n`);
302
324
  return emit(out, asJson);
303
325
  } finally {
326
+ process.removeListener('SIGINT', finishInterrupted);
327
+ process.removeListener('SIGTERM', finishInterrupted);
304
328
  pulse.releaseLock(root);
305
329
  }
306
330
  }
@@ -372,14 +396,45 @@ function resolveEngineBinDirs(extraBins = []) {
372
396
 
373
397
  function installCommand(args, root = process.cwd()) {
374
398
  const asJson = wantsJson(args);
375
- const cron = readFlag(args, '--cadence', pulse.DEFAULT_CADENCE_CRON);
376
- const days = Math.max(1, Number(readFlag(args, '--days', '7')) || 7);
399
+ const cadenceInput = readFlag(args, '--cadence', pulse.DEFAULT_CADENCE_CRON);
400
+ let cron;
401
+ try {
402
+ cron = pulse.normalizeCronCadence(cadenceInput);
403
+ } catch (error) {
404
+ const out = {
405
+ ok: false,
406
+ action: 'pulse_install',
407
+ reason: 'invalid_cadence',
408
+ detail: error && error.message ? error.message : String(error),
409
+ cadence: cadenceInput,
410
+ };
411
+ if (!asJson) process.stdout.write(`pulse install failed: ${out.detail}\n`);
412
+ return emit(out, asJson);
413
+ }
414
+ let expiry;
415
+ try {
416
+ expiry = pulse.normalizeExpiryDuration({
417
+ hours: readFlag(args, '--hours', null),
418
+ days: readFlag(args, '--days', '7'),
419
+ });
420
+ } catch (error) {
421
+ const out = {
422
+ ok: false,
423
+ action: 'pulse_install',
424
+ reason: 'invalid_expiry',
425
+ detail: error && error.message ? error.message : String(error),
426
+ hours: readFlag(args, '--hours', null),
427
+ days: readFlag(args, '--days', '7'),
428
+ };
429
+ if (!asJson) process.stdout.write(`pulse install failed: ${out.detail}\n`);
430
+ return emit(out, asJson);
431
+ }
377
432
  const verifyCmd = readFlag(args, '--verify', 'npm test');
378
- const model = readFlag(args, '--model', process.env.ATRIS_RUNNER_MODEL || process.env.ATRIS_CLAUDE_MODEL || 'opus');
433
+ const model = readFlag(args, '--model', process.env.ATRIS_RUNNER_MODEL || process.env.ATRIS_CLAUDE_MODEL || DEFAULT_CLAUDE_RUNNER_MODEL);
379
434
  const runnerProfile = readFlag(args, '--runner-profile', process.env.ATRIS_RUNNER_PROFILE || '');
380
435
  const runnerBin = readFlag(args, '--runner-bin', process.env.ATRIS_RUNNER_BIN || process.env.ATRIS_CLAUDE_BIN || '');
381
436
  const runnerCommandTemplate = readFlag(args, '--runner-template', process.env.ATRIS_RUNNER_COMMAND_TEMPLATE || process.env.ATRIS_CLAUDE_COMMAND_TEMPLATE || '');
382
- const deadlineEpoch = Math.floor(Date.now() / 1000) + days * 86400;
437
+ const deadlineEpoch = Math.floor(Date.now() / 1000) + expiry.seconds;
383
438
 
384
439
  fs.mkdirSync(STATE_HOME, { recursive: true });
385
440
  const scriptPath = path.join(STATE_HOME, 'tick.sh');
@@ -414,7 +469,10 @@ function installCommand(args, root = process.cwd()) {
414
469
  script_path: scriptPath,
415
470
  crontab_line: line,
416
471
  cadence: cron,
417
- expires_in_days: days,
472
+ cadence_input: cadenceInput,
473
+ expires_in_days: expiry.days,
474
+ expires_in_hours: expiry.hours,
475
+ expires_in_seconds: expiry.seconds,
418
476
  deadline_epoch: deadlineEpoch,
419
477
  runner_profile: runnerProfile || null,
420
478
  runner_bin: runnerBin || null,
@@ -425,7 +483,7 @@ function installCommand(args, root = process.cwd()) {
425
483
  process.stdout.write([
426
484
  `pulse installed. heartbeat fires '${cron}' against ${root}.`,
427
485
  `script: ${scriptPath}`,
428
- `auto-expires in ${days} days. stop early: atris pulse uninstall`,
486
+ `auto-expires in ${expiry.hours ? `${expiry.hours} hours` : `${expiry.days} days`}. stop early: atris pulse uninstall`,
429
487
  ].join('\n') + '\n');
430
488
  } else {
431
489
  process.stdout.write(`pulse install failed to write crontab: ${apply.stderr || apply.status}\nscript written to ${scriptPath}; add this line to your crontab manually:\n${line}\n`);
package/commands/run.js CHANGED
@@ -15,7 +15,7 @@ const { parseTodo } = require('../lib/todo');
15
15
  const {
16
16
  buildRunnerCommand,
17
17
  buildRunnerAvailabilityCommand,
18
- resolveClaudeRunnerBin,
18
+ runnerAvailabilityFailureMessage,
19
19
  } = require('../lib/runner-command');
20
20
  const { cleanAtris } = require('./clean');
21
21
 
@@ -384,8 +384,8 @@ async function runAtris(options = {}) {
384
384
  // Check configured runner CLI is available.
385
385
  try {
386
386
  execSync(buildRunnerAvailabilityCommand(), { stdio: 'pipe' });
387
- } catch {
388
- console.error(`${resolveClaudeRunnerBin()} CLI not found. Set ATRIS_RUNNER_BIN (or legacy ATRIS_CLAUDE_BIN), or install the configured runner first.`);
387
+ } catch (err) {
388
+ console.error(runnerAvailabilityFailureMessage(err));
389
389
  process.exit(1);
390
390
  }
391
391
 
@@ -0,0 +1,301 @@
1
+ // atris strings — a content design system built from live codebase content (no LLM).
2
+ //
3
+ // The missing pillar next to `atris slop`: slop catches HOW copy is written (tells,
4
+ // hype, em-dashes); strings governs WHAT words you ship. It scans the repo for
5
+ // user-facing strings, builds a terminology registry in .atris/strings.json, flags
6
+ // the same string written three different ways (the "unnecessary variant" tell), and
7
+ // enforces preferred terms at the commit/PR gate so you rename "live" -> "active"
8
+ // once and it holds everywhere.
9
+ //
10
+ // Zero external deps (Node built-ins only) — repo contract. Deterministic: a finding
11
+ // is a fact (file:line + term), not a taste opinion, so it drops into CI + the gate.
12
+ //
13
+ // Usage:
14
+ // atris strings scan [path] # extract UI strings -> .atris/strings.json
15
+ // atris strings variants # the same string written N different ways
16
+ // atris strings term --ban live --prefer active --why "..." # codify a rule
17
+ // atris strings check --staged # gate: banned terms in changed lines (exit 1)
18
+ // atris strings list # dump the registry
19
+ //
20
+ // Exit code: 0 = clean, 1 = violation/variants found, 2 = bad usage.
21
+
22
+ const fs = require('fs');
23
+ const path = require('path');
24
+ const { gitChangedLines } = require('./slop'); // reuse the diff parser — DRY
25
+
26
+ const CODE_EXTS = new Set(['.tsx', '.jsx', '.ts', '.js', '.mjs', '.vue', '.svelte', '.astro', '.html']);
27
+ const TEXT_EXTS = new Set([...CODE_EXTS, '.md', '.mdx', '.txt']);
28
+ const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', '.next', '.astro', 'coverage', '.cache', 'out', 'vendor']);
29
+
30
+ const REGISTRY_FILE = path.join('.atris', 'strings.json');
31
+ const MAX_LOCATIONS = 12; // cap stored locations per string so the registry stays readable
32
+
33
+ function walk(target, out, exts) {
34
+ let stat;
35
+ try { stat = fs.statSync(target); } catch { return out; }
36
+ if (stat.isFile()) {
37
+ if (exts.has(path.extname(target))) out.push(target);
38
+ return out;
39
+ }
40
+ if (stat.isDirectory()) {
41
+ if (SKIP_DIRS.has(path.basename(target))) return out;
42
+ for (const name of fs.readdirSync(target)) {
43
+ if (name.startsWith('.') && name !== '.') continue;
44
+ walk(path.join(target, name), out, exts);
45
+ }
46
+ }
47
+ return out;
48
+ }
49
+
50
+ // Is this captured literal a user-facing string, or just code/classes/identifiers?
51
+ // High precision on purpose: a noisy registry gets ignored. minWords lets JSX text
52
+ // nodes (>Settings<) keep single-word labels while quoted literals require a phrase.
53
+ function isUserFacing(s, minWords) {
54
+ if (s.length < 2 || s.length > 200) return false;
55
+ if (!/[A-Za-z]/.test(s)) return false;
56
+ if (/[<>{}=]|\$\{|=>|&&|\|\||::|\/\/|\/\*|\*\//.test(s)) return false; // code
57
+ if (/^https?:/i.test(s) || /\bwww\.[a-z]/i.test(s)) return false; // urls
58
+ if (/^[./~#@\\]/.test(s)) return false; // path/anchor/hex/handle
59
+ if (/^#[0-9a-fA-F]{3,8}$/.test(s)) return false; // hex color
60
+ if (/^[A-Z0-9_]{2,}$/.test(s)) return false; // CONSTANT_CASE
61
+ const words = s.split(/\s+/);
62
+ // className / token-list reject: multi-token where most tokens look like classes
63
+ if (words.length > 1
64
+ && words.every((w) => /^[\w:[\]\-./%@]+$/.test(w))
65
+ && words.filter((w) => w.includes('-')).length >= Math.ceil(words.length / 2)) return false;
66
+ if (words.length === 1) {
67
+ if (minWords > 1) return false; // quoted single tokens are usually identifiers
68
+ return /^[A-Z][a-zA-Z]{2,}$/.test(s); // a real Label word, e.g. "Settings"
69
+ }
70
+ const prose = (s.match(/[A-Za-z ]/g) || []).length;
71
+ return prose / s.length >= 0.55; // reads like a sentence, not a payload
72
+ }
73
+
74
+ // Extract candidate user-facing strings from one file's text.
75
+ function extractStrings(text) {
76
+ const out = [];
77
+ const lines = text.split('\n');
78
+ for (let i = 0; i < lines.length; i++) {
79
+ const line = lines[i];
80
+ const push = (raw, minWords) => { const s = raw.trim(); if (isUserFacing(s, minWords)) out.push({ text: s, line: i + 1 }); };
81
+ let m;
82
+ const jsx = />([^<>{}\n]{2,})</g; while ((m = jsx.exec(line))) push(m[1], 1); // JSX/HTML text node
83
+ const dq = /"([^"\\\n]{2,}?)"/g; while ((m = dq.exec(line))) push(m[1], 2); // "double"
84
+ const sq = /'([^'\\\n]{2,}?)'/g; while ((m = sq.exec(line))) push(m[1], 2); // 'single'
85
+ const tq = /`([^`$\\\n]{2,}?)`/g; while ((m = tq.exec(line))) push(m[1], 2); // `template` (no ${})
86
+ }
87
+ return out;
88
+ }
89
+
90
+ // Canonical form for variant detection: same meaning, different surface (case / punct / spacing).
91
+ function normalize(s) {
92
+ return s.toLowerCase()
93
+ .replace(/…/g, '').replace(/\.\.\.$/, '')
94
+ .replace(/\s+/g, ' ')
95
+ .replace(/[.!?,;:]+$/, '')
96
+ .trim();
97
+ }
98
+
99
+ function loadRegistry(root = process.cwd()) {
100
+ try { return JSON.parse(fs.readFileSync(path.join(root, REGISTRY_FILE), 'utf8')); }
101
+ catch { return { version: 1, scannedAt: null, root: null, strings: [], terms: [] }; }
102
+ }
103
+
104
+ function saveRegistry(reg, root = process.cwd()) {
105
+ const file = path.join(root, REGISTRY_FILE);
106
+ fs.mkdirSync(path.dirname(file), { recursive: true });
107
+ fs.writeFileSync(file, JSON.stringify(reg, null, 2) + '\n');
108
+ return file;
109
+ }
110
+
111
+ // Group strings by normalized form; a cluster with >1 distinct surface form is an inconsistency.
112
+ function variantClusters(strings) {
113
+ const byNorm = new Map();
114
+ for (const s of strings) {
115
+ if (!byNorm.has(s.norm)) byNorm.set(s.norm, []);
116
+ byNorm.get(s.norm).push(s);
117
+ }
118
+ const clusters = [];
119
+ for (const [norm, group] of byNorm) {
120
+ const surfaces = [...new Set(group.map((g) => g.text))];
121
+ if (surfaces.length > 1) {
122
+ clusters.push({ norm, surfaces, count: group.reduce((n, g) => n + g.count, 0) });
123
+ }
124
+ }
125
+ return clusters.sort((a, b) => b.count - a.count);
126
+ }
127
+
128
+ function scan(argv) {
129
+ const json = argv.includes('--json');
130
+ const target = argv.find((a) => !a.startsWith('-')) || '.';
131
+ const files = walk(path.resolve(target), [], CODE_EXTS);
132
+
133
+ const byText = new Map(); // text -> { text, norm, count, locations[] }
134
+ for (const file of files) {
135
+ let text; try { text = fs.readFileSync(file, 'utf8'); } catch { continue; }
136
+ const rel = path.relative(process.cwd(), file);
137
+ for (const hit of extractStrings(text)) {
138
+ let entry = byText.get(hit.text);
139
+ if (!entry) { entry = { text: hit.text, norm: normalize(hit.text), count: 0, locations: [] }; byText.set(hit.text, entry); }
140
+ entry.count++;
141
+ if (entry.locations.length < MAX_LOCATIONS) entry.locations.push(`${rel}:${hit.line}`);
142
+ }
143
+ }
144
+
145
+ const reg = loadRegistry();
146
+ reg.scannedAt = new Date().toISOString();
147
+ reg.root = path.relative(process.cwd(), path.resolve(target)) || '.';
148
+ reg.strings = [...byText.values()].sort((a, b) => b.count - a.count);
149
+ const file = saveRegistry(reg);
150
+ const clusters = variantClusters(reg.strings);
151
+
152
+ if (json) {
153
+ console.log(JSON.stringify({
154
+ ok: true, scanned: files.length, strings: reg.strings.length,
155
+ occurrences: reg.strings.reduce((n, s) => n + s.count, 0),
156
+ variantClusters: clusters.length, registry: path.relative(process.cwd(), file),
157
+ }, null, 2));
158
+ return 0;
159
+ }
160
+ console.log(`\n scanned ${files.length} file${files.length === 1 ? '' : 's'} -> ${reg.strings.length} unique string${reg.strings.length === 1 ? '' : 's'}`);
161
+ console.log(` registry: ${path.relative(process.cwd(), file)}`);
162
+ if (clusters.length) console.log(`\n ⚠ ${clusters.length} variant cluster${clusters.length === 1 ? '' : 's'} (same string, different casing/punctuation) — run: atris strings variants`);
163
+ else console.log(`\n ✓ no inconsistent variants`);
164
+ console.log('');
165
+ return 0;
166
+ }
167
+
168
+ function variants(argv) {
169
+ const json = argv.includes('--json');
170
+ const reg = loadRegistry();
171
+ if (!reg.strings.length) { console.error(' no registry yet — run: atris strings scan'); return 2; }
172
+ const clusters = variantClusters(reg.strings);
173
+ if (json) { console.log(JSON.stringify({ ok: clusters.length === 0, clusters }, null, 2)); return clusters.length ? 1 : 0; }
174
+ if (!clusters.length) { console.log(`\n ✓ clean — every string is written one way\n`); return 0; }
175
+ console.log(`\n ${clusters.length} variant cluster${clusters.length === 1 ? '' : 's'} — same string, inconsistent surface form:\n`);
176
+ for (const c of clusters) {
177
+ console.log(` ⚠ ${c.surfaces.map((s) => JSON.stringify(s)).join(' vs ')} (${c.count}×)`);
178
+ }
179
+ console.log(`\n pick one per cluster, then: atris strings term --ban "<wrong>" --prefer "<right>"\n`);
180
+ return 1;
181
+ }
182
+
183
+ function term(argv) {
184
+ const get = (flag) => { const i = argv.indexOf(flag); return i >= 0 ? argv[i + 1] : null; };
185
+ const ban = get('--ban');
186
+ const prefer = get('--prefer');
187
+ const why = get('--why') || '';
188
+ if (argv.includes('--list') || (!ban && !argv.includes('--remove'))) {
189
+ const reg = loadRegistry();
190
+ if (!reg.terms.length) { console.log('\n no terms yet — add one: atris strings term --ban "live" --prefer "active"\n'); return 0; }
191
+ console.log('\n preferred terms (enforced by: atris strings check):\n');
192
+ for (const t of reg.terms) console.log(` ✗ "${t.ban}" → "${t.prefer}"${t.why ? ` (${t.why})` : ''}`);
193
+ console.log('');
194
+ return 0;
195
+ }
196
+ const reg = loadRegistry();
197
+ if (argv.includes('--remove')) {
198
+ const before = reg.terms.length;
199
+ reg.terms = reg.terms.filter((t) => t.ban.toLowerCase() !== String(get('--remove') || ban || '').toLowerCase());
200
+ saveRegistry(reg);
201
+ console.log(` ${before === reg.terms.length ? 'no match' : 'removed'}: "${get('--remove') || ban}"`);
202
+ return 0;
203
+ }
204
+ if (!ban || !prefer) { console.error(' usage: atris strings term --ban <word> --prefer <word> [--why "..."]'); return 2; }
205
+ reg.terms = reg.terms.filter((t) => t.ban.toLowerCase() !== ban.toLowerCase());
206
+ reg.terms.push({ ban, prefer, why });
207
+ const file = saveRegistry(reg);
208
+ console.log(` ✓ "${ban}" → "${prefer}" added to ${path.relative(process.cwd(), file)}`);
209
+ console.log(` enforce it: atris strings check --staged`);
210
+ return 0;
211
+ }
212
+
213
+ function check(argv) {
214
+ const json = argv.includes('--json');
215
+ const quiet = argv.includes('--quiet');
216
+ const staged = argv.includes('--staged');
217
+ const diffMode = staged || argv.includes('--diff');
218
+ const reg = loadRegistry();
219
+ if (!reg.terms.length) {
220
+ if (json) { console.log(JSON.stringify({ ok: true, terms: 0, findings: [] }, null, 2)); }
221
+ else if (!quiet) console.log('\n no terms to enforce — add one: atris strings term --ban "live" --prefer "active"\n');
222
+ return 0;
223
+ }
224
+
225
+ let files, changed = null;
226
+ if (diffMode) {
227
+ changed = gitChangedLines(staged);
228
+ files = [...changed.keys()].filter((f) => TEXT_EXTS.has(path.extname(f)) && fs.existsSync(f));
229
+ } else {
230
+ const target = argv.find((a) => !a.startsWith('-')) || '.';
231
+ files = walk(path.resolve(target), [], TEXT_EXTS);
232
+ }
233
+ const regAbs = path.resolve(process.cwd(), REGISTRY_FILE);
234
+
235
+ const matchers = reg.terms.map((t) => ({ ...t, re: new RegExp(`\\b${t.ban.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'i') }));
236
+ const findings = [];
237
+ for (const file of files) {
238
+ if (path.resolve(file) === regAbs) continue; // never flag the registry itself
239
+ let text; try { text = fs.readFileSync(file, 'utf8'); } catch { continue; }
240
+ const lines = text.split('\n');
241
+ for (let i = 0; i < lines.length; i++) {
242
+ if (diffMode && changed && !(changed.get(path.resolve(file)) && changed.get(path.resolve(file)).has(i + 1))) continue;
243
+ for (const t of matchers) {
244
+ if (t.re.test(lines[i])) findings.push({ file: path.relative(process.cwd(), file), line: i + 1, ban: t.ban, prefer: t.prefer, why: t.why });
245
+ }
246
+ }
247
+ }
248
+
249
+ if (json) { console.log(JSON.stringify({ ok: findings.length === 0, scanned: files.length, terms: reg.terms.length, findings }, null, 2)); return findings.length ? 1 : 0; }
250
+ if (!findings.length) { if (!quiet) console.log(`\n ✓ clean — no banned terms in ${files.length} file${files.length === 1 ? '' : 's'}\n`); else console.log(' ✓ clean · exit 0'); return 0; }
251
+ if (!quiet) {
252
+ console.log('');
253
+ const w = Math.max(...findings.map((f) => `${f.file}:${f.line}`.length));
254
+ for (const f of findings) console.log(` ✗ ${`${f.file}:${f.line}`.padEnd(w)} "${f.ban}" → "${f.prefer}"${f.why ? ` ${f.why}` : ''}`);
255
+ }
256
+ console.log(`\n ${findings.length} banned-term use${findings.length === 1 ? '' : 's'} · exit 1\n`);
257
+ return 1;
258
+ }
259
+
260
+ function list(argv) {
261
+ const json = argv.includes('--json');
262
+ const reg = loadRegistry();
263
+ if (json) { console.log(JSON.stringify(reg, null, 2)); return 0; }
264
+ if (!reg.strings.length) { console.error(' no registry yet — run: atris strings scan'); return 2; }
265
+ const top = Number((argv[argv.indexOf('--top') + 1]) || 30);
266
+ console.log(`\n ${reg.strings.length} strings (scanned ${reg.scannedAt || '?'}), top ${Math.min(top, reg.strings.length)} by use:\n`);
267
+ for (const s of reg.strings.slice(0, top)) console.log(` ${String(s.count).padStart(3)}× ${JSON.stringify(s.text)}`);
268
+ console.log('');
269
+ return 0;
270
+ }
271
+
272
+ function stringsCommand(argv) {
273
+ const sub = argv[0];
274
+ const rest = argv.slice(1);
275
+ if (sub === 'scan') return scan(rest);
276
+ if (sub === 'variants' || sub === 'dupes') return variants(rest);
277
+ if (sub === 'term' || sub === 'terms') return term(rest);
278
+ if (sub === 'check' || sub === 'gate') return check(rest);
279
+ if (sub === 'list' || sub === 'ls') return list(rest);
280
+ console.log(`
281
+ atris strings — a content design system from your live codebase (no LLM)
282
+
283
+ atris strings scan [path] extract user-facing strings -> .atris/strings.json
284
+ atris strings variants the same string written N different ways (pick one)
285
+ atris strings term --ban <a> --prefer <b> [--why "..."] codify a preferred term
286
+ atris strings term --list show the preferred terms
287
+ atris strings check [--staged] gate: flag banned terms in changed lines (exit 1)
288
+ atris strings list [--top N] the registry, most-used first
289
+ add --json to scan/variants/check/list for machine output
290
+
291
+ Pairs with 'atris slop' (how copy reads) — strings governs what words you ship.
292
+ The registry lives in .atris/strings.json. Wire 'check --staged' into the pre-commit gate.
293
+ `);
294
+ return 0;
295
+ }
296
+
297
+ module.exports = {
298
+ stringsCommand, scan, variants, term, check, list,
299
+ isUserFacing, extractStrings, normalize, variantClusters,
300
+ loadRegistry, saveRegistry, walk, CODE_EXTS, TEXT_EXTS,
301
+ };