@ethlete/agent-rules 0.1.0-next.2 → 0.1.0-next.21

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 (178) hide show
  1. package/CHANGELOG.md +275 -0
  2. package/README.md +350 -29
  3. package/THIRD-PARTY-LICENSES.md +35 -0
  4. package/content/git-hooks/post-checkout.sh +10 -0
  5. package/content/git-hooks/pre-push.sh +5 -0
  6. package/content/hooks/context-warning.py +766 -108
  7. package/content/hooks/subagent-model-policy.py +193 -0
  8. package/content/output-styles/ste-clarity.md +131 -0
  9. package/content/rules/app-styling.md +59 -0
  10. package/content/rules/comments.md +53 -20
  11. package/content/rules/lint-and-format.md +6 -5
  12. package/content/rules/nx-layout.md +47 -0
  13. package/content/rules/reactive-state.md +1 -1
  14. package/content/rules/styling.md +9 -7
  15. package/content/rules/subagent-models.md +29 -0
  16. package/content/skills/angular-patterns/SKILL.md +30 -7
  17. package/content/skills/api-source/SKILL.md +130 -0
  18. package/content/skills/app-testing/SKILL.md +141 -0
  19. package/content/skills/codex-subagent/SKILL.md +90 -0
  20. package/content/skills/codex-subagent/codex-agent.mjs +229 -0
  21. package/content/skills/design-exploration/SKILL.md +205 -0
  22. package/content/skills/design-exploration/check-story.mjs +139 -0
  23. package/content/skills/design-exploration/shoot-template.mjs +61 -0
  24. package/content/skills/domain-modeling/ADR-FORMAT.md +47 -0
  25. package/content/skills/domain-modeling/CONTEXT-FORMAT.md +60 -0
  26. package/content/skills/domain-modeling/SKILL.md +80 -0
  27. package/content/skills/figma-export/SKILL.md +191 -0
  28. package/content/skills/figma-export/dump-figma-layers.py +83 -0
  29. package/content/skills/figma-export/dump-figma-svg.py +235 -0
  30. package/content/skills/figma-export/measure-template.mjs +87 -0
  31. package/content/skills/git-commit/SKILL.md +6 -7
  32. package/content/skills/git-flow/SKILL.md +87 -0
  33. package/content/skills/grill-with-docs/SKILL.md +13 -0
  34. package/content/skills/grilling/SKILL.md +34 -0
  35. package/content/skills/handoff/SKILL.md +74 -4
  36. package/content/skills/query/SKILL.md +165 -64
  37. package/content/skills/rxjs-signals/SKILL.md +31 -4
  38. package/content/skills/sdk-docs/SKILL.md +50 -14
  39. package/content/skills/sdk-local-build/SKILL.md +30 -10
  40. package/content/skills/sdk-local-build/sdk-local-baseline.mjs +64 -0
  41. package/content/skills/sdk-source/SKILL.md +49 -21
  42. package/content/skills/sdk-update/SKILL.md +96 -0
  43. package/content/skills/story-styling/SKILL.md +10 -8
  44. package/content/skills/styleguide/SKILL.md +12 -7
  45. package/content/skills/styleguide/assets.md +54 -0
  46. package/content/skills/styleguide/changesets.md +17 -0
  47. package/content/skills/styleguide/file-structure.md +73 -0
  48. package/content/skills/styleguide/lint-rule-lookup.md +51 -0
  49. package/content/skills/styleguide/storybook-structure.md +25 -0
  50. package/content/skills/theming/SKILL.md +41 -28
  51. package/content/skills/timetrack/SKILL.md +397 -0
  52. package/content/skills/verify-in-app/SKILL.md +107 -0
  53. package/migrations/app-styling-utilities.md +63 -0
  54. package/migrations/list-state-query-form.md +20 -0
  55. package/migrations/nx-layout.md +29 -0
  56. package/migrations/sdk-components-over-hand-built-ui.md +27 -0
  57. package/migrations/search-query-field.md +52 -0
  58. package/migrations.json +48 -0
  59. package/package.json +16 -2
  60. package/src/index.js +63 -28
  61. package/src/index.js.map +1 -1
  62. package/src/lib/cli-args.d.ts +9 -0
  63. package/src/lib/cli-args.js +48 -0
  64. package/src/lib/cli-args.js.map +1 -0
  65. package/src/lib/commitlint.d.ts +10 -0
  66. package/src/lib/commitlint.js +51 -0
  67. package/src/lib/commitlint.js.map +1 -0
  68. package/src/lib/config.d.ts +38 -4
  69. package/src/lib/config.js +84 -8
  70. package/src/lib/config.js.map +1 -1
  71. package/src/lib/filter.d.ts +2 -0
  72. package/src/lib/filter.js +4 -4
  73. package/src/lib/filter.js.map +1 -1
  74. package/src/lib/frontmatter.d.ts +2 -0
  75. package/src/lib/frontmatter.js +33 -5
  76. package/src/lib/frontmatter.js.map +1 -1
  77. package/src/lib/git-flow/build.d.ts +35 -0
  78. package/src/lib/git-flow/build.js +24 -0
  79. package/src/lib/git-flow/build.js.map +1 -0
  80. package/src/lib/git-flow/config.d.ts +59 -0
  81. package/src/lib/git-flow/config.js +50 -0
  82. package/src/lib/git-flow/config.js.map +1 -0
  83. package/src/lib/git-flow/index.d.ts +6 -0
  84. package/src/lib/git-flow/index.js +10 -0
  85. package/src/lib/git-flow/index.js.map +1 -0
  86. package/src/lib/git-flow/parse.d.ts +49 -0
  87. package/src/lib/git-flow/parse.js +300 -0
  88. package/src/lib/git-flow/parse.js.map +1 -0
  89. package/src/lib/git-flow/rename.d.ts +24 -0
  90. package/src/lib/git-flow/rename.js +70 -0
  91. package/src/lib/git-flow/rename.js.map +1 -0
  92. package/src/lib/git-flow/start.d.ts +49 -0
  93. package/src/lib/git-flow/start.js +57 -0
  94. package/src/lib/git-flow/start.js.map +1 -0
  95. package/src/lib/git-flow/validate.d.ts +34 -0
  96. package/src/lib/git-flow/validate.js +72 -0
  97. package/src/lib/git-flow/validate.js.map +1 -0
  98. package/src/lib/git-flow-command.d.ts +4 -0
  99. package/src/lib/git-flow-command.js +157 -0
  100. package/src/lib/git-flow-command.js.map +1 -0
  101. package/src/lib/git-flow-repair.d.ts +17 -0
  102. package/src/lib/git-flow-repair.js +157 -0
  103. package/src/lib/git-flow-repair.js.map +1 -0
  104. package/src/lib/git-flow-start.d.ts +20 -0
  105. package/src/lib/git-flow-start.js +132 -0
  106. package/src/lib/git-flow-start.js.map +1 -0
  107. package/src/lib/git.d.ts +64 -0
  108. package/src/lib/git.js +131 -0
  109. package/src/lib/git.js.map +1 -0
  110. package/src/lib/gitlab.d.ts +35 -0
  111. package/src/lib/gitlab.js +108 -0
  112. package/src/lib/gitlab.js.map +1 -0
  113. package/src/lib/index.d.ts +2 -0
  114. package/src/lib/index.js +2 -0
  115. package/src/lib/index.js.map +1 -1
  116. package/src/lib/migrate.js +1 -1
  117. package/src/lib/migrate.js.map +1 -1
  118. package/src/lib/output-style-command.d.ts +3 -0
  119. package/src/lib/output-style-command.js +69 -0
  120. package/src/lib/output-style-command.js.map +1 -0
  121. package/src/lib/output-style.d.ts +38 -0
  122. package/src/lib/output-style.js +134 -0
  123. package/src/lib/output-style.js.map +1 -0
  124. package/src/lib/owned-paths.js +27 -3
  125. package/src/lib/owned-paths.js.map +1 -1
  126. package/src/lib/package-runner.d.ts +3 -0
  127. package/src/lib/package-runner.js +69 -0
  128. package/src/lib/package-runner.js.map +1 -0
  129. package/src/lib/plain-text.d.ts +9 -0
  130. package/src/lib/plain-text.js +22 -0
  131. package/src/lib/plain-text.js.map +1 -0
  132. package/src/lib/plan.d.ts +6 -6
  133. package/src/lib/plan.js +147 -40
  134. package/src/lib/plan.js.map +1 -1
  135. package/src/lib/prompt.d.ts +8 -0
  136. package/src/lib/prompt.js +27 -0
  137. package/src/lib/prompt.js.map +1 -0
  138. package/src/lib/render.d.ts +24 -2
  139. package/src/lib/render.js +39 -10
  140. package/src/lib/render.js.map +1 -1
  141. package/src/lib/sync.d.ts +0 -1
  142. package/src/lib/sync.js +23 -15
  143. package/src/lib/sync.js.map +1 -1
  144. package/src/lib/targets/agents-skills.js +1 -1
  145. package/src/lib/targets/agents-skills.js.map +1 -1
  146. package/src/lib/targets/claude-hooks.d.ts +1 -23
  147. package/src/lib/targets/claude-hooks.js +17 -85
  148. package/src/lib/targets/claude-hooks.js.map +1 -1
  149. package/src/lib/targets/claude.js +1 -2
  150. package/src/lib/targets/claude.js.map +1 -1
  151. package/src/lib/targets/codex-hooks.d.ts +12 -0
  152. package/src/lib/targets/codex-hooks.js +34 -0
  153. package/src/lib/targets/codex-hooks.js.map +1 -0
  154. package/src/lib/targets/codex.js +4 -4
  155. package/src/lib/targets/codex.js.map +1 -1
  156. package/src/lib/targets/copilot.js +3 -3
  157. package/src/lib/targets/copilot.js.map +1 -1
  158. package/src/lib/targets/cursor.js +2 -2
  159. package/src/lib/targets/cursor.js.map +1 -1
  160. package/src/lib/targets/git-hooks.d.ts +24 -0
  161. package/src/lib/targets/git-hooks.js +71 -0
  162. package/src/lib/targets/git-hooks.js.map +1 -0
  163. package/src/lib/targets/hooks-shared.d.ts +58 -0
  164. package/src/lib/targets/hooks-shared.js +128 -0
  165. package/src/lib/targets/hooks-shared.js.map +1 -0
  166. package/src/lib/targets/shared.d.ts +1 -9
  167. package/src/lib/targets/shared.js +4 -8
  168. package/src/lib/targets/shared.js.map +1 -1
  169. package/src/lib/timetrack-command.d.ts +11 -0
  170. package/src/lib/timetrack-command.js +1055 -0
  171. package/src/lib/timetrack-command.js.map +1 -0
  172. package/src/lib/timetrack-snapshot.d.ts +55 -0
  173. package/src/lib/timetrack-snapshot.js +120 -0
  174. package/src/lib/timetrack-snapshot.js.map +1 -0
  175. package/src/lib/timetrack.d.ts +561 -0
  176. package/src/lib/timetrack.js +271 -0
  177. package/src/lib/timetrack.js.map +1 -0
  178. package/content/skills/styleguide/STYLEGUIDE.md +0 -520
