@ethlete/agent-rules 0.1.0-next.27 → 0.1.0-next.28

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 (35) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/content/hooks/context-warning.py +32 -3
  3. package/content/mods/.claude-plugin/marketplace.json +11 -0
  4. package/content/mods/plugins/e2e-progress/.claude-plugin/plugin.json +6 -0
  5. package/content/mods/plugins/e2e-progress/hooks/hooks.json +1 -0
  6. package/content/mods/plugins/e2e-progress/hooks/register.tsx +442 -0
  7. package/content/mods/plugins/e2e-progress/tsconfig.json +3 -0
  8. package/content/mods/plugins/e2e-progress/types/index.d.ts +23 -0
  9. package/content/rules/agent-sessions-wake-up.md +11 -0
  10. package/content/skills/agent-sessions/SKILL.md +81 -0
  11. package/content/skills/agent-sessions/agent-sessions.mjs +436 -0
  12. package/content/skills/design-exploration/SKILL.md +3 -1
  13. package/content/skills/e2e-triage/SKILL.md +80 -0
  14. package/content/skills/handoff/SKILL.md +48 -2
  15. package/content/skills/sdk-update/SKILL.md +7 -0
  16. package/package.json +1 -1
  17. package/src/index.js +7 -0
  18. package/src/index.js.map +1 -1
  19. package/src/lib/config.d.ts +2 -0
  20. package/src/lib/config.js +5 -3
  21. package/src/lib/config.js.map +1 -1
  22. package/src/lib/plan.js +4 -0
  23. package/src/lib/plan.js.map +1 -1
  24. package/src/lib/sessions-command.d.ts +4 -0
  25. package/src/lib/sessions-command.js +16 -0
  26. package/src/lib/sessions-command.js.map +1 -0
  27. package/src/lib/targets/claude-hooks.d.ts +2 -0
  28. package/src/lib/targets/claude-hooks.js +4 -1
  29. package/src/lib/targets/claude-hooks.js.map +1 -1
  30. package/src/lib/targets/claude-mods.d.ts +20 -0
  31. package/src/lib/targets/claude-mods.js +47 -0
  32. package/src/lib/targets/claude-mods.js.map +1 -0
  33. package/src/lib/targets/hooks-shared.d.ts +1 -0
  34. package/src/lib/targets/hooks-shared.js +3 -2
  35. package/src/lib/targets/hooks-shared.js.map +1 -1
