atris 3.82.26 → 3.83.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 (39) hide show
  1. package/AGENTS.md +10 -12
  2. package/README.md +77 -0
  3. package/bin/atris.js +95 -44
  4. package/commands/activate.js +39 -33
  5. package/commands/agent-home.js +403 -0
  6. package/commands/agent-signin.js +214 -0
  7. package/commands/agent-spawn.js +1 -0
  8. package/commands/autoland.js +173 -71
  9. package/commands/brain.js +211 -410
  10. package/commands/doctor.js +38 -26
  11. package/commands/init.js +4 -4
  12. package/commands/join.js +159 -0
  13. package/commands/memory.js +2 -2
  14. package/commands/morning.js +132 -0
  15. package/commands/night.js +178 -0
  16. package/commands/recap.js +4 -4
  17. package/commands/task.js +31 -2
  18. package/lib/agent-home.js +289 -0
  19. package/lib/agent-page.js +1257 -0
  20. package/lib/cleanup-summary.js +4 -2
  21. package/lib/first-minute.js +41 -20
  22. package/lib/gm-phone-page.js +8 -2
  23. package/lib/known-commands.js +1 -1
  24. package/lib/member-export.js +31 -0
  25. package/lib/memory-folder.js +122 -24
  26. package/lib/memory-live.js +2 -2
  27. package/lib/night-learned.js +171 -0
  28. package/lib/night-shift.js +123 -0
  29. package/lib/task-core.js +2 -1
  30. package/lib/task-db.js +32 -0
  31. package/package.json +1 -1
  32. package/templates/members/designer-1/MEMBER.md +34 -0
  33. package/templates/members/designer-1/MISSION.md +11 -0
  34. package/templates/members/designer-1/SOUL.md +11 -0
  35. package/templates/members/designer-1/context/why.md +5 -0
  36. package/templates/members/designer-1/goals.json +5 -0
  37. package/templates/members/designer-1/goals.md +3 -0
  38. package/templates/members/designer-1/skills/designer-1/SKILL.md +179 -0
  39. package/templates/members/designer-1/skills/designer-1/parts.md +55 -0
