@ethlete/agent-rules 0.1.0-next.3 → 0.1.0-next.31

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 (200) hide show
  1. package/AGENTS.md +28 -0
  2. package/CHANGELOG.md +480 -0
  3. package/CLAUDE.md +1 -0
  4. package/README.md +362 -29
  5. package/THIRD-PARTY-LICENSES.md +35 -0
  6. package/content/defaults.json +1 -1
  7. package/content/git-hooks/post-checkout.sh +10 -0
  8. package/content/git-hooks/pre-push.sh +5 -0
  9. package/content/hooks/context-warning.py +872 -110
  10. package/content/hooks/subagent-model-policy.py +194 -0
  11. package/content/mods/.claude-plugin/marketplace.json +11 -0
  12. package/content/mods/plugins/e2e-progress/.claude-plugin/plugin.json +6 -0
  13. package/content/mods/plugins/e2e-progress/hooks/hooks.json +1 -0
  14. package/content/mods/plugins/e2e-progress/hooks/register.tsx +442 -0
  15. package/content/mods/plugins/e2e-progress/tsconfig.json +3 -0
  16. package/content/mods/plugins/e2e-progress/types/index.d.ts +23 -0
  17. package/content/output-styles/ste-clarity.md +131 -0
  18. package/content/rules/agent-sessions-wake-up.md +11 -0
  19. package/content/rules/app-styling.md +66 -0
  20. package/content/rules/comments.md +4 -1
  21. package/content/rules/lint-and-format.md +7 -5
  22. package/content/rules/nx-layout.md +50 -0
  23. package/content/rules/reactive-state.md +1 -1
  24. package/content/rules/styling.md +9 -7
  25. package/content/rules/subagent-models.md +39 -0
  26. package/content/skills/agent-sessions/SKILL.md +81 -0
  27. package/content/skills/agent-sessions/agent-sessions.mjs +498 -0
  28. package/content/skills/angular-patterns/SKILL.md +30 -7
  29. package/content/skills/api-source/SKILL.md +156 -0
  30. package/content/skills/app-testing/SKILL.md +141 -0
  31. package/content/skills/codex-subagent/SKILL.md +90 -0
  32. package/content/skills/codex-subagent/codex-agent.mjs +229 -0
  33. package/content/skills/design-exploration/SKILL.md +687 -0
  34. package/content/skills/design-exploration/check-story.mjs +139 -0
  35. package/content/skills/design-exploration/shoot-template.mjs +61 -0
  36. package/content/skills/domain-modeling/ADR-FORMAT.md +47 -0
  37. package/content/skills/domain-modeling/CONTEXT-FORMAT.md +60 -0
  38. package/content/skills/domain-modeling/SKILL.md +80 -0
  39. package/content/skills/e2e-triage/SKILL.md +80 -0
  40. package/content/skills/figma-export/SKILL.md +191 -0
  41. package/content/skills/figma-export/dump-figma-layers.py +83 -0
  42. package/content/skills/figma-export/dump-figma-svg.py +235 -0
  43. package/content/skills/figma-export/measure-template.mjs +87 -0
  44. package/content/skills/git-commit/SKILL.md +11 -7
  45. package/content/skills/git-flow/SKILL.md +87 -0
  46. package/content/skills/grill-with-docs/SKILL.md +13 -0
  47. package/content/skills/grilling/SKILL.md +51 -0
  48. package/content/skills/handoff/SKILL.md +125 -5
  49. package/content/skills/query/SKILL.md +165 -64
  50. package/content/skills/rxjs-signals/SKILL.md +33 -4
  51. package/content/skills/sdk-docs/SKILL.md +50 -14
  52. package/content/skills/sdk-local-build/SKILL.md +26 -8
  53. package/content/skills/sdk-local-build/sdk-local-baseline.mjs +64 -0
  54. package/content/skills/sdk-source/SKILL.md +37 -34
  55. package/content/skills/sdk-update/SKILL.md +103 -0
  56. package/content/skills/story-styling/SKILL.md +10 -8
  57. package/content/skills/styleguide/SKILL.md +12 -7
  58. package/content/skills/styleguide/assets.md +54 -0
  59. package/content/skills/styleguide/changesets.md +17 -0
  60. package/content/skills/styleguide/file-structure.md +73 -0
  61. package/content/skills/styleguide/lint-rule-lookup.md +51 -0
  62. package/content/skills/styleguide/storybook-structure.md +25 -0
  63. package/content/skills/theming/SKILL.md +50 -28
  64. package/content/skills/timetrack/SKILL.md +397 -0
  65. package/content/skills/verify-in-app/SKILL.md +107 -0
  66. package/migrations/app-styling-utilities.md +63 -0
  67. package/migrations/list-state-query-form.md +20 -0
  68. package/migrations/nx-layout.md +29 -0
  69. package/migrations/sdk-components-over-hand-built-ui.md +27 -0
  70. package/migrations/search-query-field.md +52 -0
  71. package/migrations.json +48 -0
  72. package/package.json +19 -3
  73. package/src/index.js +85 -30
  74. package/src/index.js.map +1 -1
  75. package/src/lib/cli-args.d.ts +9 -0
  76. package/src/lib/cli-args.js +48 -0
  77. package/src/lib/cli-args.js.map +1 -0
  78. package/src/lib/commitlint.d.ts +10 -0
  79. package/src/lib/commitlint.js +51 -0
  80. package/src/lib/commitlint.js.map +1 -0
  81. package/src/lib/config.d.ts +41 -5
  82. package/src/lib/config.js +117 -10
  83. package/src/lib/config.js.map +1 -1
  84. package/src/lib/filter.d.ts +2 -0
  85. package/src/lib/filter.js +4 -4
  86. package/src/lib/filter.js.map +1 -1
  87. package/src/lib/frontmatter.d.ts +2 -0
  88. package/src/lib/frontmatter.js +33 -5
  89. package/src/lib/frontmatter.js.map +1 -1
  90. package/src/lib/git-flow/build.d.ts +35 -0
  91. package/src/lib/git-flow/build.js +24 -0
  92. package/src/lib/git-flow/build.js.map +1 -0
  93. package/src/lib/git-flow/config.d.ts +59 -0
  94. package/src/lib/git-flow/config.js +50 -0
  95. package/src/lib/git-flow/config.js.map +1 -0
  96. package/src/lib/git-flow/index.d.ts +6 -0
  97. package/src/lib/git-flow/index.js +23 -0
  98. package/src/lib/git-flow/index.js.map +1 -0
  99. package/src/lib/git-flow/parse.d.ts +49 -0
  100. package/src/lib/git-flow/parse.js +300 -0
  101. package/src/lib/git-flow/parse.js.map +1 -0
  102. package/src/lib/git-flow/rename.d.ts +24 -0
  103. package/src/lib/git-flow/rename.js +70 -0
  104. package/src/lib/git-flow/rename.js.map +1 -0
  105. package/src/lib/git-flow/start.d.ts +49 -0
  106. package/src/lib/git-flow/start.js +57 -0
  107. package/src/lib/git-flow/start.js.map +1 -0
  108. package/src/lib/git-flow/validate.d.ts +34 -0
  109. package/src/lib/git-flow/validate.js +72 -0
  110. package/src/lib/git-flow/validate.js.map +1 -0
  111. package/src/lib/git-flow-command.d.ts +4 -0
  112. package/src/lib/git-flow-command.js +165 -0
  113. package/src/lib/git-flow-command.js.map +1 -0
  114. package/src/lib/git-flow-repair.d.ts +17 -0
  115. package/src/lib/git-flow-repair.js +165 -0
  116. package/src/lib/git-flow-repair.js.map +1 -0
  117. package/src/lib/git-flow-start.d.ts +20 -0
  118. package/src/lib/git-flow-start.js +140 -0
  119. package/src/lib/git-flow-start.js.map +1 -0
  120. package/src/lib/git.d.ts +64 -0
  121. package/src/lib/git.js +131 -0
  122. package/src/lib/git.js.map +1 -0
  123. package/src/lib/gitlab.d.ts +35 -0
  124. package/src/lib/gitlab.js +116 -0
  125. package/src/lib/gitlab.js.map +1 -0
  126. package/src/lib/index.d.ts +2 -0
  127. package/src/lib/index.js +26 -11
  128. package/src/lib/index.js.map +1 -1
  129. package/src/lib/migrate.js +6 -1
  130. package/src/lib/migrate.js.map +1 -1
  131. package/src/lib/output-style-command.d.ts +3 -0
  132. package/src/lib/output-style-command.js +69 -0
  133. package/src/lib/output-style-command.js.map +1 -0
  134. package/src/lib/output-style.d.ts +38 -0
  135. package/src/lib/output-style.js +134 -0
  136. package/src/lib/output-style.js.map +1 -0
  137. package/src/lib/owned-paths.js +27 -3
  138. package/src/lib/owned-paths.js.map +1 -1
  139. package/src/lib/package-runner.d.ts +4 -0
  140. package/src/lib/package-runner.js +86 -0
  141. package/src/lib/package-runner.js.map +1 -0
  142. package/src/lib/plain-text.d.ts +9 -0
  143. package/src/lib/plain-text.js +22 -0
  144. package/src/lib/plain-text.js.map +1 -0
  145. package/src/lib/plan.d.ts +6 -6
  146. package/src/lib/plan.js +164 -40
  147. package/src/lib/plan.js.map +1 -1
  148. package/src/lib/prompt.d.ts +8 -0
  149. package/src/lib/prompt.js +35 -0
  150. package/src/lib/prompt.js.map +1 -0
  151. package/src/lib/render.d.ts +25 -2
  152. package/src/lib/render.js +43 -10
  153. package/src/lib/render.js.map +1 -1
  154. package/src/lib/sessions-command.d.ts +4 -0
  155. package/src/lib/sessions-command.js +16 -0
  156. package/src/lib/sessions-command.js.map +1 -0
  157. package/src/lib/sync.d.ts +0 -1
  158. package/src/lib/sync.js +30 -17
  159. package/src/lib/sync.js.map +1 -1
  160. package/src/lib/targets/agents-skills.js +1 -1
  161. package/src/lib/targets/agents-skills.js.map +1 -1
  162. package/src/lib/targets/claude-hooks.d.ts +3 -23
  163. package/src/lib/targets/claude-hooks.js +22 -86
  164. package/src/lib/targets/claude-hooks.js.map +1 -1
  165. package/src/lib/targets/claude-mods.d.ts +20 -0
  166. package/src/lib/targets/claude-mods.js +47 -0
  167. package/src/lib/targets/claude-mods.js.map +1 -0
  168. package/src/lib/targets/claude.js +2 -3
  169. package/src/lib/targets/claude.js.map +1 -1
  170. package/src/lib/targets/codex-hooks.d.ts +12 -0
  171. package/src/lib/targets/codex-hooks.js +35 -0
  172. package/src/lib/targets/codex-hooks.js.map +1 -0
  173. package/src/lib/targets/codex.js +4 -4
  174. package/src/lib/targets/codex.js.map +1 -1
  175. package/src/lib/targets/copilot.js +4 -4
  176. package/src/lib/targets/copilot.js.map +1 -1
  177. package/src/lib/targets/cursor.js +3 -3
  178. package/src/lib/targets/cursor.js.map +1 -1
  179. package/src/lib/targets/git-hooks.d.ts +25 -0
  180. package/src/lib/targets/git-hooks.js +71 -0
  181. package/src/lib/targets/git-hooks.js.map +1 -0
  182. package/src/lib/targets/hooks-shared.d.ts +60 -0
  183. package/src/lib/targets/hooks-shared.js +130 -0
  184. package/src/lib/targets/hooks-shared.js.map +1 -0
  185. package/src/lib/targets/mcp.d.ts +16 -0
  186. package/src/lib/targets/mcp.js +68 -0
  187. package/src/lib/targets/mcp.js.map +1 -0
  188. package/src/lib/targets/shared.d.ts +1 -9
  189. package/src/lib/targets/shared.js +4 -8
  190. package/src/lib/targets/shared.js.map +1 -1
  191. package/src/lib/timetrack-command.d.ts +11 -0
  192. package/src/lib/timetrack-command.js +1063 -0
  193. package/src/lib/timetrack-command.js.map +1 -0
  194. package/src/lib/timetrack-snapshot.d.ts +55 -0
  195. package/src/lib/timetrack-snapshot.js +120 -0
  196. package/src/lib/timetrack-snapshot.js.map +1 -0
  197. package/src/lib/timetrack.d.ts +561 -0
  198. package/src/lib/timetrack.js +279 -0
  199. package/src/lib/timetrack.js.map +1 -0
  200. package/content/skills/styleguide/STYLEGUIDE.md +0 -520