@@ -0,0 +1,436 @@
1
+ import { execFileSync, spawn, spawnSync } from 'node:child_process';
2
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, watchFile, writeFileSync } from 'node:fs';
3
+ import { basename, dirname, join, relative, resolve } from 'node:path';
4
+ import { homedir } from 'node:os';
5
+ import { fileURLToPath } from 'node:url';
6
+
7
+ const root = (() => {
8
+ try {
9
+ return execFileSync('git', ['rev-parse', '--show-toplevel'], {
10
+ encoding: 'utf8',
11
+ stdio: ['ignore', 'pipe', 'ignore'],
12
+ }).trim();
13
+ } catch {
14
+ return process.cwd();
15
+ }
16
+ })();
17
+ const mailbox = process.env.AGENT_MAILBOX ?? join(root, '.ethlete/mailbox.jsonl');
18
+ const script = fileURLToPath(import.meta.url);
19
+ const hasRootScript = (name) => {
20
+ try {
21
+ return Boolean(JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).scripts?.[name]);
22
+ } catch {
23
+ return false;
24
+ }
25
+ };
26
+ const inboxCommand = (agent) => {
27
+ if (existsSync(join(root, 'node_modules/.bin/ethlete-agents'))) return `npx ethlete-agents sessions inbox ${agent}`;
28
+ if (hasRootScript('agents:sessions')) return `yarn agents:sessions inbox ${agent}`;
29
+ return `node ${script} inbox ${agent}`;
30
+ };
31
+ const DEFAULT_TTL_HOURS = 4;
32
+ const DEFAULT_LAST = 20;
33
+ const ENTER_DELAY_MS = 400;
34
+ const WAKE_GRACE_MS = 1500;
35
+ const DRAFT_RETRY_MS = 2000;
36
+ const RESTART_AFTER_SECONDS = 5;
37
+ const RESTART_CLEAR_MS = 2500;
38
+ const repoSlug = basename(root).replace(/[.:]/g, '-');
39
+ const sessionFor = (agent) => `${agent}@${repoSlug}`;
40
+ const DISPATCH_SESSION = process.env.AGENT_MAILBOX_DISPATCH_SESSION ?? `mailbox-dispatch@${repoSlug}`;
41
+
42
+ const takeFlag = (args, name, hasValue) => {
43
+ const index = args.indexOf(name);
44
+ if (index === -1) return undefined;
45
+ const [flag, value] = args.splice(index, hasValue ? 2 : 1);
46
+ return hasValue ? value : flag;
47
+ };
48
+
49
+ const args = process.argv.slice(2);
50
+ const last = takeFlag(args, '--last', true);
51
+ const ttl = takeFlag(args, '--ttl', true);
52
+ const all = takeFlag(args, '--all', false);
53
+ const force = takeFlag(args, '--force', false);
54
+ const waitSeconds = takeFlag(args, '--wait', true);
55
+ const recipient = takeFlag(args, '--to', true);
56
+ const rcFile = takeFlag(args, '--file', true);
57
+ const remove = takeFlag(args, '--remove', false);
58
+ const afterSeconds = takeFlag(args, '--after', true);
59
+ const now = takeFlag(args, '--now', false);
60
+ const dryRun = takeFlag(args, '--dry-run', false);
61
+ const [command, sender, ...words] = args;
62
+
63
+ const help = `Shared agent mailbox: ${mailbox}
64
+
65
+ Send: node ${script} send <name> [--to <agent>] '<message>' (no message: read it from stdin)
66
+ Inbox: node ${script} inbox <name> [--wait <seconds>] (only the messages you have not seen yet;
67
+ --wait blocks until one arrives or the time ends)
68
+ Read: node ${script} read [--last <n>] [--all] (default: the last ${DEFAULT_LAST} messages)
69
+ Watch: node ${script} watch <name>
70
+ Claim: node ${script} claim <name> <path...> [--ttl <hours>] [--force]
71
+ Release: node ${script} release <name> [path...] (no path: release every claim of <name>)
72
+ Claims: node ${script} claims
73
+ Up: node ${script} up <name>[=<command>]... (start a tmux session per agent, plus one that runs dispatch)
74
+ Run: node ${script} run <name> <command...> (start or attach the agent in its tmux session; starts dispatch)
75
+ Install: node ${script} shell-install [<agent>...] [--file <path>] [--remove] [--dry-run]
76
+ (write the shell functions into ~/.zshrc or ~/.bashrc)
77
+ Shell: node ${script} shell-init [<agent>...] (print shell functions that wrap each agent with run; default: claude codex)
78
+ Restart: node ${script} restart <name> [--after <seconds>] <prompt...>
79
+ (type the clear command and then the prompt into the tmux session of <name>)
80
+ Down: node ${script} down [<name>...] (stop dispatch; stop the named sessions too)
81
+ Dispatch: node ${script} dispatch (wake the tmux session named <agent> for each message sent --to <agent>)
82
+
83
+ Protocol: append one JSON object per line; never rewrite or truncate.
84
+ Fields: timestamp (UTC), sender, message. A claim also has paths and expires.
85
+ Pick a sender name no other agent uses. Claim the paths you edit before you edit them.
86
+ A claim is advisory, not a lock: it expires after ${DEFAULT_TTL_HOURS} hours unless --ttl says otherwise.
87
+ Silence is not approval. Acknowledge handoffs explicitly.
88
+ Watch prints existing messages and then new messages from other senders every second.
89
+ Keep credentials and personal data out of the mailbox. No agent is started automatically.
90
+ `;
91
+
92
+ const tmux = (...tmuxArgs) => spawnSync('tmux', tmuxArgs, { encoding: 'utf8' });
93
+ const quote = (text) => `'${text.replaceAll("'", "'\\''")}'`;
94
+ const sessionExists = (name) => tmux('has-session', '-t', `=${name}`).status === 0;
95
+ const ONE_SHOT_FLAGS = new Set(['-p', '--print', '-v', '--version', '-h', '--help']);
96
+ const runsInSession = (agentArgs) =>
97
+ Boolean(process.stdin.isTTY && process.stdout.isTTY) && !agentArgs.some((arg) => ONE_SHOT_FLAGS.has(arg));
98
+
99
+ /**
100
+ * Reads the last prompt line (`❯` for Claude Code, `›` for Codex) of the pane. Dim text is the empty
101
+ * prompt's placeholder, so it does not count. A menu cursor (`❯ 1. Yes`) also counts, which keeps a
102
+ * wake-up line from answering a permission dialog.
103
+ */
104
+ const SGR = `${String.fromCharCode(27)}\\[([0-9;]*)m`;
105
+ const hasDraft = (session) => {
106
+ const pane = tmux('capture-pane', '-p', '-e', '-t', `=${session}:`).stdout ?? '';
107
+ const prompt = pane
108
+ .split('\n')
109
+ .reverse()
110
+ .find((line) => /^\s*[❯›]/.test(line.replace(new RegExp(SGR, 'g'), '')));
111
+ if (!prompt) return false;
112
+ let dim = false;
113
+ const visible = prompt
114
+ .split(new RegExp(`(${SGR.replace('(', '(?:')})`))
115
+ .map((part) => {
116
+ const codes = part.match(new RegExp(`^${SGR}$`))?.[1].split(';');
117
+ if (!codes) return dim ? '' : part;
118
+ codes.forEach((code) => {
119
+ if (code === '2') dim = true;
120
+ if (code === '' || code === '0' || code === '22') dim = false;
121
+ });
122
+ return '';
123
+ })
124
+ .join('');
125
+ return visible.replace(/^\s*[❯›]/, '').trim().length > 0;
126
+ };
127
+ const isAttached = (name) => tmux('list-clients', '-t', `=${name}`).stdout.trim() !== '';
128
+ const freeSessionFor = (agent) => {
129
+ for (let number = 1; ; number++) {
130
+ const session = sessionFor(number === 1 ? agent : `${agent}-${number}`);
131
+ if (!sessionExists(session) || !isAttached(session)) return session;
132
+ }
133
+ };
134
+ const startSession = (name, shellCommand, quiet = false) => {
135
+ if (sessionExists(name)) return quiet ? undefined : process.stdout.write(`Session ${name} already runs.\n`);
136
+ const result = tmux('new-session', '-d', '-s', name, '-c', root, ...(shellCommand ? [shellCommand] : []));
137
+ if (result.status !== 0) return process.stderr.write(`Could not start ${name}: ${result.stderr.trim()}\n`);
138
+ if (!quiet) process.stdout.write(`Started ${name}. Attach with: tmux attach -t ${name}\n`);
139
+ };
140
+
141
+ const IGNORED = ['mailbox.jsonl', 'mailbox.cursor.*'];
142
+
143
+ const prepare = () => {
144
+ mkdirSync(dirname(mailbox), { recursive: true });
145
+ if (process.env.AGENT_MAILBOX) return;
146
+ const ignore = join(dirname(mailbox), '.gitignore');
147
+ const present = existsSync(ignore) ? readFileSync(ignore, 'utf8') : '';
148
+ const missing = IGNORED.filter((line) => !present.split('\n').includes(line));
149
+ if (missing.length)
150
+ writeFileSync(ignore, `${present && !present.endsWith('\n') ? `${present}\n` : present}${missing.join('\n')}\n`);
151
+ };
152
+
153
+ const append = (entry) => {
154
+ prepare();
155
+ appendFileSync(mailbox, `${JSON.stringify({ timestamp: new Date().toISOString(), ...entry })}\n`, 'utf8');
156
+ };
157
+
158
+ const parse = (line) => {
159
+ if (!line.trim()) return undefined;
160
+ try {
161
+ return JSON.parse(line);
162
+ } catch {
163
+ process.stderr.write('Ignoring malformed mailbox line.\n');
164
+ return undefined;
165
+ }
166
+ };
167
+
168
+ const entries = () => (existsSync(mailbox) ? readFileSync(mailbox, 'utf8').split('\n').map(parse).filter(Boolean) : []);
169
+
170
+ const show = (entry) => process.stdout.write(`[${entry.timestamp}] ${entry.sender}: ${entry.message}\n`);
171
+
172
+ const inboxCursor = (reader) => join(dirname(mailbox), `mailbox.cursor.${reader}`);
173
+
174
+ const unreadFor = (reader) => {
175
+ const cursor = inboxCursor(reader);
176
+ const history = entries();
177
+ const seen = existsSync(cursor) ? Number(readFileSync(cursor, 'utf8')) : history.length - DEFAULT_LAST;
178
+ return { count: history.length, unread: history.slice(Math.max(0, seen)).filter((entry) => entry.sender !== reader) };
179
+ };
180
+
181
+ const sleep = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
182
+
183
+ const showInbox = (reader, waitFor = 0) => {
184
+ const deadline = Date.now() + waitFor * 1000;
185
+ let { count, unread } = unreadFor(reader);
186
+ while (!unread.length && Date.now() < deadline) {
187
+ sleep(1000);
188
+ ({ count, unread } = unreadFor(reader));
189
+ }
190
+ if (!unread.length) process.stdout.write('No new messages.\n');
191
+ unread.forEach(show);
192
+ prepare();
193
+ writeFileSync(inboxCursor(reader), String(count));
194
+ };
195
+
196
+ const toRepoPath = (path) => relative(root, resolve(path)).split('\\').join('/');
197
+
198
+ const overlaps = (a, b) => a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`);
199
+
200
+ const activeClaims = () => {
201
+ const now = Date.now();
202
+ const claims = [];
203
+ for (const entry of entries()) {
204
+ if (entry.type === 'claim') {
205
+ for (const path of entry.paths) claims.push({ sender: entry.sender, path, expires: entry.expires });
206
+ } else if (entry.type === 'release') {
207
+ for (let index = claims.length - 1; index >= 0; index--) {
208
+ const claim = claims[index];
209
+ const released = !entry.paths?.length || entry.paths.some((path) => overlaps(path, claim.path));
210
+ if (claim.sender === entry.sender && released) claims.splice(index, 1);
211
+ }
212
+ }
213
+ }
214
+ return claims.filter((claim) => Date.parse(claim.expires) > now);
215
+ };
216
+
217
+ if (command === 'send' && sender) {
218
+ const message = words.length ? words.join(' ') : process.stdin.isTTY ? '' : readFileSync(0, 'utf8').trim();
219
+ if (!message) {
220
+ process.stderr.write('No message given.\n');
221
+ process.exitCode = 1;
222
+ } else {
223
+ append({ sender, message, ...(recipient ? { to: recipient } : {}) });
224
+ process.stdout.write(`Sent as ${sender}.\n`);
225
+ }
226
+ } else if (command === 'up') {
227
+ const withEnv = process.env.AGENT_MAILBOX ? `AGENT_MAILBOX=${quote(process.env.AGENT_MAILBOX)} ` : '';
228
+ startSession(DISPATCH_SESSION, `${withEnv}node ${quote(script)} dispatch`);
229
+ [sender, ...words].filter(Boolean).forEach((spec) => {
230
+ const [name, ...rest] = spec.split('=');
231
+ startSession(sessionFor(name), rest.join('=') || undefined);
232
+ });
233
+ } else if (command === 'run' && sender && words.length && !runsInSession(words.slice(1))) {
234
+ const [program, ...programArgs] = words;
235
+ process.exitCode = spawnSync(program, programArgs, { stdio: 'inherit' }).status ?? 1;
236
+ } else if (command === 'run' && sender && words.length) {
237
+ const withEnv = process.env.AGENT_MAILBOX ? `AGENT_MAILBOX=${quote(process.env.AGENT_MAILBOX)} ` : '';
238
+ if (!sessionExists(DISPATCH_SESSION))
239
+ startSession(DISPATCH_SESSION, `${withEnv}node ${quote(script)} dispatch`, true);
240
+ const session = freeSessionFor(sender);
241
+ const shellCommand = words.map(quote).join(' ');
242
+ if (process.env.TMUX) {
243
+ if (!sessionExists(session)) tmux('new-session', '-d', '-s', session, '-c', process.cwd(), shellCommand);
244
+ process.exitCode = spawnSync('tmux', ['switch-client', '-t', `=${session}`], { stdio: 'inherit' }).status ?? 1;
245
+ } else {
246
+ process.exitCode =
247
+ spawnSync('tmux', ['new-session', '-A', '-s', session, '-c', process.cwd(), shellCommand], { stdio: 'inherit' })
248
+ .status ?? 1;
249
+ }
250
+ } else if (command === 'shell-init' || command === 'shell-install') {
251
+ const agents = [sender, ...words].filter(Boolean);
252
+ const functions = (agents.length ? agents : ['claude', 'codex'])
253
+ .map(
254
+ (agent) => `${agent}() {
255
+ local root script
256
+ root=$(git rev-parse --show-toplevel 2>/dev/null) && script="$root/.agents/skills/ethlete-agent-sessions/agent-sessions.mjs"
257
+ if [ -n "$script" ] && [ -f "$script" ] && command -v tmux >/dev/null; then
258
+ node "$script" run ${agent} ${agent} "$@"
259
+ else
260
+ command ${agent} "$@"
261
+ fi
262
+ }
263
+ `,
264
+ )
265
+ .join('');
266
+ if (command === 'shell-init') {
267
+ process.stdout.write(functions);
268
+ } else {
269
+ const shell = basename(process.env.SHELL ?? '');
270
+ const file = rcFile ?? (shell === 'zsh' ? '.zshrc' : shell === 'bash' ? '.bashrc' : undefined);
271
+ if (!file) {
272
+ process.stderr.write('Unknown shell. Pass --file <path> to the startup file.\n');
273
+ process.exitCode = 1;
274
+ } else {
275
+ const path = rcFile ? resolve(rcFile) : join(homedir(), file);
276
+ const present = existsSync(path) ? readFileSync(path, 'utf8') : '';
277
+ const start = '# >>> ethlete agent-sessions >>>';
278
+ const end = '# <<< ethlete agent-sessions <<<';
279
+ const without = ['agent-mailbox', 'agent-sessions']
280
+ .reduce(
281
+ (text, name) =>
282
+ text.replace(new RegExp(`\\n?# >>> ethlete ${name} >>>[\\s\\S]*?# <<< ethlete ${name} <<<\\n?`), '\n'),
283
+ present,
284
+ )
285
+ .replace(/\n+$/, '\n');
286
+ const block = `${start}\n${functions}${end}\n`;
287
+ const next = remove ? without : `${without}\n${block}`;
288
+ if (dryRun) {
289
+ process.stdout.write(`Would write ${path}:\n${remove ? '(block removed)\n' : block}`);
290
+ } else {
291
+ writeFileSync(path, next);
292
+ process.stdout.write(
293
+ `${remove ? 'Removed the block from' : 'Wrote the block to'} ${path}. Open a new terminal.\n`,
294
+ );
295
+ }
296
+ }
297
+ }
298
+ } else if (command === 'restart' && sender && words.length) {
299
+ const session = [sessionFor(sender), sender].find(sessionExists);
300
+ const own = process.env.TMUX_PANE
301
+ ? tmux('display-message', '-p', '-t', process.env.TMUX_PANE, '#{session_name}').stdout.trim()
302
+ : null;
303
+ if (!session) {
304
+ process.stderr.write(`No tmux session for ${sender}.\n`);
305
+ process.exitCode = 1;
306
+ } else if (own && own !== session) {
307
+ process.stderr.write(
308
+ `Refused: ${session} is not your session. You run in ${own}, so pass ${own.replace(`@${repoSlug}`, '')}.\n`,
309
+ );
310
+ process.exitCode = 1;
311
+ } else if (now) {
312
+ const type = (...keys) => tmux('send-keys', '-t', `=${session}:`, ...keys);
313
+ sleep(Number(afterSeconds ?? RESTART_AFTER_SECONDS) * 1000);
314
+ type('-l', sender.startsWith('codex') ? '/new' : '/clear');
315
+ sleep(ENTER_DELAY_MS);
316
+ type('Enter');
317
+ sleep(RESTART_CLEAR_MS);
318
+ type('-l', words.join(' '));
319
+ sleep(ENTER_DELAY_MS);
320
+ type('Enter');
321
+ } else {
322
+ const delay = Number(afterSeconds ?? RESTART_AFTER_SECONDS);
323
+ spawn(process.execPath, [script, 'restart', sender, '--now', '--after', String(delay), ...words], {
324
+ detached: true,
325
+ stdio: 'ignore',
326
+ }).unref();
327
+ process.stdout.write(`Restarting ${session} in ${delay} seconds. End your turn now.\n`);
328
+ }
329
+ } else if (command === 'down') {
330
+ [DISPATCH_SESSION, ...[sender, ...words].filter(Boolean).map(sessionFor)].forEach((name) => {
331
+ const result = tmux('kill-session', '-t', `=${name}`);
332
+ process.stdout.write(result.status === 0 ? `Stopped ${name}.\n` : `No session ${name}.\n`);
333
+ });
334
+ } else if (command === 'dispatch') {
335
+ prepare();
336
+ appendFileSync(mailbox, '');
337
+ let seen = entries().length;
338
+ const due = {};
339
+ const waiting = new Set();
340
+ const wake = (agent) => {
341
+ if (!unreadFor(agent).unread.length) return;
342
+ const text = `New mailbox message for ${agent}. Run: ${inboxCommand(agent)}`;
343
+ const session = [sessionFor(agent), agent].find(sessionExists);
344
+ if (!session) return process.stderr.write(`No tmux session for ${agent}.\n`);
345
+ if (hasDraft(session)) {
346
+ if (!waiting.has(agent)) process.stdout.write(`Waiting for the typed text in ${session}.\n`);
347
+ waiting.add(agent);
348
+ due[agent] = Date.now() + DRAFT_RETRY_MS;
349
+ return;
350
+ }
351
+ waiting.delete(agent);
352
+ spawnSync('tmux', ['send-keys', '-t', `=${session}:`, '-l', text]);
353
+ sleep(ENTER_DELAY_MS);
354
+ spawnSync('tmux', ['send-keys', '-t', `=${session}:`, 'Enter']);
355
+ process.stdout.write(`Woke ${session}.\n`);
356
+ };
357
+ const poll = () => {
358
+ const history = entries();
359
+ const fresh = history.slice(seen);
360
+ seen = history.length;
361
+ fresh
362
+ .filter((entry) => entry.to && entry.to !== entry.sender)
363
+ .forEach((entry) => (due[entry.to] ??= Date.now() + WAKE_GRACE_MS));
364
+ Object.keys(due)
365
+ .filter((agent) => due[agent] <= Date.now())
366
+ .forEach((agent) => {
367
+ delete due[agent];
368
+ wake(agent);
369
+ });
370
+ };
371
+ process.stdout.write(`Dispatching for ${resolve(mailbox)}\n`);
372
+ setInterval(poll, 1000);
373
+ } else if (command === 'inbox' && sender) {
374
+ showInbox(sender, Number(waitSeconds ?? 0));
375
+ } else if (command === 'read') {
376
+ const history = entries();
377
+ (all ? history : history.slice(-Number(last ?? DEFAULT_LAST))).forEach(show);
378
+ } else if (command === 'claims') {
379
+ const claims = activeClaims();
380
+ if (!claims.length) process.stdout.write('No active claims.\n');
381
+ claims.forEach((claim) => process.stdout.write(`${claim.path} ${claim.sender} until ${claim.expires}\n`));
382
+ } else if (command === 'claim' && sender && words.length) {
383
+ showInbox(sender);
384
+ const paths = words.map(toRepoPath);
385
+ const conflicts = activeClaims().filter(
386
+ (claim) => claim.sender !== sender && paths.some((path) => overlaps(path, claim.path)),
387
+ );
388
+ if (conflicts.length && !force) {
389
+ conflicts.forEach((claim) =>
390
+ process.stderr.write(`Claimed by ${claim.sender}: ${claim.path} until ${claim.expires}\n`),
391
+ );
392
+ process.stderr.write('Ask for a handoff, or pass --force to claim anyway.\n');
393
+ process.exitCode = 2;
394
+ } else {
395
+ const hours = ttl === undefined ? DEFAULT_TTL_HOURS : Number(ttl);
396
+ const expires = new Date(Date.now() + hours * 3_600_000).toISOString();
397
+ append({ sender, type: 'claim', paths, expires, message: `claims ${paths.join(', ')}` });
398
+ process.stdout.write(`Claimed ${paths.join(', ')} as ${sender} until ${expires}.\n`);
399
+ }
400
+ } else if (command === 'release' && sender) {
401
+ const paths = words.map(toRepoPath);
402
+ append({
403
+ sender,
404
+ type: 'release',
405
+ paths,
406
+ message: paths.length ? `releases ${paths.join(', ')}` : 'releases all claims',
407
+ });
408
+ process.stdout.write(`Released as ${sender}.\n`);
409
+ } else if (command === 'watch' && sender) {
410
+ prepare();
411
+ appendFileSync(mailbox, '');
412
+ let consumed = 0;
413
+ let pending = '';
414
+ const update = () => {
415
+ const contents = readFileSync(mailbox, 'utf8');
416
+ if (contents.length < consumed) {
417
+ process.stderr.write('Mailbox was truncated; restarting read.\n');
418
+ consumed = 0;
419
+ pending = '';
420
+ }
421
+ pending += contents.slice(consumed);
422
+ consumed = contents.length;
423
+ const lines = pending.split('\n');
424
+ pending = lines.pop() ?? '';
425
+ lines
426
+ .map(parse)
427
+ .filter((entry) => entry && entry.sender !== sender)
428
+ .forEach(show);
429
+ };
430
+ process.stdout.write(`Watching ${resolve(mailbox)} as ${sender}\n`);
431
+ update();
432
+ watchFile(mailbox, { interval: 1000 }, update);
433
+ } else {
434
+ process.stdout.write(help);
435
+ if (command && command !== '--help') process.exitCode = 1;
436
+ }
@@ -45,7 +45,7 @@ finished while the user is still talking about it.
45
45
  check that a variant renders. In a repository with `et design`, take it with
