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.
- package/AGENTS.md +16 -3
- package/FOR_AGENTS.md +81 -0
- package/README.md +4 -2
- package/atris/atris.md +51 -19
- package/atris/skills/README.md +1 -0
- package/atris/skills/blocks/SKILL.md +134 -0
- package/atris/skills/clawhub/philosophy-of-work/SKILL.md +56 -0
- package/atris/skills/youtube/SKILL.md +31 -11
- package/atris.md +7 -0
- package/ax +367 -93
- package/bin/atris.js +317 -155
- package/commands/autoland.js +319 -0
- package/commands/autopilot.js +94 -4
- package/commands/business.js +1 -1
- package/commands/clean.js +72 -9
- package/commands/codex-goal.js +72 -22
- package/commands/computer.js +48 -3
- package/commands/gm.js +1 -1
- package/commands/harvest.js +179 -0
- package/commands/init.js +1 -1
- package/commands/land.js +442 -0
- package/commands/loop-front.js +122 -4
- package/commands/member.js +519 -19
- package/commands/mission.js +3659 -282
- package/commands/play.js +1 -1
- package/commands/pulse.js +65 -7
- package/commands/run.js +3 -3
- package/commands/strings.js +301 -0
- package/commands/task.js +575 -102
- package/commands/truth.js +170 -0
- package/commands/xp.js +32 -8
- package/commands/youtube.js +72 -5
- package/decks/README.md +6 -12
- package/lib/auto-accept-certified.js +10 -0
- package/lib/autoland.js +283 -0
- package/lib/context-gatherer.js +0 -8
- package/lib/mission-artifact.js +504 -0
- package/lib/mission-room.js +846 -0
- package/lib/mission-runtime-loop.js +320 -0
- package/lib/next-moves.js +212 -6
- package/lib/pulse.js +74 -1
- package/lib/runner-command.js +20 -8
- package/lib/runs-prune.js +242 -0
- package/lib/task-proof.js +1 -1
- package/package.json +3 -3
- package/decks/atris-seed-pitch-v3.json +0 -118
- package/decks/atris-seed-pitch-v4-skeleton.json +0 -106
- package/decks/atris-seed-pitch-v5.json +0 -109
- package/decks/atris-seed-pitch-v6.json +0 -137
- package/decks/atris-seed-pitch-v7.json +0 -133
- package/decks/mark-pincus-narrative.json +0 -102
- package/decks/mark-pincus-sourcery.json +0 -94
- package/decks/yash-applied-compute-detailed.json +0 -150
- package/decks/yash-applied-compute-generalist.json +0 -82
- package/decks/yash-applied-compute-narrative.json +0 -54
- package/lib/ax-chat-input.js +0 -164
- package/lib/ax-goal.js +0 -307
- package/lib/ax-prefs.js +0 -63
- 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
|
|
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 ||
|
|
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
|
|
376
|
-
|
|
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 ||
|
|
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) +
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
+
};
|