@@ -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
 
@@ -0,0 +1,130 @@
1
+ ---
2
+ name: api-source
3
+ description: How to read the source of the API an app in this repo talks to, from a local backend checkout named in ethlete.config.local.json. Read when a response shape, a status code, an error body, an enum or an auth rule has to be confirmed rather than guessed - and never edit that checkout as part of work in this repo.
4
+ kind: skill
5
+ scope: consumer
6
+ ---
7
+
8
+ # Reading the API source
9
+
10
+ The client's types describe what the frontend _expects_. When the two disagree, the
11
+ server is right, so a question about the contract is answered in the API repository -
12
+ not by reading the frontend's own models harder.
13
+
14
+ Reach for the checkout when:
15
+
16
+ - a response arrives with a field, a shape or a `null` the frontend types do not allow
17
+ - you need the exact status code, error body or validation message for a failure path
18
+ - an enum, a permission or a filter parameter has to match the server's list exactly
19
+ - a request fails and you cannot tell whether the client or the server is wrong
20
+ - you are about to report or fix something in the API itself
21
+
22
+ Do not read it to design frontend code that the API does not serve yet. An endpoint in
23
+ the checkout is not deployed until the API team ships it.
24
+
25
+ ## 1. Resolve the checkout for your app
26
+
27
+ One repository can hold several apps, each with its own API, so the paths are a map from
28
+ app to checkout. They are per machine, so the map lives in the gitignored
29
+ `ethlete.config.local.json` at the repo root:
30
+
31
+ ```json
32
+ {
33
+ "apiRepoPaths": {
34
+ "hub": "../fut-hub-backend",
35
+ "*": "../shared-backend"
36
+ },
37
+ "apiRepoBranches": {
38
+ "hub": "develop",
39
+ "*": "main"
40
+ }
41
+ }
42
+ ```
43
+
44
+ Older checkouts still keep these keys in `ethlete-agents.config.local.json`. Read that file as a
45
+ fallback when `ethlete.config.local.json` is absent.
46
+
47
+ Read that file before searching anywhere. The rules:
48
+
49
+ - **The key is the app** as this repo names it - the workspace project name, which is
50
+ normally also the folder under `apps/`. Match the app you are working in.
51
+ - **A relative path resolves from the repo root**, not from the app folder.
52
+ - **Require an exact app key.** If several apps intentionally share one API, configure
53
+ the explicit `"*"` fallback. Never treat an unrelated single entry as a fallback.
54
+ - **No matching entry - stop and ask.** Do not guess a sibling folder and do not clone
55
+ the repository. Say which app you needed the API for and offer the snippet above; the
56
+ file is gitignored, so adding it changes nothing for anyone else.
57
+
58
+ Without a checkout, fall back to what the running API tells you: the generated API
59
+ description if the project serves one (`/openapi.json`, `/swagger`, `/api/doc`), and the
60
+ real response body of the call you are debugging.
61
+
62
+ ## 2. Resolve the relevant files before judging checkout state
63
+
64
+ Search by the route, field, or error you already know. Record the contract, serializer,
65
+ handler, and tests that can answer the question. Then inspect checkout state only for
66
+ those paths:
67
+
68
+ ```bash
69
+ git -C <apiRepoPath> status -sb
70
+ git -C <apiRepoPath> status --short -- <relevant-paths>
71
+ git -C <apiRepoPath> diff -- <relevant-paths>
72
+ ```
73
+
74
+ Unrelated dirty files are not evidence about these paths. Continue without blocking on
75
+ them. If a relevant file is dirty, distinguish the worktree implementation from the
76
+ committed or deployed behavior. Ask only when that difference changes the answer or the
77
+ user has to choose which behavior matters.
78
+
79
+ `apiRepoBranches` configures the expected branch per exact app key, with `"*"` as the
80
+ only fallback. If no branch is configured, report the current branch as context; do not
81
+ invent a blocking “development branch” requirement. A different or ahead branch matters
82
+ only when the relevant files differ in that commit range.
83
+
84
+ Use existing remote refs first. `git fetch` preserves the worktree but is not read-only:
85
+ it uses the network and mutates remote refs. Fetch only when freshness is material, and
86
+ request any approval the environment requires. Never switch, pull, stash, or reset the
87
+ checkout yourself.
88
+
89
+ ## 3. The checkout is not the environment the app calls
90
+
91
+ Even a clean, current checkout is only the source. The app talks to a deployed
92
+ environment, which can lag it:
93
+
94
+ - Read the app's API base URL from its environment config, and say which environment
95
+ your answer is about.
96
+ - When the source and the observed response disagree, **the observed response wins** for
97
+ what the app has to handle today. Report the difference instead of writing client code
98
+ against the newer source.
99
+
100
+ ## 4. Search it, don't read it whole
101
+
102
+ The stack is whatever the API team chose, so search by the thing you already know - the
103
+ path, the field name, the error message - rather than by an expected file layout:
104
+
105
+ ```bash
106
+ rg -n "api/v1/matches" <apiRepoPath> --glob '!*test*' # the route
107
+ rg -n "kickoffAt" <apiRepoPath> # a field of the payload
108
+ rg -n "MATCH_NOT_FOUND" <apiRepoPath> # an error code you received
109
+ ```
110
+
111
+ Two things to find first, because they answer most questions on their own:
112
+
113
+ - **The API description** the project generates or checks in (OpenAPI, Swagger, GraphQL
114
+ schema, `.http` files). It is the contract, and it is cheaper to read than the code.
115
+ - **The serializer, resource or DTO** for the entity - the field list the client sees,
116
+ which is usually a subset of the database model, and the place where a name is
117
+ rewritten between the two.
118
+
119
+ The API's own tests describe intended behaviour more directly than the implementation:
120
+ they name the status code and the body for each case.
121
+
122
+ ## 5. The checkout is read-only from here
123
+
124
+ It is a different repository with its own branch, review and release process. Never edit
125
+ it while working on a task in this repo, and never change its git state without being
126
+ asked. A fetch also needs to meet the freshness and approval conditions above.
127
+
128
+ When the fix belongs in the API, say so precisely: the endpoint, the field, and the
129
+ behaviour it should have. Then handle the API as it is today - a client workaround for a
130
+ server bug is a decision for the user, and it needs a comment naming what it waits on.
@@ -0,0 +1,141 @@
1
+ ---
2
+ name: app-testing
3
+ description: How to unit-test an app built on the Ethlete SDK - views that fetch through @ethlete/query (HttpTestingController and the @ethlete/query/testing helpers), components that open overlays, and views that read the URL with injectQueryParam under provideRouter. Read before writing or fixing a spec for a component, view or service that uses @ethlete/query, @ethlete/core router signals or @ethlete/components overlays.
4
+ kind: skill
5
+ scope: consumer
6
+ requires: ['@ethlete/core']
7
+ vars: [docsBaseUrl]
8
+ ---
9
+
10
+ # Testing an Ethlete app
11
+
12
+ Specs run in jsdom under `TestBed`. Test through the public API the view uses - mount the
13
+ component, answer its requests, move the URL - and assert on what it renders or exposes. The full
14
+ reference for the query helpers is {%docsBaseUrl%}/query/testing.
15
+
16
+ ## Views that fetch
17
+
18
+ A client made with `createQueryClient` is provided in root, so a spec provides nothing for it. It
19
+ needs Angular's HTTP testing backend, and answers each request by hand:
20
+
21
+ ```ts
22
+ import { provideHttpClient } from '@angular/common/http';
23
+ import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
24
+ import { TestBed } from '@angular/core/testing';
25
+
26
+ beforeEach(() => {
27
+ TestBed.configureTestingModule({ providers: [provideHttpClient(), provideHttpClientTesting()] });
28
+ });
29
+
30
+ afterEach(() => TestBed.inject(HttpTestingController).verify());
31
+
32
+ it('renders the players', async () => {
33
+ const http = TestBed.inject(HttpTestingController);
34
+ const fixture = TestBed.createComponent(PlayersComponent);
35
+ fixture.detectChanges();
36
+ TestBed.tick();
37
+
38
+ http.expectOne((req) => req.url.includes('/players')).flush({ items: ['Müller'] });
39
+ await fixture.whenStable();
40
+
41
+ expect(fixture.nativeElement.textContent).toContain('Müller');
42
+ });
43
+ ```
44
+
45
+ - A GET query executes once its args resolve, which happens on change detection -
46
+ `TestBed.tick()` before `expectOne`, and again after `flush` before reading the query's
47
+ `response()`, `loading()` or `error()`.
48
+ - `req.url` carries the query string, so `expectOne('https://api.example.com/players?search=mul')`
49
+ matches the full URL. Match with a predicate, as above, when the params are not the point.
50
+ - Fail a request with `flush(body, { status: 422, statusText: 'Unprocessable Entity' })`.
51
+ - Mutations (`POST`, `PUT`, `PATCH`, `DELETE`) never auto-execute - call `.execute({ args })`,
52
+ then expect the request.
53
+
54
+ From `@ethlete/query/testing`:
55
+
56
+ | Export | Use |
57
+ | ----------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
58
+ | `expectAndFlush(http, url, body, status?)` | `expectOne(url).flush(body)` in one call. |
59
+ | `expectFlushAndWait(http, url, body, status?)` | The same, then `TestBed.tick()`. |
60
+ | `setupQueryTest(config?)` | A scratch client, `HttpTestingController`, `createGet` … `createDelete` creators - for testing code that takes a creator or client. |
61
+ | `setupAuthTest({ querySetup })` | A bearer auth provider on that scratch client, with `login()` / `refresh()` helpers that answer the requests. |
62
+ | `createFakeQueryPersistenceStore()` | In-memory persistence adapter - jsdom has no IndexedDB. |
63
+ | `installFakeBroadcastChannel()`, `installFakeWebLocks()`, `flushMultiTabSync` | Two clients in one spec behave as two tabs. |
64
+ | `createWebSocketTestDouble()` | A scripted socket.io `io` for `createWebSocketClient({ io })`. |
65
+
66
+ `setupQueryTest` calls `TestBed.configureTestingModule` itself and replaces `ErrorHandler` with a
67
+ no-op (`mockErrorHandler: false` keeps the real one). Call it inside `beforeEach`, and call
68
+ `restoreConsole()` in `afterEach` if the file spies on `console`.
69
+
70
+ ## Views that read the URL
71
+
72
+ `injectQueryParam`, `injectQueryParams`, `injectPathParam` and the other router signals from
73
+ `@ethlete/core` read the router, so the spec provides one and navigates it:
74
+
75
+ ```ts
76
+ import { provideRouter, Router } from '@angular/router';
77
+
78
+ TestBed.configureTestingModule({
79
+ providers: [provideHttpClient(), provideHttpClientTesting(), provideRouter([])],
80
+ });
81
+
82
+ await TestBed.inject(Router).navigateByUrl('/?search=mul');
83
+ const fixture = TestBed.createComponent(PlayersComponent);
84
+ fixture.detectChanges();
85
+ TestBed.tick();
86
+
87
+ http.expectOne((req) => req.urlWithParams.includes('search=mul')).flush({ items: [] });
88
+
89
+ await TestBed.inject(Router).navigateByUrl('/?search=x');
90
+ TestBed.tick();
91
+ http.expectOne((req) => req.urlWithParams.includes('search=x')).flush({ items: [] });
92
+ ```
93
+
94
+ Navigate to change a param - never set the signal or mock the inject function. A view that also
95
+ reads path params needs the real route in the config (`provideRouter([{ path: 'players/:id',
96
+ component: PlayersComponent }])`) and `RouterTestingHarness` from `@angular/router/testing` to
97
+ render it.
98
+
99
+ ## Components that open overlays
100
+
101
+ An overlay mounts at the end of `document.body`, not in the fixture, and it opens and closes over
102
+ animation frames. Query the document, wait two frames, and close what is left after each test:
103
+
104
+ ```ts
105
+ import { injectOverlayManager } from '@ethlete/components';
106
+
107
+ const flushFrames = () =>
108
+ new Promise<void>((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())));
109
+
110
+ afterEach(async () => {
111
+ TestBed.runInInjectionContext(() => injectOverlayManager())
112
+ .openOverlays()
113
+ .forEach((ref) => ref.forceClose());
114
+ await flushFrames();
115
+ });
116
+
117
+ it('opens the edit dialog', async () => {
118
+ const fixture = TestBed.createComponent(PlayerListComponent);
119
+ fixture.detectChanges();
120
+
121
+ fixture.nativeElement.querySelector('button.edit').click();
122
+ await flushFrames();
123
+
124
+ expect(document.querySelector('.player-edit-dialog')).not.toBeNull();
125
+ });
126
+ ```
127
+
128
+ - Count open overlays with `injectOverlayManager().openOverlays().length` (inside
129
+ `TestBed.runInInjectionContext`).
130
+ - The `OverlayRef` that `createOverlayOpener(...).open()` returns has `componentInstance()`,
131
+ `close(result)` and `afterClosed()` - enough to test what the opener does with the result.
132
+ - A query-param overlay (`defineQueryParamOverlay`) opens from the URL: provide the router and
133
+ navigate with the param set, as above.
134
+ - jsdom has no `ResizeObserver`, `IntersectionObserver`, `matchMedia` or `Element.animate`. If a
135
+ spec throws on one of them, stub it once in the test setup file with an inert class or function,
136
+ not per spec.
137
+
138
+ ## Before you call it done
139
+
140
+ A spec for a bug fix must fail without the fix - revert it, run the spec, see it fail, restore it.
141
+ To check the same change in the running app, read {%skill:verify-in-app%}.
@@ -0,0 +1,90 @@
1
+ ---
2
+ name: codex-subagent
3
+ description: Delegate a task to a Codex agent (gpt-5.6-luna, gpt-5.6-sol, gpt-5.6-terra, gpt-6-astra) instead of a Claude subagent - a second opinion from another model family, a review, or a cheap parallel lookup. Read before spawning a subagent when another model would answer better, and whenever the user says codex, GPT, or "second opinion".
4
+ kind: skill
5
+ scope: both
6
+ ---
7
+
8
+ # Codex as a subagent
9
+
10
+ `codex exec` runs one Codex agent to completion without a terminal. It is a subagent with its
11
+ own context, its own tools and its own model family. It reads the repository's `AGENTS.md` by
12
+ itself, so it starts with the same rules you have.
13
+
14
+ Use it when the task gains from a model that is not Claude: a second opinion on a design, a
15
+ review of your own diff, a bug hunt that your reading already failed, or work you want to run
16
+ beside your own. Use it also when the Claude usage limit is close, because a Codex run does not
17
+ draw on it - move the delegatable work there and keep the limit for the work you lead. For
18
+ everything else, a Claude subagent stays the cheaper choice.
19
+
20
+ ## The call
21
+
22
+ The wrapper is {%resource:codex-agent.mjs%}. It keeps the Codex trace in a log file and prints
23
+ only the final message, so one run costs you a few hundred tokens instead of the whole trace.
24
+ Below, `<skill-dir>` is the directory this file sits in.
25
+
26
+ ```bash
27
+ node <skill-dir>/codex-agent.mjs -m gpt-5.6-terra -e medium "<prompt>"
28
+ ```
29
+
30
+ Options: `-m` model, `-e` effort (`low`, `medium`, `high`, `xhigh`), `-C` working root,
31
+ `--write`, `--resume <thread>`, `--prompt-file <file>`, `--schema <json-schema-file>`,
32
+ `--timeout <seconds>`, `--trace`. Run it with `--help` for the full list.
33
+
34
+ The run prints a footer with the model, the sandbox, the time, the output tokens, the resume
35
+ id and the log path. Read the log only when a run fails.
36
+
37
+ ## Pick the model
38
+
39
+ | Model | The work it fits |
40
+ | --------------- | ---------------------------------------------------------------------------------------------------------------- |
41
+ | `gpt-5.6-luna` | Fast and cheap: a lookup, a grep, a question about one file. |
42
+ | `gpt-5.6-terra` | Balanced agentic coding. The default for real work, and the wrapper's default. |
43
+ | `gpt-5.6-sol` | A reliable workhorse for a straight task. It costs about what `fable` costs, so ask the user before you pick it. |
44
+ | `gpt-6-astra` | Complex, demanding work. The most expensive of the four, so ask the user before you pick it. |
45
+
46
+ Effort follows both the flag and the prompt. Pass `-e low` for a lookup and `-e high` for a
47
+ hard bug. Say "keep it brief" in the prompt when you want a short answer.
48
+
49
+ ## Read-only by default
50
+
51
+ A run without `--write` gets the `read-only` sandbox. Codex can read and run commands, but it
52
+ cannot change a file. This is the right mode for a review, a second opinion and research.
53
+
54
+ Pass `--write` only when the user asked for a code change from Codex. Two rules hold then:
55
+
56
+ 1. **Codex must not commit, push, or run `git add`.** Say so in the prompt. Other sessions
57
+ share this checkout, so a broad stage takes work that is not yours.
58
+ 2. **Name the files it may touch.** An open brief in a shared checkout is how two agents edit
59
+ the same file.
60
+
61
+ `--resume` carries the sandbox and the working root of the thread it resumes. A thread that
62
+ started read-only stays read-only, so start a new run when you need write access.
63
+
64
+ ## Run several at once
65
+
66
+ The wrapper blocks until the agent is done, which takes minutes for real work. Start each run
67
+ as a background command, and collect the answers when they arrive. Give each run its own
68
+ question. Two agents on one question waste a model.
69
+
70
+ ## Write the prompt for a stranger
71
+
72
+ The Codex agent shares no context with you. It sees your prompt, the repository and
73
+ `AGENTS.md`. So:
74
+
75
+ - State the task as one question, and name what "done" means.
76
+ - Give the file paths you already know. It costs a model minutes to find what you can name.
77
+ - Ask for the shape of the answer: a verdict, a list of findings with `file:line`, a patch in
78
+ a fenced block.
79
+ - Do not paste the repository rules. It reads them itself.
80
+
81
+ Pass `--schema` with a JSON Schema file when you want the answer as JSON you can parse.
82
+
83
+ ## Before you reach for it
84
+
85
+ - `codex` must be on `PATH` and logged in. If it is not, tell the user and stop. Do not
86
+ install it, and do not write a token into the repository.
87
+ - If Codex reports that the project is not trusted, ask the user to run `codex` once in the
88
+ repository and trust it there.
89
+ - A Codex run is not free. Prefer it where a second model earns its cost, not for the
90
+ twentieth grep.
@@ -0,0 +1,229 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Run one Codex agent to completion and print only its final message.
4
+ *
5
+ * `codex exec` streams its whole reasoning and command trace to stdout. That trace costs a
6
+ * calling agent more context than the answer is worth, so it stays in a log file here.
7
+ *
8
+ * Usage: node codex-agent.mjs [options] "<prompt>"
9
+ */
10
+
11
+ import { spawn } from 'node:child_process';
12
+ import { createWriteStream, mkdirSync, readFileSync, rmSync } from 'node:fs';
13
+ import { tmpdir } from 'node:os';
14
+ import { join } from 'node:path';
15
+ import { createInterface } from 'node:readline';
16
+
17
+ const DEFAULT_MODEL = 'gpt-5.6-terra';
18
+ const DEFAULT_TIMEOUT_SECONDS = 900;
19
+
20
+ const USAGE = `Usage: node codex-agent.mjs [options] "<prompt>"
21
+
22
+ -m, --model <id> Model id (default ${DEFAULT_MODEL})
23
+ -e, --effort <level> Reasoning effort: low | medium | high | xhigh
24
+ -w, --write Let the agent edit the working tree (default: read-only)
25
+ -C, --cd <dir> Working root for the agent (default: the current directory)
26
+ --resume <thread> Continue an earlier thread instead of starting one
27
+ --prompt-file <f> Read the prompt from a file
28
+ --schema <file> JSON Schema the final message must match
29
+ --timeout <sec> Give up after this many seconds (default ${DEFAULT_TIMEOUT_SECONDS})
30
+ --trace Also print the command and file-change trace
31
+ -h, --help Print this help`;
32
+
33
+ const parseArgs = (argv) => {
34
+ const options = {
35
+ model: DEFAULT_MODEL,
36
+ effort: '',
37
+ write: false,
38
+ cd: process.cwd(),
39
+ resume: '',
40
+ promptFile: '',
41
+ schema: '',
42
+ timeout: DEFAULT_TIMEOUT_SECONDS,
43
+ trace: false,
44
+ prompt: '',
45
+ };
46
+
47
+ const rest = [];
48
+
49
+ for (let index = 0; index < argv.length; index += 1) {
50
+ const arg = argv[index];
51
+ const next = () => {
52
+ index += 1;
53
+ if (index >= argv.length) throw new Error(`${arg} needs a value.`);
54
+ return argv[index];
55
+ };
56
+
57
+ if (arg === '-m' || arg === '--model') options.model = next();
58
+ else if (arg === '-e' || arg === '--effort') options.effort = next();
59
+ else if (arg === '-w' || arg === '--write') options.write = true;
60
+ else if (arg === '-C' || arg === '--cd') options.cd = next();
61
+ else if (arg === '--resume') options.resume = next();
62
+ else if (arg === '--prompt-file') options.promptFile = next();
63
+ else if (arg === '--schema') options.schema = next();
64
+ else if (arg === '--timeout') options.timeout = Number(next());
65
+ else if (arg === '--trace') options.trace = true;
66
+ else if (arg === '-h' || arg === '--help') options.help = true;
67
+ else if (arg.startsWith('-') && arg !== '-') throw new Error(`Unknown option ${arg}.`);
68
+ else rest.push(arg);
69
+ }
70
+
71
+ options.prompt = options.promptFile ? readFileSync(options.promptFile, 'utf8') : rest.join(' ');
72
+
73
+ return options;
74
+ };
75
+
76
+ const readStdin = () =>
77
+ new Promise((resolve) => {
78
+ let buffer = '';
79
+ process.stdin.setEncoding('utf8');
80
+ process.stdin.on('data', (chunk) => (buffer += chunk));
81
+ process.stdin.on('end', () => resolve(buffer));
82
+ });
83
+
84
+ const buildArgs = (options, lastMessageFile) => {
85
+ const args = ['exec'];
86
+
87
+ if (options.resume) args.push('resume');
88
+
89
+ args.push('--json', '--skip-git-repo-check', '-o', lastMessageFile, '-m', options.model);
90
+
91
+ // `codex exec resume` carries the sandbox and the working root of the session it resumes,
92
+ // and rejects both flags, so only a fresh run may pass them.
93
+ if (!options.resume) {
94
+ args.push('-C', options.cd, '--sandbox', options.write ? 'workspace-write' : 'read-only');
95
+ }
96
+
97
+ if (options.effort) args.push('-c', `model_reasoning_effort="${options.effort}"`);
98
+ if (options.schema) args.push('--output-schema', options.schema);
99
+ if (options.resume) args.push(options.resume);
100
+
101
+ args.push('-');
102
+
103
+ return args;
104
+ };
105
+
106
+ const describeItem = (item) => {
107
+ if (item.type === 'command_execution') return `$ ${String(item.command ?? '').split('\n')[0]}`;
108
+ if (item.type === 'file_change') {
109
+ const changes = Array.isArray(item.changes) ? item.changes : [];
110
+ return `~ ${changes.map((change) => `${change.kind ?? 'edit'} ${change.path ?? ''}`).join(', ')}`;
111
+ }
112
+ if (item.type === 'error') return `! ${item.message ?? 'unknown error'}`;
113
+ return '';
114
+ };
115
+
116
+ const duration = (milliseconds) => {
117
+ const seconds = Math.round(milliseconds / 1000);
118
+ return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m${String(seconds % 60).padStart(2, '0')}s`;
119
+ };
120
+
121
+ const run = async () => {
122
+ const options = parseArgs(process.argv.slice(2));
123
+
124
+ if (options.help) {
125
+ process.stdout.write(`${USAGE}\n`);
126
+ return 0;
127
+ }
128
+
129
+ if (!options.prompt.trim() && !process.stdin.isTTY) options.prompt = await readStdin();
130
+
131
+ if (!options.prompt.trim()) {
132
+ process.stderr.write(`${USAGE}\n`);
133
+ return 2;
134
+ }
135
+
136
+ const logDir = join(tmpdir(), 'codex-agent');
137
+ mkdirSync(logDir, { recursive: true });
138
+
139
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
140
+ const logFile = join(logDir, `${stamp}.jsonl`);
141
+ const lastMessageFile = join(logDir, `${stamp}.last.md`);
142
+ const log = createWriteStream(logFile);
143
+ const started = Date.now();
144
+
145
+ const child = spawn('codex', buildArgs(options, lastMessageFile), {
146
+ stdio: ['pipe', 'pipe', 'pipe'],
147
+ env: process.env,
148
+ });
149
+
150
+ child.stdin.end(options.prompt);
151
+
152
+ const state = { thread: '', usage: null, errors: [], trace: [], failed: false };
153
+
154
+ createInterface({ input: child.stdout }).on('line', (line) => {
155
+ log.write(`${line}\n`);
156
+
157
+ let event;
158
+
159
+ try {
160
+ event = JSON.parse(line);
161
+ } catch {
162
+ return;
163
+ }
164
+
165
+ if (event.type === 'thread.started') state.thread = event.thread_id ?? '';
166
+ else if (event.type === 'turn.completed') state.usage = event.usage ?? null;
167
+ else if (event.type === 'turn.failed') {
168
+ state.failed = true;
169
+ state.errors.push(event.error?.message ?? 'the turn failed');
170
+ } else if (event.type === 'item.completed') {
171
+ const description = describeItem(event.item ?? {});
172
+ if (description) state.trace.push(description);
173
+ if (event.item?.type === 'error') state.errors.push(event.item.message ?? 'unknown error');
174
+ }
175
+ });
176
+
177
+ let stderrText = '';
178
+ child.stderr.setEncoding('utf8');
179
+ child.stderr.on('data', (chunk) => (stderrText += chunk));
180
+
181
+ const timer = setTimeout(() => child.kill('SIGKILL'), Math.max(1, options.timeout) * 1000);
182
+ const code = await new Promise((resolve) => child.on('close', resolve));
183
+
184
+ clearTimeout(timer);
185
+ log.end();
186
+
187
+ let finalMessage;
188
+
189
+ try {
190
+ finalMessage = readFileSync(lastMessageFile, 'utf8').trim();
191
+ rmSync(lastMessageFile, { force: true });
192
+ } catch {
193
+ finalMessage = '';
194
+ }
195
+
196
+ const failed = code !== 0 || state.failed || (!finalMessage && state.errors.length > 0);
197
+
198
+ if (options.trace && state.trace.length > 0) process.stdout.write(`${state.trace.join('\n')}\n\n`);
199
+ if (finalMessage) process.stdout.write(`${finalMessage}\n`);
200
+
201
+ if (failed) {
202
+ const reason = state.errors.join('\n') || stderrText.trim() || `codex exited with code ${code}`;
203
+ process.stdout.write(`\ncodex agent FAILED: ${reason}\n`);
204
+ }
205
+
206
+ const parts = [
207
+ `codex ${options.model}`,
208
+ options.write ? 'workspace-write' : 'read-only',
209
+ duration(Date.now() - started),
210
+ ];
211
+
212
+ if (state.usage) parts.push(`${state.usage.output_tokens ?? 0} out tok`);
213
+
214
+ process.stdout.write(`\n--- ${parts.join(' · ')}\n`);
215
+
216
+ if (state.thread) process.stdout.write(`resume: --resume ${state.thread}\n`);
217
+
218
+ process.stdout.write(`log: ${logFile}\n`);
219
+
220
+ return failed ? 1 : 0;
221
+ };
222
+
223
+ run().then(
224
+ (code) => process.exit(code),
225
+ (error) => {
226
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
227
+ process.exit(2);
228
+ },
229
+ );