@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.
- package/AGENTS.md +28 -0
- package/CHANGELOG.md +480 -0
- package/CLAUDE.md +1 -0
- package/README.md +362 -29
- package/THIRD-PARTY-LICENSES.md +35 -0
- package/content/defaults.json +1 -1
- package/content/git-hooks/post-checkout.sh +10 -0
- package/content/git-hooks/pre-push.sh +5 -0
- package/content/hooks/context-warning.py +872 -110
- package/content/hooks/subagent-model-policy.py +194 -0
- package/content/mods/.claude-plugin/marketplace.json +11 -0
- package/content/mods/plugins/e2e-progress/.claude-plugin/plugin.json +6 -0
- package/content/mods/plugins/e2e-progress/hooks/hooks.json +1 -0
- package/content/mods/plugins/e2e-progress/hooks/register.tsx +442 -0
- package/content/mods/plugins/e2e-progress/tsconfig.json +3 -0
- package/content/mods/plugins/e2e-progress/types/index.d.ts +23 -0
- package/content/output-styles/ste-clarity.md +131 -0
- package/content/rules/agent-sessions-wake-up.md +11 -0
- package/content/rules/app-styling.md +66 -0
- package/content/rules/comments.md +4 -1
- package/content/rules/lint-and-format.md +7 -5
- package/content/rules/nx-layout.md +50 -0
- package/content/rules/reactive-state.md +1 -1
- package/content/rules/styling.md +9 -7
- package/content/rules/subagent-models.md +39 -0
- package/content/skills/agent-sessions/SKILL.md +81 -0
- package/content/skills/agent-sessions/agent-sessions.mjs +498 -0
- package/content/skills/angular-patterns/SKILL.md +30 -7
- package/content/skills/api-source/SKILL.md +156 -0
- package/content/skills/app-testing/SKILL.md +141 -0
- package/content/skills/codex-subagent/SKILL.md +90 -0
- package/content/skills/codex-subagent/codex-agent.mjs +229 -0
- package/content/skills/design-exploration/SKILL.md +687 -0
- package/content/skills/design-exploration/check-story.mjs +139 -0
- package/content/skills/design-exploration/shoot-template.mjs +61 -0
- package/content/skills/domain-modeling/ADR-FORMAT.md +47 -0
- package/content/skills/domain-modeling/CONTEXT-FORMAT.md +60 -0
- package/content/skills/domain-modeling/SKILL.md +80 -0
- package/content/skills/e2e-triage/SKILL.md +80 -0
- package/content/skills/figma-export/SKILL.md +191 -0
- package/content/skills/figma-export/dump-figma-layers.py +83 -0
- package/content/skills/figma-export/dump-figma-svg.py +235 -0
- package/content/skills/figma-export/measure-template.mjs +87 -0
- package/content/skills/git-commit/SKILL.md +11 -7
- package/content/skills/git-flow/SKILL.md +87 -0
- package/content/skills/grill-with-docs/SKILL.md +13 -0
- package/content/skills/grilling/SKILL.md +51 -0
- package/content/skills/handoff/SKILL.md +125 -5
- package/content/skills/query/SKILL.md +165 -64
- package/content/skills/rxjs-signals/SKILL.md +33 -4
- package/content/skills/sdk-docs/SKILL.md +50 -14
- package/content/skills/sdk-local-build/SKILL.md +26 -8
- package/content/skills/sdk-local-build/sdk-local-baseline.mjs +64 -0
- package/content/skills/sdk-source/SKILL.md +37 -34
- package/content/skills/sdk-update/SKILL.md +103 -0
- package/content/skills/story-styling/SKILL.md +10 -8
- package/content/skills/styleguide/SKILL.md +12 -7
- package/content/skills/styleguide/assets.md +54 -0
- package/content/skills/styleguide/changesets.md +17 -0
- package/content/skills/styleguide/file-structure.md +73 -0
- package/content/skills/styleguide/lint-rule-lookup.md +51 -0
- package/content/skills/styleguide/storybook-structure.md +25 -0
- package/content/skills/theming/SKILL.md +50 -28
- package/content/skills/timetrack/SKILL.md +397 -0
- package/content/skills/verify-in-app/SKILL.md +107 -0
- package/migrations/app-styling-utilities.md +63 -0
- package/migrations/list-state-query-form.md +20 -0
- package/migrations/nx-layout.md +29 -0
- package/migrations/sdk-components-over-hand-built-ui.md +27 -0
- package/migrations/search-query-field.md +52 -0
- package/migrations.json +48 -0
- package/package.json +19 -3
- package/src/index.js +85 -30
- package/src/index.js.map +1 -1
- package/src/lib/cli-args.d.ts +9 -0
- package/src/lib/cli-args.js +48 -0
- package/src/lib/cli-args.js.map +1 -0
- package/src/lib/commitlint.d.ts +10 -0
- package/src/lib/commitlint.js +51 -0
- package/src/lib/commitlint.js.map +1 -0
- package/src/lib/config.d.ts +41 -5
- package/src/lib/config.js +117 -10
- package/src/lib/config.js.map +1 -1
- package/src/lib/filter.d.ts +2 -0
- package/src/lib/filter.js +4 -4
- package/src/lib/filter.js.map +1 -1
- package/src/lib/frontmatter.d.ts +2 -0
- package/src/lib/frontmatter.js +33 -5
- package/src/lib/frontmatter.js.map +1 -1
- package/src/lib/git-flow/build.d.ts +35 -0
- package/src/lib/git-flow/build.js +24 -0
- package/src/lib/git-flow/build.js.map +1 -0
- package/src/lib/git-flow/config.d.ts +59 -0
- package/src/lib/git-flow/config.js +50 -0
- package/src/lib/git-flow/config.js.map +1 -0
- package/src/lib/git-flow/index.d.ts +6 -0
- package/src/lib/git-flow/index.js +23 -0
- package/src/lib/git-flow/index.js.map +1 -0
- package/src/lib/git-flow/parse.d.ts +49 -0
- package/src/lib/git-flow/parse.js +300 -0
- package/src/lib/git-flow/parse.js.map +1 -0
- package/src/lib/git-flow/rename.d.ts +24 -0
- package/src/lib/git-flow/rename.js +70 -0
- package/src/lib/git-flow/rename.js.map +1 -0
- package/src/lib/git-flow/start.d.ts +49 -0
- package/src/lib/git-flow/start.js +57 -0
- package/src/lib/git-flow/start.js.map +1 -0
- package/src/lib/git-flow/validate.d.ts +34 -0
- package/src/lib/git-flow/validate.js +72 -0
- package/src/lib/git-flow/validate.js.map +1 -0
- package/src/lib/git-flow-command.d.ts +4 -0
- package/src/lib/git-flow-command.js +165 -0
- package/src/lib/git-flow-command.js.map +1 -0
- package/src/lib/git-flow-repair.d.ts +17 -0
- package/src/lib/git-flow-repair.js +165 -0
- package/src/lib/git-flow-repair.js.map +1 -0
- package/src/lib/git-flow-start.d.ts +20 -0
- package/src/lib/git-flow-start.js +140 -0
- package/src/lib/git-flow-start.js.map +1 -0
- package/src/lib/git.d.ts +64 -0
- package/src/lib/git.js +131 -0
- package/src/lib/git.js.map +1 -0
- package/src/lib/gitlab.d.ts +35 -0
- package/src/lib/gitlab.js +116 -0
- package/src/lib/gitlab.js.map +1 -0
- package/src/lib/index.d.ts +2 -0
- package/src/lib/index.js +26 -11
- package/src/lib/index.js.map +1 -1
- package/src/lib/migrate.js +6 -1
- package/src/lib/migrate.js.map +1 -1
- package/src/lib/output-style-command.d.ts +3 -0
- package/src/lib/output-style-command.js +69 -0
- package/src/lib/output-style-command.js.map +1 -0
- package/src/lib/output-style.d.ts +38 -0
- package/src/lib/output-style.js +134 -0
- package/src/lib/output-style.js.map +1 -0
- package/src/lib/owned-paths.js +27 -3
- package/src/lib/owned-paths.js.map +1 -1
- package/src/lib/package-runner.d.ts +4 -0
- package/src/lib/package-runner.js +86 -0
- package/src/lib/package-runner.js.map +1 -0
- package/src/lib/plain-text.d.ts +9 -0
- package/src/lib/plain-text.js +22 -0
- package/src/lib/plain-text.js.map +1 -0
- package/src/lib/plan.d.ts +6 -6
- package/src/lib/plan.js +164 -40
- package/src/lib/plan.js.map +1 -1
- package/src/lib/prompt.d.ts +8 -0
- package/src/lib/prompt.js +35 -0
- package/src/lib/prompt.js.map +1 -0
- package/src/lib/render.d.ts +25 -2
- package/src/lib/render.js +43 -10
- package/src/lib/render.js.map +1 -1
- package/src/lib/sessions-command.d.ts +4 -0
- package/src/lib/sessions-command.js +16 -0
- package/src/lib/sessions-command.js.map +1 -0
- package/src/lib/sync.d.ts +0 -1
- package/src/lib/sync.js +30 -17
- package/src/lib/sync.js.map +1 -1
- package/src/lib/targets/agents-skills.js +1 -1
- package/src/lib/targets/agents-skills.js.map +1 -1
- package/src/lib/targets/claude-hooks.d.ts +3 -23
- package/src/lib/targets/claude-hooks.js +22 -86
- package/src/lib/targets/claude-hooks.js.map +1 -1
- package/src/lib/targets/claude-mods.d.ts +20 -0
- package/src/lib/targets/claude-mods.js +47 -0
- package/src/lib/targets/claude-mods.js.map +1 -0
- package/src/lib/targets/claude.js +2 -3
- package/src/lib/targets/claude.js.map +1 -1
- package/src/lib/targets/codex-hooks.d.ts +12 -0
- package/src/lib/targets/codex-hooks.js +35 -0
- package/src/lib/targets/codex-hooks.js.map +1 -0
- package/src/lib/targets/codex.js +4 -4
- package/src/lib/targets/codex.js.map +1 -1
- package/src/lib/targets/copilot.js +4 -4
- package/src/lib/targets/copilot.js.map +1 -1
- package/src/lib/targets/cursor.js +3 -3
- package/src/lib/targets/cursor.js.map +1 -1
- package/src/lib/targets/git-hooks.d.ts +25 -0
- package/src/lib/targets/git-hooks.js +71 -0
- package/src/lib/targets/git-hooks.js.map +1 -0
- package/src/lib/targets/hooks-shared.d.ts +60 -0
- package/src/lib/targets/hooks-shared.js +130 -0
- package/src/lib/targets/hooks-shared.js.map +1 -0
- package/src/lib/targets/mcp.d.ts +16 -0
- package/src/lib/targets/mcp.js +68 -0
- package/src/lib/targets/mcp.js.map +1 -0
- package/src/lib/targets/shared.d.ts +1 -9
- package/src/lib/targets/shared.js +4 -8
- package/src/lib/targets/shared.js.map +1 -1
- package/src/lib/timetrack-command.d.ts +11 -0
- package/src/lib/timetrack-command.js +1063 -0
- package/src/lib/timetrack-command.js.map +1 -0
- package/src/lib/timetrack-snapshot.d.ts +55 -0
- package/src/lib/timetrack-snapshot.js +120 -0
- package/src/lib/timetrack-snapshot.js.map +1 -0
- package/src/lib/timetrack.d.ts +561 -0
- package/src/lib/timetrack.js +279 -0
- package/src/lib/timetrack.js.map +1 -0
- 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.
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
|
55
|
-
|
|
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
|
|