@@ -0,0 +1,498 @@
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 RESTART_POLL_MS = 2000;
39
+ const RESTART_WAIT_MS = 30 * 60 * 1000;
40
+ const RESTART_CLEAR_WAIT_MS = Number(process.env.AGENT_RESTART_CLEAR_WAIT_MS ?? 60 * 1000);
41
+ const repoSlug = basename(root).replace(/[.:]/g, '-');
42
+ const sessionFor = (agent) => `${agent}@${repoSlug}`;
43
+ const DISPATCH_SESSION = process.env.AGENT_MAILBOX_DISPATCH_SESSION ?? `mailbox-dispatch@${repoSlug}`;
44
+
45
+ const takeFlag = (args, name, hasValue) => {
46
+ const index = args.indexOf(name);
47
+ if (index === -1) return undefined;
48
+ const [flag, value] = args.splice(index, hasValue ? 2 : 1);
49
+ return hasValue ? value : flag;
50
+ };
51
+
52
+ const args = process.argv.slice(2);
53
+ const last = takeFlag(args, '--last', true);
54
+ const ttl = takeFlag(args, '--ttl', true);
55
+ const all = takeFlag(args, '--all', false);
56
+ const force = takeFlag(args, '--force', false);
57
+ const waitSeconds = takeFlag(args, '--wait', true);
58
+ const recipient = takeFlag(args, '--to', true);
59
+ const rcFile = takeFlag(args, '--file', true);
60
+ const remove = takeFlag(args, '--remove', false);
61
+ const afterSeconds = takeFlag(args, '--after', true);
62
+ const now = takeFlag(args, '--now', false);
63
+ const dryRun = takeFlag(args, '--dry-run', false);
64
+ const [command, sender, ...words] = args;
65
+
66
+ const help = `Shared agent mailbox: ${mailbox}
67
+
68
+ Send: node ${script} send <name> [--to <agent>] '<message>' (no message: read it from stdin)
69
+ Inbox: node ${script} inbox <name> [--wait <seconds>] (only the messages you have not seen yet;
70
+ --wait blocks until one arrives or the time ends)
71
+ Read: node ${script} read [--last <n>] [--all] (default: the last ${DEFAULT_LAST} messages)
72
+ Watch: node ${script} watch <name>
73
+ Claim: node ${script} claim <name> <path...> [--ttl <hours>] [--force]
74
+ Release: node ${script} release <name> [path...] (no path: release every claim of <name>)
75
+ Claims: node ${script} claims
76
+ Up: node ${script} up <name>[=<command>]... (start a tmux session per agent, plus one that runs dispatch)
77
+ Run: node ${script} run <name> <command...> (start or attach the agent in its tmux session; starts dispatch)
78
+ Install: node ${script} shell-install [<agent>...] [--file <path>] [--remove] [--dry-run]
79
+ (write the shell functions into ~/.zshrc or ~/.bashrc)
80
+ Shell: node ${script} shell-init [<agent>...] (print shell functions that wrap each agent with run; default: claude codex)
81
+ Restart: node ${script} restart <name> [--after <seconds>] <prompt...>
82
+ (type the clear command and then the prompt into the tmux session of <name>)
83
+ Down: node ${script} down [<name>...] (stop dispatch; stop the named sessions too)
84
+ Dispatch: node ${script} dispatch (wake the tmux session named <agent> for each message sent --to <agent>)
85
+
86
+ Protocol: append one JSON object per line; never rewrite or truncate.
87
+ Fields: timestamp (UTC), sender, message. A claim also has paths and expires.
88
+ Pick a sender name no other agent uses. Claim the paths you edit before you edit them.
89
+ A claim is advisory, not a lock: it expires after ${DEFAULT_TTL_HOURS} hours unless --ttl says otherwise.
90
+ Silence is not approval. Acknowledge handoffs explicitly.
91
+ Watch prints existing messages and then new messages from other senders every second.
92
+ Keep credentials and personal data out of the mailbox. No agent is started automatically.
93
+ `;
94
+
95
+ const tmux = (...tmuxArgs) => spawnSync('tmux', tmuxArgs, { encoding: 'utf8' });
96
+ const quote = (text) => `'${text.replaceAll("'", "'\\''")}'`;
97
+ const sessionExists = (name) => tmux('has-session', '-t', `=${name}`).status === 0;
98
+ const ONE_SHOT_FLAGS = new Set(['-p', '--print', '-v', '--version', '-h', '--help']);
99
+ const runsInSession = (agentArgs) =>
100
+ Boolean(process.stdin.isTTY && process.stdout.isTTY) && !agentArgs.some((arg) => ONE_SHOT_FLAGS.has(arg));
101
+
102
+ /**
103
+ * Reads the last prompt line (`❯` for Claude Code, `›` for Codex) of the pane. Dim text is the empty
104
+ * prompt's placeholder, so it does not count. A menu cursor (`❯ 1. Yes`) also counts, which keeps a
105
+ * wake-up line from answering a permission dialog.
106
+ */
107
+ const SGR = `${String.fromCharCode(27)}\\[([0-9;]*)m`;
108
+ const busyLine = (session) =>
109
+ (tmux('capture-pane', '-p', '-t', `=${session}:`).stdout ?? '')
110
+ .split('\n')
111
+ .find((line) => /esc to inte/i.test(line)) ?? null;
112
+ const restartLog = (message) => {
113
+ mkdirSync(dirname(mailbox), { recursive: true });
114
+ appendFileSync(join(dirname(mailbox), 'restart.log'), `${new Date().toISOString()} ${message}\n`);
115
+ };
116
+
117
+ /**
118
+ * Waits until the agent ended its turn and nobody types in its prompt, twice in a row; `false` after the time limit.
119
+ * With `allowBusy`, a running turn does not block, since Claude Code queues input typed during a turn.
120
+ * Each change of the reason it waits is logged to `restart.log` next to the mailbox.
121
+ */
122
+ const waitForQuiet = (session, limitMs = RESTART_WAIT_MS, allowBusy = false) => {
123
+ const end = Date.now() + limitMs;
124
+ let quiet = 0;
125
+ let reason = '';
126
+ while (Date.now() < end) {
127
+ const busy = allowBusy ? null : busyLine(session);
128
+ const draft = busy === null ? draftLine(session) : null;
129
+ const next = busy !== null ? `busy: ${busy.trim()}` : draft !== null ? `draft: ${draft}` : '';
130
+ if (next !== reason) restartLog(`${session} ${next ? `waits, ${next}` : 'is quiet'}`);
131
+ reason = next;
132
+ quiet = next ? 0 : quiet + 1;
133
+ if (quiet === 2) return true;
134
+ sleep(RESTART_POLL_MS);
135
+ }
136
+ return false;
137
+ };
138
+
139
+ /** The IDE context chip (`[⧉ In file.ts]`, `[⧉ 3 lines selected]`) shares the prompt line and survives `/clear`. */
140
+ const IDE_CHIP = /\[⧉ [^\]]*\]/g;
141
+ const draftLine = (session) => {
142
+ const pane = tmux('capture-pane', '-p', '-e', '-t', `=${session}:`).stdout ?? '';
143
+ const prompt = pane
144
+ .split('\n')
145
+ .reverse()
146
+ .find((line) => /^\s*[❯›]/.test(line.replace(new RegExp(SGR, 'g'), '')));
147
+ if (!prompt) return null;
148
+ let dim = false;
149
+ const visible = prompt
150
+ .split(new RegExp(`(${SGR.replace('(', '(?:')})`))
151
+ .map((part) => {
152
+ const codes = part.match(new RegExp(`^${SGR}$`))?.[1].split(';');
153
+ if (!codes) return dim ? '' : part;
154
+ codes.forEach((code) => {
155
+ if (code === '2') dim = true;
156
+ if (code === '' || code === '0' || code === '22') dim = false;
157
+ });
158
+ return '';
159
+ })
160
+ .join('');
161
+ const text = visible
162
+ .replace(/^\s*[❯›]/, '')
163
+ .replace(IDE_CHIP, '')
164
+ .trim();
165
+ return text.length > 0 ? text : null;
166
+ };
167
+ const hasDraft = (session) => draftLine(session) !== null;
168
+ const isAttached = (name) => tmux('list-clients', '-t', `=${name}`).stdout.trim() !== '';
169
+ const freeSessionFor = (agent) => {
170
+ for (let number = 1; ; number++) {
171
+ const session = sessionFor(number === 1 ? agent : `${agent}-${number}`);
172
+ if (!sessionExists(session) || !isAttached(session)) return session;
173
+ }
174
+ };
175
+ const startSession = (name, shellCommand, quiet = false) => {
176
+ if (sessionExists(name)) return quiet ? undefined : process.stdout.write(`Session ${name} already runs.\n`);
177
+ const result = tmux('new-session', '-d', '-s', name, '-c', root, ...(shellCommand ? [shellCommand] : []));
178
+ if (result.status !== 0) return process.stderr.write(`Could not start ${name}: ${result.stderr.trim()}\n`);
179
+ if (!quiet) process.stdout.write(`Started ${name}. Attach with: tmux attach -t ${name}\n`);
180
+ };
181
+
182
+ const IGNORED = ['mailbox.jsonl', 'mailbox.cursor.*', 'restart.log', 'restart.prompt.*'];
183
+
184
+ const prepare = () => {
185
+ mkdirSync(dirname(mailbox), { recursive: true });
186
+ if (process.env.AGENT_MAILBOX) return;
187
+ const ignore = join(dirname(mailbox), '.gitignore');
188
+ const present = existsSync(ignore) ? readFileSync(ignore, 'utf8') : '';
189
+ const missing = IGNORED.filter((line) => !present.split('\n').includes(line));
190
+ if (missing.length)
191
+ writeFileSync(ignore, `${present && !present.endsWith('\n') ? `${present}\n` : present}${missing.join('\n')}\n`);
192
+ };
193
+
194
+ const append = (entry) => {
195
+ prepare();
196
+ appendFileSync(mailbox, `${JSON.stringify({ timestamp: new Date().toISOString(), ...entry })}\n`, 'utf8');
197
+ };
198
+
199
+ const parse = (line) => {
200
+ if (!line.trim()) return undefined;
201
+ try {
202
+ return JSON.parse(line);
203
+ } catch {
204
+ process.stderr.write('Ignoring malformed mailbox line.\n');
205
+ return undefined;
206
+ }
207
+ };
208
+
209
+ const entries = () => (existsSync(mailbox) ? readFileSync(mailbox, 'utf8').split('\n').map(parse).filter(Boolean) : []);
210
+
211
+ const show = (entry) => process.stdout.write(`[${entry.timestamp}] ${entry.sender}: ${entry.message}\n`);
212
+
213
+ const inboxCursor = (reader) => join(dirname(mailbox), `mailbox.cursor.${reader}`);
214
+
215
+ const unreadFor = (reader) => {
216
+ const cursor = inboxCursor(reader);
217
+ const history = entries();
218
+ const seen = existsSync(cursor) ? Number(readFileSync(cursor, 'utf8')) : history.length - DEFAULT_LAST;
219
+ return { count: history.length, unread: history.slice(Math.max(0, seen)).filter((entry) => entry.sender !== reader) };
220
+ };
221
+
222
+ const sleep = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
223
+
224
+ const showInbox = (reader, waitFor = 0) => {
225
+ const deadline = Date.now() + waitFor * 1000;
226
+ let { count, unread } = unreadFor(reader);
227
+ while (!unread.length && Date.now() < deadline) {
228
+ sleep(1000);
229
+ ({ count, unread } = unreadFor(reader));
230
+ }
231
+ if (!unread.length) process.stdout.write('No new messages.\n');
232
+ unread.forEach(show);
233
+ prepare();
234
+ writeFileSync(inboxCursor(reader), String(count));
235
+ };
236
+
237
+ const toRepoPath = (path) => relative(root, resolve(path)).split('\\').join('/');
238
+
239
+ const overlaps = (a, b) => a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`);
240
+
241
+ const activeClaims = () => {
242
+ const now = Date.now();
243
+ const claims = [];
244
+ for (const entry of entries()) {
245
+ if (entry.type === 'claim') {
246
+ for (const path of entry.paths) claims.push({ sender: entry.sender, path, expires: entry.expires });
247
+ } else if (entry.type === 'release') {
248
+ for (let index = claims.length - 1; index >= 0; index--) {
249
+ const claim = claims[index];
250
+ const released = !entry.paths?.length || entry.paths.some((path) => overlaps(path, claim.path));
251
+ if (claim.sender === entry.sender && released) claims.splice(index, 1);
252
+ }
253
+ }
254
+ }
255
+ return claims.filter((claim) => Date.parse(claim.expires) > now);
256
+ };
257
+
258
+ if (command === 'send' && sender) {
259
+ const message = words.length ? words.join(' ') : process.stdin.isTTY ? '' : readFileSync(0, 'utf8').trim();
260
+ if (!message) {
261
+ process.stderr.write('No message given.\n');
262
+ process.exitCode = 1;
263
+ } else {
264
+ append({ sender, message, ...(recipient ? { to: recipient } : {}) });
265
+ process.stdout.write(`Sent as ${sender}.\n`);
266
+ }
267
+ } else if (command === 'up') {
268
+ const withEnv = process.env.AGENT_MAILBOX ? `AGENT_MAILBOX=${quote(process.env.AGENT_MAILBOX)} ` : '';
269
+ startSession(DISPATCH_SESSION, `${withEnv}node ${quote(script)} dispatch`);
270
+ [sender, ...words].filter(Boolean).forEach((spec) => {
271
+ const [name, ...rest] = spec.split('=');
272
+ startSession(sessionFor(name), rest.join('=') || undefined);
273
+ });
274
+ } else if (command === 'run' && sender && words.length && !runsInSession(words.slice(1))) {
275
+ const [program, ...programArgs] = words;
276
+ process.exitCode = spawnSync(program, programArgs, { stdio: 'inherit' }).status ?? 1;
277
+ } else if (command === 'run' && sender && words.length) {
278
+ const withEnv = process.env.AGENT_MAILBOX ? `AGENT_MAILBOX=${quote(process.env.AGENT_MAILBOX)} ` : '';
279
+ if (!sessionExists(DISPATCH_SESSION))
280
+ startSession(DISPATCH_SESSION, `${withEnv}node ${quote(script)} dispatch`, true);
281
+ const session = freeSessionFor(sender);
282
+ const shellCommand = words.map(quote).join(' ');
283
+ if (process.env.TMUX) {
284
+ if (!sessionExists(session)) tmux('new-session', '-d', '-s', session, '-c', process.cwd(), shellCommand);
285
+ process.exitCode = spawnSync('tmux', ['switch-client', '-t', `=${session}`], { stdio: 'inherit' }).status ?? 1;
286
+ } else {
287
+ process.exitCode =
288
+ spawnSync('tmux', ['new-session', '-A', '-s', session, '-c', process.cwd(), shellCommand], { stdio: 'inherit' })
289
+ .status ?? 1;
290
+ }
291
+ } else if (command === 'shell-init' || command === 'shell-install') {
292
+ const agents = [sender, ...words].filter(Boolean);
293
+ const functions = (agents.length ? agents : ['claude', 'codex'])
294
+ .map(
295
+ (agent) => `${agent}() {
296
+ local root script
297
+ root=$(git rev-parse --show-toplevel 2>/dev/null) && script="$root/.agents/skills/ethlete-agent-sessions/agent-sessions.mjs"
298
+ if [ -n "$script" ] && [ -f "$script" ] && command -v tmux >/dev/null; then
299
+ node "$script" run ${agent} ${agent} "$@"
300
+ else
301
+ command ${agent} "$@"
302
+ fi
303
+ }
304
+ `,
305
+ )
306
+ .join('');
307
+ if (command === 'shell-init') {
308
+ process.stdout.write(functions);
309
+ } else {
310
+ const shell = basename(process.env.SHELL ?? '');
311
+ const file = rcFile ?? (shell === 'zsh' ? '.zshrc' : shell === 'bash' ? '.bashrc' : undefined);
312
+ if (!file) {
313
+ process.stderr.write('Unknown shell. Pass --file <path> to the startup file.\n');
314
+ process.exitCode = 1;
315
+ } else {
316
+ const path = rcFile ? resolve(rcFile) : join(homedir(), file);
317
+ const present = existsSync(path) ? readFileSync(path, 'utf8') : '';
318
+ const start = '# >>> ethlete agent-sessions >>>';
319
+ const end = '# <<< ethlete agent-sessions <<<';
320
+ const without = ['agent-mailbox', 'agent-sessions']
321
+ .reduce(
322
+ (text, name) =>
323
+ text.replace(new RegExp(`\\n?# >>> ethlete ${name} >>>[\\s\\S]*?# <<< ethlete ${name} <<<\\n?`), '\n'),
324
+ present,
325
+ )
326
+ .replace(/\n+$/, '\n');
327
+ const block = `${start}\n${functions}${end}\n`;
328
+ const next = remove ? without : `${without}\n${block}`;
329
+ if (dryRun) {
330
+ process.stdout.write(`Would write ${path}:\n${remove ? '(block removed)\n' : block}`);
331
+ } else {
332
+ writeFileSync(path, next);
333
+ process.stdout.write(
334
+ `${remove ? 'Removed the block from' : 'Wrote the block to'} ${path}. Open a new terminal.\n`,
335
+ );
336
+ }
337
+ }
338
+ }
339
+ } else if (command === 'restart' && sender && words.length) {
340
+ const session = [sessionFor(sender), sender].find(sessionExists);
341
+ const own = process.env.TMUX_PANE
342
+ ? tmux('display-message', '-p', '-t', process.env.TMUX_PANE, '#{session_name}').stdout.trim()
343
+ : null;
344
+ if (!session) {
345
+ process.stderr.write(`No tmux session for ${sender}.\n`);
346
+ process.exitCode = 1;
347
+ } else if (own && own !== session) {
348
+ process.stderr.write(
349
+ `Refused: ${session} is not your session. You run in ${own}, so pass ${own.replace(`@${repoSlug}`, '')}.\n`,
350
+ );
351
+ process.exitCode = 1;
352
+ } else if (now) {
353
+ const type = (...keys) => tmux('send-keys', '-t', `=${session}:`, ...keys);
354
+ sleep(Number(afterSeconds ?? RESTART_AFTER_SECONDS) * 1000);
355
+ if (!waitForQuiet(session)) {
356
+ process.exitCode = 1;
357
+ } else {
358
+ type('-l', sender.startsWith('codex') ? '/new' : '/clear');
359
+ sleep(ENTER_DELAY_MS);
360
+ type('Enter');
361
+ sleep(RESTART_CLEAR_MS);
362
+ if (waitForQuiet(session, RESTART_CLEAR_WAIT_MS, true)) {
363
+ type('-l', words.join(' '));
364
+ sleep(ENTER_DELAY_MS);
365
+ type('Enter');
366
+ } else {
367
+ const saved = join(dirname(mailbox), `restart.prompt.${session}.txt`);
368
+ writeFileSync(saved, `${words.join(' ')}\n`);
369
+ restartLog(
370
+ `${session} prompt not empty ${RESTART_CLEAR_WAIT_MS / 1000}s after the clear, prompt not typed, saved to ${saved}`,
371
+ );
372
+ tmux(
373
+ 'display-message',
374
+ '-t',
375
+ `=${session}:`,
376
+ `restart: prompt not empty after the clear, prompt not typed. Saved to ${saved}`,
377
+ );
378
+ process.exitCode = 1;
379
+ }
380
+ }
381
+ } else {
382
+ const delay = Number(afterSeconds ?? RESTART_AFTER_SECONDS);
383
+ spawn(process.execPath, [script, 'restart', sender, '--now', '--after', String(delay), ...words], {
384
+ detached: true,
385
+ stdio: 'ignore',
386
+ }).unref();
387
+ process.stdout.write(
388
+ `Restarting ${session} once its turn ended and its prompt is empty, ${delay} seconds at the earliest. End your turn now.\n`,
389
+ );
390
+ }
391
+ } else if (command === 'down') {
392
+ [DISPATCH_SESSION, ...[sender, ...words].filter(Boolean).map(sessionFor)].forEach((name) => {
393
+ const result = tmux('kill-session', '-t', `=${name}`);
394
+ process.stdout.write(result.status === 0 ? `Stopped ${name}.\n` : `No session ${name}.\n`);
395
+ });
396
+ } else if (command === 'dispatch') {
397
+ prepare();
398
+ appendFileSync(mailbox, '');
399
+ let seen = entries().length;
400
+ const due = {};
401
+ const waiting = new Set();
402
+ const wake = (agent) => {
403
+ if (!unreadFor(agent).unread.length) return;
404
+ const text = `New mailbox message for ${agent}. Run: ${inboxCommand(agent)}`;
405
+ const session = [sessionFor(agent), agent].find(sessionExists);
406
+ if (!session) return process.stderr.write(`No tmux session for ${agent}.\n`);
407
+ if (hasDraft(session)) {
408
+ if (!waiting.has(agent)) process.stdout.write(`Waiting for the typed text in ${session}.\n`);
409
+ waiting.add(agent);
410
+ due[agent] = Date.now() + DRAFT_RETRY_MS;
411
+ return;
412
+ }
413
+ waiting.delete(agent);
414
+ spawnSync('tmux', ['send-keys', '-t', `=${session}:`, '-l', text]);
415
+ sleep(ENTER_DELAY_MS);
416
+ spawnSync('tmux', ['send-keys', '-t', `=${session}:`, 'Enter']);
417
+ process.stdout.write(`Woke ${session}.\n`);
418
+ };
419
+ const poll = () => {
420
+ const history = entries();
421
+ const fresh = history.slice(seen);
422
+ seen = history.length;
423
+ fresh
424
+ .filter((entry) => entry.to && entry.to !== entry.sender)
425
+ .forEach((entry) => (due[entry.to] ??= Date.now() + WAKE_GRACE_MS));
426
+ Object.keys(due)
427
+ .filter((agent) => due[agent] <= Date.now())
428
+ .forEach((agent) => {
429
+ delete due[agent];
430
+ wake(agent);
431
+ });
432
+ };
433
+ process.stdout.write(`Dispatching for ${resolve(mailbox)}\n`);
434
+ setInterval(poll, 1000);
435
+ } else if (command === 'inbox' && sender) {
436
+ showInbox(sender, Number(waitSeconds ?? 0));
437
+ } else if (command === 'read') {
438
+ const history = entries();
439
+ (all ? history : history.slice(-Number(last ?? DEFAULT_LAST))).forEach(show);
440
+ } else if (command === 'claims') {
441
+ const claims = activeClaims();
442
+ if (!claims.length) process.stdout.write('No active claims.\n');
443
+ claims.forEach((claim) => process.stdout.write(`${claim.path} ${claim.sender} until ${claim.expires}\n`));
444
+ } else if (command === 'claim' && sender && words.length) {
445
+ showInbox(sender);
446
+ const paths = words.map(toRepoPath);
447
+ const conflicts = activeClaims().filter(
448
+ (claim) => claim.sender !== sender && paths.some((path) => overlaps(path, claim.path)),
449
+ );
450
+ if (conflicts.length && !force) {
451
+ conflicts.forEach((claim) =>
452
+ process.stderr.write(`Claimed by ${claim.sender}: ${claim.path} until ${claim.expires}\n`),
453
+ );
454
+ process.stderr.write('Ask for a handoff, or pass --force to claim anyway.\n');
455
+ process.exitCode = 2;
456
+ } else {
457
+ const hours = ttl === undefined ? DEFAULT_TTL_HOURS : Number(ttl);
458
+ const expires = new Date(Date.now() + hours * 3_600_000).toISOString();
459
+ append({ sender, type: 'claim', paths, expires, message: `claims ${paths.join(', ')}` });
460
+ process.stdout.write(`Claimed ${paths.join(', ')} as ${sender} until ${expires}.\n`);
461
+ }
462
+ } else if (command === 'release' && sender) {
463
+ const paths = words.map(toRepoPath);
464
+ append({
465
+ sender,
466
+ type: 'release',
467
+ paths,
468
+ message: paths.length ? `releases ${paths.join(', ')}` : 'releases all claims',
469
+ });
470
+ process.stdout.write(`Released as ${sender}.\n`);
471
+ } else if (command === 'watch' && sender) {
472
+ prepare();
473
+ appendFileSync(mailbox, '');
474
+ let consumed = 0;
475
+ let pending = '';
476
+ const update = () => {
477
+ const contents = readFileSync(mailbox, 'utf8');
478
+ if (contents.length < consumed) {
479
+ process.stderr.write('Mailbox was truncated; restarting read.\n');
480
+ consumed = 0;
481
+ pending = '';
482
+ }
483
+ pending += contents.slice(consumed);
484
+ consumed = contents.length;
485
+ const lines = pending.split('\n');
486
+ pending = lines.pop() ?? '';
487
+ lines
488
+ .map(parse)
489
+ .filter((entry) => entry && entry.sender !== sender)
490
+ .forEach(show);
491
+ };
492
+ process.stdout.write(`Watching ${resolve(mailbox)} as ${sender}\n`);
493
+ update();
494
+ watchFile(mailbox, { interval: 1000 }, update);
495
+ } else {
496
+ process.stdout.write(help);
497
+ if (command && command !== '--help') process.exitCode = 1;
498
+ }
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: angular-patterns
3
- description: How to build Angular pieces the Ethlete way - templates, lifecycle, and when to reach for a component/directive/service/pipe vs a plain function. Read when writing or restructuring a component, directive, service, or pipe, wiring up lifecycle, or binding values in a template. Part of the Ethlete styleguide (judgment beyond what lint enforces).
3
+ description: How to build Angular pieces the Ethlete way - templates, lifecycle, and when to reach for a component/directive/service/pipe vs a plain function. Read when writing or restructuring a component, directive, service, or pipe, wiring up lifecycle, or binding values in a template.
4
4
  kind: skill