46
46
  `et design shot <layer>/<name> --states [--variants]`: one contact sheet of every state, and
47
47
  every variant of the open sketch, with a label per cell; below each variant row, a diff row marks
48
- the pixels that differ from the merged state in red, or says "no pixel change" (with ", but the markup differs" when only the markup changed, e.g. both sides miss the same content). It prints the PNG path last. `--scale 2` enlarges a small part; `--crop x,y,w,h` cuts each cell to that CSS-pixel box, to judge hairlines. `--interaction hover` forces an interaction on every cell. `--state a,b` draws only those states, to compare them side by side. With `--variants`, `--diff-props` compares each variant with the merged part drawn with the variant's state props, so changed props do not paint the diff red. It draws
48
+ the pixels that differ from the merged state in red, or says "no pixel change" (with ", but the markup differs" when only the markup changed, e.g. both sides miss the same content). It prints the PNG path last. `--scale 2` enlarges a small part; `--crop x,y,w,h` cuts each cell to that CSS-pixel box, to judge hairlines. `--interaction hover` forces an interaction on every cell. `--state a,b` draws only those states, to compare them side by side. With `--variants`, `--round r2` or `--variant i,j` draws only those variants next to the merged row, so the current round stays readable; it filters the picture, never the checks. With `--variants`, `--diff-props` compares each variant with the merged part drawn with the variant's state props, so changed props do not paint the diff red. It draws
49
49
  undimmed, whatever Studio shows. Do not write a scratch script for batch shots.