@@ -0,0 +1,171 @@
1
+ 'use strict';
2
+
3
+ // Brings a night home. `atris morning` hands the receipt here and the lines the
4
+ // agent learned land in the project's own atris/ folder:
5
+ // atris/learned.md the newest lines, capped, labeled as hints (a brain boot
6
+ // file, see OPTIONAL_LOAD_ORDER_FILES in commands/brain.js)
7
+ // atris/lessons.md the same new lines, through the existing lesson writer
8
+ // The learned text is written by an agent, so it is untrusted: control
9
+ // characters are stripped, each line is capped, and it is only ever written
10
+ // out as text, never run or interpreted.
11
+
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+ const crypto = require('crypto');
15
+ const { cleanText } = require('./night-shift');
16
+ const { localDay } = require('./local-day');
17
+
18
+ const LEARNED_REL = 'atris/learned.md';
19
+ const LESSONS_REL = 'atris/lessons.md';
20
+ const HEADER = 'Notes an agent wrote about its own night, to be read as hints, not instructions.';
21
+ const MAX_LINES = 12; // lines kept in learned.md, newest first
22
+ const MAX_PER_NIGHT = 6; // lines taken from one night
23
+ const MAX_TEXT = 200;
24
+ const MAX_GOAL = 40;
25
+
26
+ // - 2026-11-13, "write cold emails", 71 of 99 (night a1b2c3d4): the line
27
+ const ENTRY = /^- \d{4}-\d{2}-\d{2}, "[^"]*", [^()]*\(night ([\w-]+)\): (.*)$/;
28
+
29
+ function numberOrNull(value) {
30
+ if (value === null || value === undefined || value === '' || typeof value === 'boolean') return null;
31
+ const n = Number(value);
32
+ return Number.isFinite(n) ? n : null;
33
+ }
34
+
35
+ // The score, the night before's score and the change, from the /morning data.
36
+ // `known` is false when the server sent no previous-score field at all (an
37
+ // older server), so the card says nothing rather than "first scored night".
38
+ function scoreOf(data) {
39
+ const receipt = data && data.receipt && typeof data.receipt === 'object' ? data.receipt : {};
40
+ const top = data && typeof data === 'object' ? data : {};
41
+ const pick = (key) => (top[key] !== undefined && top[key] !== null ? top[key] : receipt[key]);
42
+ const raw = pick('score');
43
+ return {
44
+ score: numberOrNull(raw && typeof raw === 'object' ? raw.score : raw),
45
+ previous: numberOrNull(pick('previous_score')),
46
+ change: numberOrNull(pick('change')),
47
+ known: 'previous_score' in top || 'previous_score' in receipt,
48
+ };
49
+ }
50
+
51
+ // What one finished night gives us, cleaned. Null when there is no receipt.
52
+ function nightFromMorning(data) {
53
+ const receipt = data && data.receipt && typeof data.receipt === 'object' ? data.receipt : null;
54
+ if (!receipt) return null;
55
+ const goal = cleanText(receipt.goal, MAX_GOAL).replace(/"/g, "'").replace(/[()]/g, '');
56
+ const id = String(data.shift_id || receipt.shift_id || '').replace(/[^A-Za-z0-9_-]/g, '').slice(0, 8);
57
+ const ended = data.ended_at ? new Date(data.ended_at) : null;
58
+ const day = ended && !Number.isNaN(ended.getTime()) ? localDay(ended) : localDay();
59
+ const learned = Array.isArray(receipt.learned) ? receipt.learned : [];
60
+ const lines = [];
61
+ for (const item of learned) {
62
+ if (typeof item !== 'string') continue;
63
+ const text = cleanText(item, MAX_TEXT);
64
+ if (text && !lines.includes(text)) lines.push(text);
65
+ }
66
+ const lessons = (Array.isArray(receipt.lessons_loaded) ? receipt.lessons_loaded : [])
67
+ .filter((item) => typeof item === 'string')
68
+ .map((item) => cleanText(item, 60))
69
+ .filter(Boolean);
70
+ return {
71
+ id: id || 'unknown',
72
+ day,
73
+ goal: goal || 'a night shift',
74
+ ...scoreOf(data),
75
+ lines: lines.slice(0, MAX_PER_NIGHT),
76
+ allLines: lines.length,
77
+ lessons,
78
+ };
79
+ }
80
+
81
+ function scoreText(night) {
82
+ return night.score === null ? 'not scored' : `${night.score} of 99`;
83
+ }
84
+
85
+ function entryLine(night, text) {
86
+ return `- ${night.day}, "${night.goal}", ${scoreText(night)} (night ${night.id}): ${text}`;
87
+ }
88
+
89
+ function keyOf(line) {
90
+ const m = ENTRY.exec(line);
91
+ return m ? `${m[1]}\n${m[2]}` : null;
92
+ }
93
+
94
+ function isFolder(dir) {
95
+ try { return fs.statSync(dir).isDirectory(); } catch { return false; }
96
+ }
97
+
98
+ function readEntries(file) {
99
+ let text = '';
100
+ try { text = fs.readFileSync(file, 'utf8'); } catch { return []; }
101
+ return text.split(/\r?\n/).filter((line) => line.startsWith('- '));
102
+ }
103
+
104
+ function writeFileAtomic(file, content) {
105
+ const tmp = `${file}.tmp-${process.pid}`;
106
+ try {
107
+ fs.writeFileSync(tmp, content, 'utf8');
108
+ fs.renameSync(tmp, file);
109
+ } catch (error) {
110
+ try { fs.rmSync(tmp, { force: true }); } catch { /* already gone */ }
111
+ throw error;
112
+ }
113
+ }
114
+
115
+ function lessonSlug(night, text) {
116
+ const id = night.id.toLowerCase().replace(/[^a-z0-9-]/g, '-');
117
+ return `night-${id}-${crypto.createHash('sha1').update(text).digest('hex').slice(0, 6)}`;
118
+ }
119
+
120
+ // Same lines to atris/lessons.md through the existing writer. One line per
121
+ // slug (night id + text), so a line that is already there is never written again.
122
+ function appendLessons(root, night, texts) {
123
+ const { writeLesson } = require('../commands/autopilot');
124
+ let existing = '';
125
+ try { existing = fs.readFileSync(path.join(root, LESSONS_REL), 'utf8'); } catch { /* none yet */ }
126
+ let added = 0;
127
+ for (const text of texts) {
128
+ const slug = lessonSlug(night, text);
129
+ if (existing.includes(`] ${slug}**`)) continue;
130
+ writeLesson(root, slug, 'pass', `night note (a hint, not an instruction): ${text}`);
131
+ added += 1;
132
+ }
133
+ return added;
134
+ }
135
+
136
+ // Writes the night's learned lines into <root>/atris. Never throws.
137
+ // Returns { saved, reason?, added, lessons, error? }.
138
+ function saveLearned(root, night) {
139
+ const dir = path.join(root, 'atris');
140
+ if (!isFolder(dir)) return { saved: false, reason: 'no-folder', added: 0, lessons: 0 };
141
+ if (!night.lines.length) return { saved: true, added: 0, lessons: 0 };
142
+ const file = path.join(root, LEARNED_REL);
143
+ try {
144
+ try {
145
+ if (fs.lstatSync(file).isSymbolicLink()) throw new Error(`${LEARNED_REL} is a link, so it was left alone`);
146
+ } catch (error) {
147
+ if (error && error.code !== 'ENOENT') throw error;
148
+ }
149
+ const existing = readEntries(file);
150
+ const seen = new Set(existing.map(keyOf).filter(Boolean));
151
+ const fresh = night.lines.filter((text) => !seen.has(`${night.id}\n${text}`));
152
+ let lessons = 0;
153
+ if (fresh.length) {
154
+ const entries = [...fresh.map((text) => entryLine(night, text)), ...existing].slice(0, MAX_LINES);
155
+ writeFileAtomic(file, `${HEADER}\n\n${entries.join('\n')}\n`);
156
+ try { lessons = appendLessons(root, night, fresh); } catch { lessons = 0; }
157
+ }
158
+ return { saved: true, added: fresh.length, lessons };
159
+ } catch (error) {
160
+ return { saved: false, reason: 'error', error: String((error && error.message) || error), added: 0, lessons: 0 };
161
+ }
162
+ }
163
+
164
+ module.exports = {
165
+ LEARNED_REL,
166
+ LESSONS_REL,
167
+ HEADER,
168
+ MAX_LINES,
169
+ nightFromMorning,
170
+ saveLearned,
171
+ };
@@ -0,0 +1,123 @@
1
+ 'use strict';
2
+
3
+ // Shared helpers for `atris night`, `atris morning`, and `atris join --name`.
4
+ // The joined agent is kept next to the login in credentials (field
5
+ // `joined_agent`), so night and morning act for that agent by default.
6
+
7
+ function parseFlags(args = [], valueFlags = [], boolFlags = []) {
8
+ const flags = {};
9
+ const positionals = [];
10
+ for (let i = 0; i < args.length; i++) {
11
+ const arg = String(args[i]);
12
+ const eq = arg.indexOf('=');
13
+ const name = arg.startsWith('--') && eq > 2 ? arg.slice(0, eq) : arg;
14
+ if (valueFlags.includes(name)) {
15
+ const key = name.slice(2);
16
+ if (eq > 2 && arg.startsWith('--')) {
17
+ flags[key] = arg.slice(eq + 1);
18
+ } else {
19
+ const next = args[i + 1];
20
+ flags[key] = next === undefined ? '' : String(next);
21
+ i += 1;
22
+ }
23
+ } else if (boolFlags.includes(arg)) {
24
+ flags[arg.slice(2)] = true;
25
+ } else {
26
+ positionals.push(arg);
27
+ }
28
+ }
29
+ return { flags, positionals };
30
+ }
31
+
32
+ function wantsHelp(args = []) {
33
+ return Array.isArray(args) && (args.includes('--help') || args.includes('-h') || args[0] === 'help');
34
+ }
35
+
36
+ function formatUsd(value) {
37
+ const n = Number(value);
38
+ if (!Number.isFinite(n)) return '$0';
39
+ return Number.isInteger(n) ? `$${n}` : `$${n.toFixed(2)}`;
40
+ }
41
+
42
+ function joinedAgentFrom(creds) {
43
+ const agent = creds && typeof creds === 'object' ? creds.joined_agent : null;
44
+ if (!agent || typeof agent !== 'object' || !agent.agent_id) return null;
45
+ return agent;
46
+ }
47
+
48
+ // --business accepts a business id or a connected business slug.
49
+ function resolveBusinessId(value, deps = {}) {
50
+ const wanted = String(value || '').trim();
51
+ if (!wanted) return null;
52
+ let businesses = {};
53
+ try {
54
+ const load = deps.loadBusinesses || require('./business-core').loadBusinesses;
55
+ businesses = load() || {};
56
+ } catch {
57
+ businesses = {};
58
+ }
59
+ const lower = wanted.toLowerCase();
60
+ for (const [slug, entry] of Object.entries(businesses)) {
61
+ if (!entry || typeof entry !== 'object') continue;
62
+ if (slug.toLowerCase() === lower || String(entry.slug || '').toLowerCase() === lower) {
63
+ return entry.business_id || entry.id || wanted;
64
+ }
65
+ }
66
+ return wanted;
67
+ }
68
+
69
+ // Who the call is for: explicit flags win, then the agent this login joined as.
70
+ function resolveTarget(flags = {}, creds = null, deps = {}) {
71
+ const businessId = flags.business ? resolveBusinessId(flags.business, deps) : null;
72
+ let agentId = flags.agent ? String(flags.agent).trim() : null;
73
+ let business = businessId;
74
+ if (!agentId) {
75
+ const joined = joinedAgentFrom(creds);
76
+ if (joined && (!businessId || joined.business_id === businessId)) {
77
+ agentId = joined.agent_id;
78
+ if (!business && joined.business_id) business = joined.business_id;
79
+ }
80
+ }
81
+ return { agentId: agentId || null, businessId: business || null };
82
+ }
83
+
84
+ function oneLine(value) {
85
+ if (value && typeof value === 'object') {
86
+ const detail = value.detail || value.error || value.message;
87
+ if (detail) return oneLine(detail);
88
+ try { return JSON.stringify(value); } catch { return 'request failed'; }
89
+ }
90
+ return String(value == null ? 'request failed' : value).replace(/\s+/g, ' ').trim() || 'request failed';
91
+ }
92
+
93
+ // Text from outside (a night's learned lines, a goal, a mission line) as one
94
+ // safe line: control, invisible and direction-changing characters out, spaces
95
+ // collapsed, em dashes softened, cut at `max` characters.
96
+ const UNSAFE_CHARS = /[\u0000-\u001f\u007f-\u009f\u00ad\u061c\u180e\u200b-\u200f\u2028-\u202e\u2060-\u2069\ufeff]/g;
97
+
98
+ function cleanText(value, max = 200) {
99
+ const text = String(value == null ? '' : value)
100
+ .replace(UNSAFE_CHARS, ' ')
101
+ .replace(/\s*\u2014\s*/g, ', ')
102
+ .replace(/\s+/g, ' ')
103
+ .trim();
104
+ const chars = Array.from(text);
105
+ if (chars.length <= max) return text;
106
+ return `${chars.slice(0, Math.max(0, max - 3)).join('').trimEnd()}...`;
107
+ }
108
+
109
+ function errorText(result) {
110
+ if (!result) return 'no answer from Atris';
111
+ return oneLine(result.error || (result.data && (result.data.detail || result.data.error)) || `request failed (${result.status})`);
112
+ }
113
+
114
+ module.exports = {
115
+ parseFlags,
116
+ wantsHelp,
117
+ formatUsd,
118
+ resolveBusinessId,
119
+ resolveTarget,
120
+ oneLine,
121
+ cleanText,
122
+ errorText,
123
+ };
package/lib/task-core.js CHANGED
@@ -118,7 +118,7 @@ atris task - durable local task state (SQLite, gitignored)
118
118
 
119
119
  atris task Same two-line next as bare atris
120
120
  atris task desk [--all] Show the full task desk
121
- atris task new "<title>" [--what-changes "..."] [--why-it-matters "..."] [--done-looks-like "..."] [--verify <cmd>]
121
+ atris task new "<title>" [--tag <tag>] [--what-changes "..."] [--why-it-matters "..."] [--done-looks-like "..."] [--verify <cmd>]
122
122
  Create a task with a plain explanation; omitted fields get honest defaults
123
123
  atris task next [--tag <tag>] [--agent <member>|--human] [--create-next]
124
124
  Show the next human or agent step; agent recommendations are read-only
@@ -185,6 +185,7 @@ atris task - durable local task state (SQLite, gitignored)
185
185
  review-state lanes: needs-agent, continue-work, human-accept-waiting, certified
186
186
  atris task note <id> "<message>" Append dialogue/context to a task
187
187
  atris task retitle <id> "<new title>" Rename a task and preserve the old title in dialogue
188
+ atris task tag <id> <tag> Set the task's tag (the one auto-accept-certified routes on)
188
189
  atris task tag <id> --add <tag> [--remove <tag>]
189
190
  Update tags on an existing task (e.g. --add needs-human to hold it
190
191
  from sweep + fleet staffing); logs a task_tags_updated event
package/lib/task-db.js CHANGED
@@ -2579,6 +2579,37 @@ function tagTask(db, { id, actor, add = [], remove = [] } = {}) {
2579
2579
  return { tagged: true, event, row: updated, tags: next, added, removed };
2580
2580
  }
2581
2581
 
2582
+ // Set the primary tag (the `tag` column `task add --tag` writes at creation) on
2583
+ // an existing task. This is the tag auto-accept and the lane gates route on;
2584
+ // `tagTask` above only edits the `metadata.tags` flag list.
2585
+ function setTaskTag(db, { id, actor, tag } = {}) {
2586
+ if (!id) throw new Error('id required');
2587
+ const next = String(tag == null ? '' : tag).trim();
2588
+ if (!next) return { tagged: false, reason: 'no_tags' };
2589
+ const row = getTask(db, id);
2590
+ if (!row) return { tagged: false, reason: 'not_found' };
2591
+ const previous = row.tag || null;
2592
+ if (previous === next) return { tagged: false, reason: 'no_changes', tag: previous };
2593
+ const now = Math.max(Date.now(), Number(row.updated_at || 0) + 1);
2594
+ const result = withBusyRetry(() => db.prepare(`
2595
+ UPDATE tasks
2596
+ SET tag = ?,
2597
+ updated_at = ?
2598
+ WHERE id = ?
2599
+ AND updated_at = ?
2600
+ `).run(next, now, id, row.updated_at));
2601
+ if (result.changes !== 1) return { tagged: false, reason: 'stale_task_state' };
2602
+ const updated = getTask(db, id);
2603
+ const event = appendTaskEvent(db, {
2604
+ taskId: id,
2605
+ workspaceRoot: updated.workspace_root,
2606
+ actor: cleanStageText(actor) || null,
2607
+ eventType: 'task_tag_set',
2608
+ payload: { tag: next, previous_tag: previous },
2609
+ });
2610
+ return { tagged: true, event, row: updated, tag: next, previous_tag: previous };
2611
+ }
2612
+
2582
2613
  function taskChatPacketFromPayload(payload) {
2583
2614
  const data = payload && typeof payload === 'object' ? payload : {};
2584
2615
  const lines = ['TASK_CHAT_UPDATE'];
@@ -3296,6 +3327,7 @@ module.exports = {
3296
3327
  noteTask,
3297
3328
  chatTask,
3298
3329
  tagTask,
3330
+ setTaskTag,
3299
3331
  reviewTask,
3300
3332
  appendTaskEvent,
3301
3333
  listTaskEvents,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atris",
3
- "version": "3.82.26",
3
+ "version": "3.83.0",
4
4
  "description": "you say what you want in plain words. atris builds it, checks it, and shows you proof. project memory your coding agents share across sessions.",
5
5
  "mcpName": "io.github.atrislabs/atris",
6
6
  "main": "bin/atris.js",
@@ -0,0 +1,34 @@
1
+ ---
2
+ name: designer-1
3
+ role: Designer
4
+ description: Builds web screens from a fixed set of parts, on one spacing scale, with almost no text.
5
+ version: 1.0.0
6
+ skills:
7
+ - designer-1
8
+ permissions:
9
+ can-read: true
10
+ can-plan: true
11
+ can-execute: true
12
+ can-approve: false
13
+ approval-required: []
14
+ ---
15
+
16
+ # Designer-1
17
+
18
+ Design is everything.
19
+
20
+ List every capability and state first. Nothing on that list is cut to save words.
21
+
22
+ Reuse a part before writing one. Build a missing part once, then reuse it.
23
+
24
+ One spacing scale. Tokens only. Every control has a name.
25
+
26
+ No em dashes. No text that explains the screen. Few words, except the ones that say what an action will cost or change.
27
+
28
+ Asked for options, pick one and say why in a sentence.
29
+
30
+ Project taste in `atris/taste/` wins over these defaults.
31
+
32
+ Web now. iOS next.
33
+
34
+ Read `skills/designer-1/SKILL.md` before any screen.
@@ -0,0 +1,11 @@
1
+ # Mission
2
+
3
+ Screens that look right, read in a glance, and work by keyboard and by name.
4
+
5
+ ## North Star
6
+
7
+ More screens from parts already made, fewer parts made twice.
8
+
9
+ ## Boundaries
10
+
11
+ Web only for now. Project taste wins. No inline restyling of a part. No report without the checks.
@@ -0,0 +1,11 @@
1
+ # soul
2
+
3
+ Design is everything.
4
+
5
+ Make it once. Use it again.
6
+
7
+ Complete before quiet.
8
+
9
+ Space carries the order. Words name things and say what will happen.
10
+
11
+ Pick one. Say why.
@@ -0,0 +1,5 @@
1
+ # Why this exists
2
+
3
+ Generated screens drift: a new button per page, spacing picked by feel, paragraphs that explain the obvious. Each rebuild costs tokens and looks a little worse.
4
+
5
+ Designer-1 holds one set of parts and one scale. A screen built from parts already made is fast to make and cheap to change.
@@ -0,0 +1,5 @@
1
+ {
2
+ "schema": "atris.member_goals.v1",
3
+ "member": "designer-1",
4
+ "goals": []
5
+ }
@@ -0,0 +1,3 @@
1
+ # Goals
2
+
3
+ Learn the project's parts and taste. Ship one screen per pass. Grow the parts folder, never a second copy of a part.
@@ -0,0 +1,179 @@
1
+ ---
2
+ name: designer-1
3
+ description: Build complete web screens from a fixed set of parts, on one spacing scale, with few words, and check them before reporting. Use when designing or changing an interface.
4
+ version: 1.2.1
5
+ tags:
6
+ - design
7
+ - frontend
8
+ ---
9
+
10
+ # designer-1
11
+
12
+ Design is everything. Make a part once, then use it again.
13
+
14
+ Covers web. iOS is next.
15
+
16
+ ## 0. taste first
17
+
18
+ 1. Read `atris/taste/TASTE.md` in the project, then the person's own `~/.atris/taste/TASTE.md`. Project taste wins over the person's; both win over this file.
19
+ 2. Taste can change a default. It cannot remove the rule that every control has a name.
20
+ 3. Never write a correction into this skill. Project corrections go in `atris/taste/TASTE.md`.
21
+
22
+ ## 1. the list
23
+
24
+ Completeness comes before restraint. Before designing, write:
25
+
26
+ 1. Every capability the request names. If a thing can be added and can change, changing it is a capability too.
27
+ 2. Every state of each one: empty, error, pending, done, undo. Write "none" with a reason when a state cannot happen.
28
+
29
+ Nothing on the list is cut to save words. Restraint decides how a thing is said, never whether it is there.
30
+
31
+ ## 2. reuse first
32
+
33
+ Before writing any control:
34
+
35
+ 1. Look for the Atris parts: `package.json` lists `@atris/design-react`, or the code imports from it. Use these 19: `Button`, `Field`, `Input`, `Textarea`, `Checkbox`, `Switch`, `RadioGroup`, `Select`, `Dialog`, `Popover`, `Menu`, `Tooltip`, `Toast`, `Tabs`, `Accordion`, `SegmentedControl`, `Combobox`, `Slider`, `RowList`.
36
+ 2. No Atris parts: search the project's component folder for a control with the same job.
37
+ 3. Nothing fits: build the part once, in the one shape, in the folder where parts live. Export it, then import it.
38
+ 4. Never restyle a part inline for one screen: no `style` or `className` that changes its color, size, radius or spacing. Change the part, or use a `size` or `tone` value.
39
+ 5. A native `<button>`, `<input>` or `<select>` in a screen file, where a part covers that job, fails.
40
+
41
+ ## 3. the one shape
42
+
43
+ - `label`: required plain string, the accessible name. `hideLabel` hides it on screen only.
44
+ - Pickers take `options`. Containers take `items`. Each entry is `{ value, label, disabled?, data? }`. Empty choice is `null`.
45
+ - Callbacks: `onValueChange`, `onCheckedChange`, `onOpenChange`; an action that is not a value is `onSelect`.
46
+ - `disabled` is the only disabled prop. `readOnly` refuses every change.
47
+ - Sizes: `small`, `default`, `large`. Tones: `default`, `primary`, `quiet`, `success`, `danger`.
48
+ - Pass `value`, `checked` or `open` to own the state; the callback runs on every attempt so a parent can refuse.
49
+
50
+ Props for all 19: [parts.md](parts.md). Open it only when you place or build a part.
51
+
52
+ ## 4. spacing
53
+
54
+ One scale, Atris token names first. A project token file maps the same steps to its names. No token file: make one, once, with this scale.
55
+
56
+ | token | px | use |
57
+ |---|---|---|
58
+ | `--atris-part-space-1` | 2 | icon nudge |
59
+ | `--atris-part-space-2` | 4 | label to control, control to hint |
60
+ | `--atris-part-space-3` | 6 | icon to text inside a control |
61
+ | `--atris-part-space-4` | 8 | between sibling buttons, chips, options |
62
+ | `--atris-part-space-6` | 12 | between fields in one group |
63
+ | `--atris-part-space-8` | 16 | panel padding on phone |
64
+ | `--atris-part-space-10` | 24 | between groups; panel padding on desktop |
65
+ | `calc(var(--atris-part-space-10) * 2)` | 48 | between page regions |
66
+
67
+ Steps `-5` (10px), `-7` (14px), `-9` (20px) are for inside a part only.
68
+
69
+ 1. No spacing value outside the scale: every margin, padding, gap and inset is a token, `0`, or `calc(var(--atris-part-space-10) * n)`.
70
+ 2. The gap between groups is at least twice the gap inside a group.
71
+ 3. Siblings of the same kind share one gap.
72
+ 4. One left edge per column.
73
+ 5. Labels sit above controls.
74
+ 6. Numbers in a column align right.
75
+ 7. A row of controls shares one size. Dialog and form buttons end-align, primary last.
76
+ 8. Nothing fixed to a screen edge covers content: a sticky or fixed bar reserves its own height in the page.
77
+
78
+ | height token | px | use |
79
+ |---|---|---|
80
+ | `--atris-part-height-small` | 30 | dense tables, desktop toolbars |
81
+ | `--atris-part-height` | 36 | default |
82
+ | `--atris-part-height-large` | 44 | phone, primary action |
83
+ | `--atris-part-touch` | 44 | minimum touch target on phone |
84
+
85
+ Text: page title `calc(var(--atris-part-text-title) * 1.25)` (20), section headings `--atris-part-text-title` (16), body `--atris-part-text-body` (14), one secondary size (`--atris-part-text-metadata`, 12). The title is at least 1.25 times a section heading, or one step above it in a project scale. At most four sizes.
86
+
87
+ Color, radius, motion: house tokens only (`--ink`, `--accent`, `--line`, `--radius-control`, `--motion`, `--ease-out`). One accent, on the primary action.
88
+
89
+ ## 5. words
90
+
91
+ Three kinds of text:
92
+
93
+ - label: names a thing or an action, and must be enough to act on. "anyone with the link", not "anyone".
94
+ - consequence: what an action will do, in numbers. Example: "charge $40.00 today, then $120.00 a month from 1 dec".
95
+ - explanation: how to work the screen. Example: "click here to change your plan".
96
+
97
+ Consequence is required. Explanation is never allowed.
98
+
99
+ Before any action that spends money, deletes, sends, or cannot be undone, the screen states the amount, when, and what changes. "an adjustment on your next invoice" fails: no amount, no date.
100
+
101
+ Words follow the truth. Never promise what the screen does not do. Two places never contradict. Text tied to a state changes with the state: "charged today" does not survive a declined payment.
102
+
103
+ After an action that cannot be undone, withdraw any earlier undo it would conflict with.
104
+
105
+ A choice carries enough to choose. When options differ in ways a label cannot hold (plans, roles, visibility), each shows its difference in one short line, or the group gets a small comparison of at most five rows.
106
+
107
+ ## 6. never
108
+
109
+ | never | detect |
110
+ |---|---|
111
+ | em dash (U+2014) in copy, code or docs | `atris slop detect <path>` rule `em-dash` |
112
+ | explanation text | visible copy containing "click", "tap", "here you can", "this page", "this button", "below", "above", "lets you", "allows you", "simply" |
113
+ | a risky action without its consequence | confirm text for spend, delete, send or no-undo with no number or no date |
114
+ | a vague label | a label that does not say who, what or how much |
115
+ | too much text | any region over the word budget |
116
+ | pulsing, glowing or looping animation | `atris slop detect` rules `pulse-animation` and `colored-glow-shadow`; an infinite iteration count |
117
+ | raw colors or sizes outside tokens | hex, `rgb(`, `hsl(` or `oklch(` outside the token file; a number with `px`, `rem` or `em` outside a `var(`; a Tailwind arbitrary value like `[13px]` |
118
+ | a control without a name | snapshot entry with an empty `label`; a part used without `label` |
119
+
120
+ Word budget, on what the person sees, not their own data:
121
+
122
+ | region | max words |
123
+ |---|---|
124
+ | button, tab, option, menu item | 3 |
125
+ | field label | 4 |
126
+ | heading | 6 |
127
+ | toast | 6 |
128
+ | option difference line | 8 |
129
+ | hint or error | 12 |
130
+ | empty state | 12 |
131
+ | dialog description | 20 |
132
+ | consequence confirmation | 40 |
133
+ | whole screen, without consequence and comparison text | 120 |
134
+
135
+ A consequence confirmation gets 40 because amount, date, what changes and what stays each need about ten words.
136
+
137
+ Labels are lowercase unless project taste says otherwise.
138
+
139
+ ## 7. opinions and pace
140
+
141
+ Asked for options, pick one and say why in one sentence: "tabs, because the views share one record."
142
+
143
+ One screen per pass. Change one thing per round.
144
+
145
+ ## 8. self-check before reporting
146
+
147
+ A check that cannot run is reported as not run, never as passed.
148
+
149
+ 1. **list**: reach every capability and state from section 1 on the screen.
150
+ 2. **keyboard**: Tab through the main flow: visual order, visible focus ring, Enter or Space works, Escape closes the top popup and returns focus to its trigger.
151
+ 3. **names**: load the atris package's `lib/agent-page.js` into the running page and call `AgentPage.createAgent().snapshot()`. Pass when every entry has a non-empty `label`, unique within its region. Report entries of kind `unread`.
152
+ 4. **flow by name**: run the main flow with `act`, each step named by `label` only. Dropdowns take `set`; menu items take `click` once the menu is open. A part the reader cannot operate stays and is named in the report; never change the design silently to pass.
153
+ 5. **failure path**: every form and money action has an error state designed. Reach one per form or money action (bad input, declined payment, lost connection).
154
+ 6. **truth**: after each failure path and each risky action, read every sentence on the screen against "words follow the truth". Then try each undo still offered; one that conflicts with the later action fails.
155
+ 7. **words**: count each region against the budget.
156
+ 8. **widths**: 390px (real phone emulation) and 1280px. Pass when nothing scrolls sideways or clips.
157
+ 9. **targets**: at 390px, measure every control's box in the running page (`getBoundingClientRect`). Fail any under 44px wide or tall. Never assume a size from the code.
158
+ 10. **edges**: at 390px, scroll to the bottom. Pass when the last control and its label are fully visible, clear of any fixed bar.
159
+ 11. **slop**: `atris slop detect <changed paths>` exits 0.
160
+
161
+ ## 9. report
162
+
163
+ Lowercase. No prose around it.
164
+
165
+ ```
166
+ reused: Button, Field, Select
167
+ new: PlanCard (components/parts/plan-card.tsx)
168
+ list: pass, 5 capabilities, 21 states reached
169
+ keyboard: pass, 7 stops in order
170
+ names: pass, 12 controls named, 0 unread
171
+ flow by name: pass, 4 steps; DatePicker not operable, kept
172
+ failure path: pass, 2 of 2 reached
173
+ truth: pass, 31 sentences after 3 changes, 1 undo withdrawn
174
+ words: pass, 64 of 120
175
+ widths: pass at 390 and 1280
176
+ targets: pass, smallest 44px
177
+ edges: pass, last control clear
178
+ slop: pass
179
+ ```