5
5
  scope: both
6
6
  requires: ['@ethlete/core']
@@ -22,10 +22,20 @@ DOM/`window`, output naming, class-member + decorator-metadata order, no
22
22
 
23
23
  ```html
24
24
  <!-- ❌ runs every CD cycle -->
25
- <button [disabled]="isDisabled()">
26
- <!-- ✅ computed signal -->
27
- <button [disabled]="disabled()"></button>
28
- </button>
25
+ <button [disabled]="isDisabled()"></button>
26
+
27
+ <!-- ✅ computed signal -->
28
+ <button [disabled]="disabled()"></button>
29
+ ```
30
+
31
+ - **Per-row formatting belongs in the data, not in a method per row.** Map the rows once
32
+ in a `computed()` and bind the prepared fields. A pure, module-level formatting function
33
+ is acceptable too; a component method called for each row in `@for` is not.
34
+
35
+ ```ts
36
+ // ❌ <td>{{ formatDate(row.createdAt) }}</td> - re-runs for every row, every CD cycle
37
+ // ✅
38
+ rows = computed(() => this.items().map((item) => ({ ...item, createdAtLabel: formatDate(item.createdAt) })));
29
39
  ```
30
40
 
31
41
  ## Lifecycle
@@ -40,6 +50,10 @@ DOM/`window`, output naming, class-member + decorator-metadata order, no
40
50
  `createRootProvider` and the `injectX()` helper pattern from `@ethlete/core`