50
50
 
51
51
  If a second defect appears, add it to the open list and give it one line. Do not draw it.
@@ -119,6 +119,8 @@ et design sketch report <layer>/<name> --variant b --by <model id> "phone"
119
119
  et design sketch report <layer>/<name> --variant b --by <model id> --done # you stopped drawing b; Studio shows its frames
120
120
  et design sketch check [<layer>/<name>] [--fix] # each variant laid over the product
121
121
  et design sketch ready <layer>/<name> --by <model id> # now the user can pick
122
+ et design sketch reject <layer>/<name> --variant b [--reason <text>] --by <model id> # only when the user rejected b; no pick or merge until reopened
123
+ et design sketch reopen <layer>/<name> --variant b [--reason <text>] --by <model id> # only when the user takes b back; the rejection stays in the history
122
124
  ```
123
125
 
124
126
  Variants are alternatives. A variant never stands for a state of another variant: a hidden or
@@ -0,0 +1,80 @@
1
+ ---
2
+ name: e2e-triage
3
+ description: Work through a failed Playwright e2e run - read the failures file the @ethlete/cli failure reporter writes, group the failures by error, fix the largest group first, and re-run only the specs that failed. Read when an e2e run fails with more than a few tests, when an `e2e-failures.jsonl` file is present, or before you start a full e2e run after a change.
4
+ kind: skill
5
+ scope: consumer
6
+ requires: ['@ethlete/cli']
7
+ vars: [docsBaseUrl]
8
+ ---
9
+
10
+ # Triage a failed e2e run
11
+
12
+ Many failed tests rarely mean many bugs. In one real run, a single change in a shared pipe failed 61
13
+ tests. After the fix, 158 of the first 159 retried tests passed. So find the shared cause first, and
14
+ never fix failures one test at a time from the top of the log.
15
+
16
+ ## Before the run
17
+
18
+ 1. Check that `playwright.config.ts` lists `@ethlete/cli/playwright/failure-reporter` in `reporter`.
19
+ If not, add it next to the reporter that is there: `[['list'], ['@ethlete/cli/playwright/failure-reporter']]`.
20
+ The reporter writes one JSON line per failed attempt while the run goes on. The guide is
21
+ {%docsBaseUrl%}/cli/e2e.
22
+ 2. Run the specs of the files you changed first. Start the full suite only when they pass. A full
23
+ run takes long, and one broken shared file fails it everywhere.
24
+
25
+ ## Read the failures
26
+
27
+ The file is `e2e-failures.jsonl` next to `playwright.config.ts`, unless `E2E_FAILURES_FILE` or the
28
+ `outputFile` option of the reporter names another path. You can read it while the run goes on.
29
+
30
+ Each line has `test`, `location`, `project`, `status`, `retry`, `seconds`, `error`, `detail` and `context`.
31
+ `error` holds the first lines of the message. `detail` holds the first later line with a URL, for errors whose first lines do not name it, or `null`. `context` is the path of Playwright's
32
+ `error-context.md`, which holds the page snapshot at the failure.
33
+
34
+ Do not parse the console log of the run. It mixes the output of parallel workers, and it has no end
35
+ line if the run stops.
36
+
37
+ ## Group, then fix the largest group
38
+
39
+ 1. Normalize the `error` text of each line. Remove line and column numbers, timeouts in ms, ids,
40
+ hashes and dates. Keep the matcher, the locator and the expected value.
41
+ 2. Group the lines by the normalized text. Count the distinct spec files per group, not only the lines.
42
+ A group that spans many spec files points at shared code: a component, a pipe, a fixture or a
43
+ test helper.
44
+ 3. Take the largest group. Read one `context` file of it, then the code the error points at. Find
45
+ one cause that explains the whole group.
46
+ 4. Fix that cause. Then re-run only the spec files of that group.
47
+
48
+ A group whose errors are a connection refused (`ERR_CONNECTION_REFUSED`, `ECONNREFUSED`) on a local mock or
49
+ `webServer` port is an infrastructure group, not a test verdict. Check that the server is still up,
50
+ restart it, and re-run those tests before you read them as failures.
51
+
52
+ Small groups that differ only in a locator often have the same cause. Check them after the large group
53
+ is fixed, because a fix often clears them too.
54
+
55
+ ## Re-run only what failed
56
+
57
+ Take the spec file from `location`, without the `:line`. Collect each file once, then run:
58
+
59
+ ```bash
60
+ npx playwright test <file> <file> --project=<project> --no-deps
61
+ ```
62
+
63
+ - Pass each file as its own argument. In zsh, an unquoted `$files` is one argument, and Playwright
64
+ then finds no tests.
65
+ - `--no-deps` skips the setup projects. Leave it out if the failure is in a setup project, or if the
66
+ setup state is gone.
67
+ - `--project` comes from the `project` field. Run one call per project.
68
+
69
+ ## Retries and flaky tests
70
+
71
+ With `retries` on, a test that fails each attempt has one line per attempt (`retry` 0, 1, and so on).
72
+ A test with a line for `retry` 0 and no line for the last retry passed on a later attempt: it is flaky.
73
+ Report a flaky test, but fix the stable failures first.
74
+
75
+ ## Rules
76
+
77
+ - Never skip a failed test, or mark it with `test.fail()` or `test.fixme()`, to make a run pass.
78
+ - Never change an assertion to match the wrong value the app shows now. A failed e2e test is a
79
+ signal first. If you think the test is wrong, say why, and ask before you change it.
80
+ - Report per group: the cause, the fix, the spec files you re-ran, and what still fails.
@@ -15,6 +15,10 @@ continue in a fresh session. Two modes:
15
15
  - **save** ("handoff", "wrap up") - write a handoff file.
16
16
  - **resume** ("continue from the handoff") - read one and continue the work.
17
17
 
18
+ Text after the slug or the name is an instruction from the user, in both modes. In save mode it is
19
+ for the session that resumes, so it must reach that session word for word. In resume mode it wins
20
+ over the Next steps of the file where the two conflict.
21
+
18
22
  Name one invocation form to the user - your own - and never both. The skill is
19
23
  installed as `ethlete-handoff`, so under Claude Code the commands are
20
24
  `/ethlete-handoff` and `/ethlete-handoff resume [name]`; there is no `/handoff`.
@@ -89,7 +93,13 @@ noise, and it costs the next session the tokens to read it.
89
93
 
90
94
  Then give a prompt the user can copy into the fresh session, in a code block: the next task, the
91
95
  plan file or finding it lives in, and any rule of this session that the repo does not record. Keep
92
- it to a few lines. When no next task is known, say so instead.
96
+ it to a few lines.
97
+
98
+ An open item counts as the next task: a known gap, an open finding, or an unbuilt step in the plan,
99
+ in the task, or in your own report of this session. "Everything waits for the user" is true only
100
+ when every open item needs a decision of the user. Then name each decision; do not stop silently.
101
+ In a `tmux` session, restart yourself with the next task as the prompt (see the next section) instead
102
+ of handing the prompt to the user.
93
103
 
94
104
  Write `{%handoffDir%}/<slug>.md` where `<slug>` is a short kebab-case name for
95
105
  the task (use the user-provided slug if they gave one). If the file exists,
@@ -152,12 +162,48 @@ map, not a transcript.
152
162
  After writing, tell the user where it landed and that a fresh session should
153
163
  resume from it.
154
164
 
165
+ ## Clear and resume on your own
166
+
167
+ Run inside a `tmux` session started with the agent-sessions `run` command or the shell functions from `shell-install` (`echo $TMUX` is not empty and
168
+ the agent-sessions script `restart` finds the session)? Then you may clear and
169
+ resume yourself after the save, instead of asking the user to. You decide. Do it only if every line
170
+ is true:
171
+
172
+ - No sub-agent, workflow or background command still runs. A background command from the old
173
+ session reports back into the new one, and a sub-agent answer has no reader after the clear.
174
+ - No answer you wait for is open: a peer session, a mailbox reply, a CI run.
175
+ - The tree is committed, or the handoff file names every uncommitted change.
176
+ - The handoff file is saved and its Next steps are concrete.
177
+
178
+ If one line is false, finish or collect that first. If you cannot, hand the prompt to the user as
179
+ before. Then run, with the slug of the file you saved:
180
+
181
+ ```bash
182
+ node <sessions-skill-dir>/agent-sessions.mjs restart <name> '<resume command> <slug> [<text after the slug>]'
183
+ ```
184
+
185
+ If the user wrote text after the slug of the save command, append it to the prompt word for word.
186
+ Escape each `'` in it as `'\''`. The restart drops anything you leave out, and the fresh session has
187
+ no other way to see it.
188
+
189
+ `<resume command>` is your own resume form from the top of this guide, with the name this repo
190
+ installed the skill under: `/ethlete-handoff resume` under Claude Code, `ethlete-handoff resume`
191
+ under Codex. A repo that ships its own copy can name it `handoff`, so check the skill directory
192
+ name. A wrong name runs no skill, and the fresh session starts without the file.
193
+
194
+ `<name>` is the sender name of your own session. Read it first with `tmux display-message -p -t "$TMUX_PANE" '#{session_name}'` and drop the `@<repo>` part: `claude-2@ethlete-sdk` gives `claude-2`. Never take the name from an example: a second Claude session is `claude-2`, not `claude`. The command refuses a session you do not run in. It returns at once. It
195
+ types `/clear` (`/new` for a `codex` name) and the prompt into your pane a few seconds later, so
196
+ end your turn after you run it and do not start another tool call. The `/clear` wipes the pane, so
197
+ the user never sees text you write after the command. Before you run it, put the reason as the
198
+ first line of the handoff file: `Restarted because: <one line>`. End the turn with no text.
199
+
155
200
  ## Resume mode
156
201
 
157
202
  1. List `{%handoffDir%}/`. Pick the file matching the given name, or the most
158
203
  recently modified one if no name was given. If the directory is empty, say so
159
204
  and stop.
160
- 2. Read the file fully.
205
+ 2. Read the file fully. If its first line starts with `Restarted because:`, show that line to the
206
+ user first, then delete it from the file.
161
207
  3. Verify reality still matches: current branch, `git status`, last commit. If
162
208
  they diverge from the handoff (e.g. someone committed in between), say what
163
209
  changed and adapt - the repo is the truth, the handoff is the guide.
@@ -86,6 +86,13 @@ Every task carries a `kind`, and the kind decides who acts:
86
86
  3. Delete the task files you finished under `.ethlete/update`, and leave the ones you could not decide.
87
87
  4. Tell the user, per task: what you changed, and what still needs their decision.
88
88
 
89
+ ## Known traps
90
+
91
+ - **Angular 22: `a?.b` gives `undefined`, not `null`.** Once the `$safeNavigationMigration` flag is
92
+ gone, a pipe that treats `null` and `undefined` differently breaks at runtime, with no type error. A
93
+ pipe that resets its state on `undefined` returns `null`, and the template then throws on a member
94
+ read. Pass `null` explicitly: `(x?.store ?? null) | suspense`.
95
+
89
96
  ## Rules
90
97
 
91
98
  - **Never invent a migration.** Only the tasks in `.ethlete/update` and the guides they link are real.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ethlete/agent-rules",
3
- "version": "0.1.0-next.27",
3
+ "version": "0.1.0-next.28",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/ethlete-io/ethdk",
package/src/index.js CHANGED
@@ -21,6 +21,7 @@ const cli_args_1 = require("./lib/cli-args");
21
21
  const config_1 = require("./lib/config");
22
22
  const git_flow_command_1 = require("./lib/git-flow-command");
23
23
  const load_content_1 = require("./lib/load-content");
24
+ const sessions_command_1 = require("./lib/sessions-command");
24
25
  const migrate_1 = require("./lib/migrate");
25
26
  const output_style_command_1 = require("./lib/output-style-command");
26
27
  const package_runner_1 = require("./lib/package-runner");
@@ -40,6 +41,9 @@ const USAGE = `ethlete-agents — compile @ethlete agent rules and skills into y
40
41
  ethlete-agents timetrack Ask the running Timetrack app about Jira - it holds this
41
42
  machine's credentials, so no repo needs a token
42
43
  (status, instance, issue, search, project, create, log)
44
+ ethlete-agents sessions Run agents in tmux sessions of one checkout: message them, wake idle
45
+ ones, restart one with a prompt, claim paths (run, up, down, restart,
46
+ send, inbox, claim). The old name "mailbox" works too
43
47
  ethlete-agents migrate Convert the repo to the AGENTS.md + .agents/skills layout:
44
48
  CLAUDE.md content moves into AGENTS.md (CLAUDE.md becomes an
45
49
  @AGENTS.md import), hand-written .claude/skills move to
@@ -112,6 +116,9 @@ const run = (argv) => {
112
116
  switch (command) {
113
117
  case 'git-flow':
114
118
  return (0, git_flow_command_1.gitFlowCommand)({ root, argv: argv.slice(1) });
119
+ case 'sessions':
120
+ case 'mailbox':
121
+ return (0, sessions_command_1.sessionsCommand)({ argv: argv.slice(1) });
115
122
  case 'output-style':
116
123
  return (0, output_style_command_1.outputStyleCommand)({ argv: argv.slice(1) });
117
124
  case 'timetrack':