41
51
  rather than an `@Injectable` or `@Service`. (Both decorators are lint-banned;
42
52
  choosing a function over a service at all is the judgment.)
53
+ - **Repeated component logic → one `injectX()` function.** When two components carry the
54
+ same signals, effects or subscriptions, extract them into an `injectX()` that runs in the
55
+ injection context and returns what the components bind. Do not copy the block, and do not
56
+ reach for a base class.
43
57
  - **Directives → plain functions where possible.** With signal APIs, move the core
44
58
  logic into a function so it's reusable without applying a directive; keep a
45
59
  directive only when a host element genuinely needs it. Avoid common input/output
@@ -47,12 +61,21 @@ DOM/`window`, output naming, class-member + decorator-metadata order, no
47
61
  - **Pipes → a `computed()` calling a utility function.** Pipes carry no logic;
48
62
  most can be dropped in favour of a `computed`.
49
63
 
64
+ ## Query params
65
+
66
+ - **Read** them with the `@ethlete/core` signals: `injectQueryParam(key)`,
67
+ `injectQueryParams()`. `ActivatedRoute` and `router.url` are lint-banned.
68
+ - **Write** them with `inject(Router).navigate([], { queryParams, queryParamsHandling: 'merge' })`.
69
+ `merge` keeps the params you do not set; `null` removes a param.
70
+ - **Filter, sort and page state bound to the URL** is a query form: with `@ethlete/query`,
71
+ use `defineQueryForm` instead of writing the params by hand.
72
+
50
73
  ## Components
51
74
 
52
75
  - Inline template/styles for small components; external `.html` / `.css` files
53
76
  for complex ones.
54
- - Component CSS is plain CSS wrapped in `@layer components`, with every colour coming
55
- from a theme token — see {%skill:theming%}.
77
+ - Component CSS is plain CSS wrapped in `@layer components`, with every primary colour
78
+ value coming from the repository's theme tokens.
56
79
 
57
80
  ## Reactive state
58
81