@ethlete/agent-rules 0.1.0-next.16 → 0.1.0-next.18
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/CHANGELOG.md +50 -0
- package/README.md +14 -6
- package/content/hooks/subagent-model-policy.py +5 -3
- package/content/rules/app-styling.md +47 -0
- package/content/rules/comments.md +4 -1
- package/content/rules/nx-layout.md +47 -0
- package/content/rules/styling.md +1 -1
- package/content/rules/subagent-models.md +7 -7
- package/content/skills/angular-patterns/SKILL.md +23 -0
- package/content/skills/app-testing/SKILL.md +141 -0
- package/content/skills/design-exploration/SKILL.md +3 -1
- package/content/skills/git-flow/SKILL.md +4 -4
- package/content/skills/query/SKILL.md +150 -82
- package/content/skills/rxjs-signals/SKILL.md +20 -0
- package/content/skills/sdk-docs/SKILL.md +35 -7
- package/content/skills/sdk-update/SKILL.md +12 -7
- package/content/skills/story-styling/SKILL.md +7 -6
- package/content/skills/styleguide/lint-rule-lookup.md +1 -1
- package/content/skills/theming/SKILL.md +3 -4
- package/content/skills/timetrack/SKILL.md +154 -49
- package/content/skills/verify-in-app/SKILL.md +107 -0
- package/migrations/app-styling-utilities.md +114 -0
- package/migrations/list-state-query-form.md +103 -0
- package/migrations/nx-layout.md +23 -0
- package/migrations/sdk-components-over-hand-built-ui.md +69 -0
- package/migrations/search-query-field.md +45 -0
- package/migrations.json +43 -0
- package/package.json +4 -1
- package/src/index.js +5 -4
- package/src/index.js.map +1 -1
- package/src/lib/config.d.ts +2 -0
- package/src/lib/config.js +16 -3
- 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.js +32 -5
- package/src/lib/frontmatter.js.map +1 -1
- package/src/lib/git-flow/parse.js +38 -12
- package/src/lib/git-flow/parse.js.map +1 -1
- package/src/lib/git-flow-repair.js +12 -1
- package/src/lib/git-flow-repair.js.map +1 -1
- package/src/lib/git.js +8 -2
- package/src/lib/git.js.map +1 -1
- package/src/lib/gitlab.js +11 -1
- package/src/lib/gitlab.js.map +1 -1
- package/src/lib/migrate.js +1 -1
- package/src/lib/migrate.js.map +1 -1
- package/src/lib/output-style.js +11 -3
- package/src/lib/output-style.js.map +1 -1
- package/src/lib/owned-paths.js +8 -2
- package/src/lib/owned-paths.js.map +1 -1
- package/src/lib/package-runner.d.ts +1 -0
- package/src/lib/package-runner.js +34 -0
- package/src/lib/package-runner.js.map +1 -0
- package/src/lib/plan.js +29 -3
- package/src/lib/plan.js.map +1 -1
- package/src/lib/render.d.ts +4 -0
- package/src/lib/render.js +8 -2
- package/src/lib/render.js.map +1 -1
- package/src/lib/sync.js +22 -12
- package/src/lib/sync.js.map +1 -1
- package/src/lib/targets/codex.js +1 -1
- package/src/lib/targets/codex.js.map +1 -1
- package/src/lib/targets/copilot.js +1 -1
- package/src/lib/targets/copilot.js.map +1 -1
- package/src/lib/targets/git-hooks.js +2 -1
- package/src/lib/targets/git-hooks.js.map +1 -1
- package/src/lib/targets/hooks-shared.js +1 -1
- package/src/lib/targets/hooks-shared.js.map +1 -1
- package/src/lib/timetrack-command.js +344 -60
- package/src/lib/timetrack-command.js.map +1 -1
- package/src/lib/timetrack.d.ts +185 -16
- package/src/lib/timetrack.js +66 -15
- package/src/lib/timetrack.js.map +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: timetrack
|
|
3
|
-
description: How to reach Jira from any repo through the running Timetrack app - look up an issue, search for one, ask which project a repo logs into, file a ticket, add a worklog row, read the evidence a day holds, or list the work that still waits for a ticket. Read whenever a task needs Jira data, a Jira write, or the day's own events, and never put a Jira token in a repo.
|
|
3
|
+
description: How to reach Jira from any repo through the running Timetrack app - look up an issue, search for one, ask which project a repo logs into, file a ticket, add a worklog row, read the user's own Tempo worklogs, read their Google Calendar, read the evidence a day holds, or list the work that still waits for a ticket. Read whenever a task needs Jira data, a Jira write, or the day's own events, and never put a Jira token in a repo.
|
|
4
4
|
kind: skill
|
|
5
5
|
scope: both
|
|
6
6
|
---
|
|
@@ -11,16 +11,30 @@ scope: both
|
|
|
11
11
|
in this machine's keychain, and every repository asks it:
|
|
12
12
|
|
|
13
13
|
```bash
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
14
|
+
{%packageRunner%} ethlete-agents timetrack status # is the app reachable, and what does it hold?
|
|
15
|
+
{%packageRunner%} ethlete-agents timetrack issue FIP-2177 # one issue: summary, type, parent, subject
|
|
16
|
+
{%packageRunner%} ethlete-agents timetrack search "password" # open issues of the picked projects
|
|
17
|
+
{%packageRunner%} ethlete-agents timetrack project # which project does this repo log into?
|
|
18
|
+
{%packageRunner%} ethlete-agents timetrack instance # the instance's own levels and custom fields
|
|
19
|
+
{%packageRunner%} ethlete-agents timetrack standins # work the user named that Jira does not hold yet
|
|
20
20
|
```
|
|
21
21
|
|
|
22
22
|
Add `--json` to any of them when you need to read a field rather than a line.
|
|
23
23
|
|
|
24
|
+
**Every write waits for the user's approval in the app.** `create`, `log`, `edit`, `standins
|
|
25
|
+
--remove | --rename | --merge | --split --force`, `sync --write`, `worklog --delete` and `resync` answer at
|
|
26
|
+
once with an approval id and write nothing yet. The user approves or rejects the request in
|
|
27
|
+
Timetrack; `approval <id>` reads the outcome:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
{%packageRunner%} ethlete-agents timetrack approval <id> # queued, approved (with the result), rejected or expired
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Tell the user a request waits in Timetrack, then read `approval` once they say they decided -
|
|
34
|
+
do not poll in a loop. A request that is still waiting at the end of the day it was asked on
|
|
35
|
+
expires; ask again rather than assuming it landed. Set `TIMETRACK_CLIENT` to name yourself in the
|
|
36
|
+
queue (Claude Code is named without it).
|
|
37
|
+
|
|
24
38
|
**Never ask the user for a Jira token, and never write one into a file.** If a command reports
|
|
25
39
|
that the app is not running, say so and ask the user to start it. That is the whole fix -
|
|
26
40
|
there is no per-repo fallback, by design: a secret copied into every checkout is a secret
|
|
@@ -28,23 +42,29 @@ nobody can rotate.
|
|
|
28
42
|
|
|
29
43
|
## What each command is for
|
|
30
44
|
|
|
31
|
-
| Command
|
|
32
|
-
|
|
|
33
|
-
| `status`
|
|
34
|
-
| `instance`
|
|
35
|
-
| `issue <KEY>`
|
|
36
|
-
| `search [text]`
|
|
37
|
-
| `project [path]`
|
|
38
|
-
| `create --summary "…"`
|
|
39
|
-
| `log --issue <KEY> --minutes <n>`
|
|
40
|
-
| `day [YYYY-MM-DD]`
|
|
41
|
-
| `rows [YYYY-MM-DD]`
|
|
42
|
-
| `edit <row-id> …`
|
|
43
|
-
| `rules`
|
|
44
|
-
| `standins`
|
|
45
|
-
| `standins --remove <id>`
|
|
46
|
-
| `standins --rename <id> --name <text>`
|
|
47
|
-
| `
|
|
45
|
+
| Command | Use it when |
|
|
46
|
+
| ------------------------------------------ | --------------------------------------------------------------------------- |
|
|
47
|
+
| `status` | Before anything else, when a Jira command failed and you need the cause |
|
|
48
|
+
| `instance` | A setup step needs the instance's levels or its branch-subject field |
|
|
49
|
+
| `issue <KEY>` | The user names a key and you need its summary, type or parent |
|
|
50
|
+
| `search [text]` | The user describes work but names no key |
|
|
51
|
+
| `project [path]` | You need the project a repository files into |
|
|
52
|
+
| `create --summary "…"` | The work has no ticket and the user asked for one |
|
|
53
|
+
| `log --issue <KEY> --minutes <n>` | The user asks to record time that nothing observed |
|
|
54
|
+
| `day [YYYY-MM-DD]` | You need the evidence a day holds, not a screenshot of it |
|
|
55
|
+
| `rows [YYYY-MM-DD]` | You need the rows the day drew, and the ids an edit names them by |
|
|
56
|
+
| `edit <row-id> …` | The user asks you to correct one row of a day |
|
|
57
|
+
| `rules` | You need to know why a band was named, or why it was not |
|
|
58
|
+
| `standins` | You need to know which work still waits for a ticket, and for how long |
|
|
59
|
+
| `standins --remove <id>` | A placeholder is wrong or too wide, and the user asked you to delete it |
|
|
60
|
+
| `standins --rename <id> --name <text>` | The name a placeholder carries is wrong, and the user asked you to fix it |
|
|
61
|
+
| `standins --merge <id> --into <id>` | Two placeholders are one piece of work, and the user asked you to join them |
|
|
62
|
+
| `naming [YYYY-MM-DD]` | A checkout was never offered a name and you need the step that stopped |
|
|
63
|
+
| `worklogs [from] [to]` | You need what the user already booked in Tempo over a span of days |
|
|
64
|
+
| `calendar [from] [to]` | You need the user's meetings and calendar entries over a span of days |
|
|
65
|
+
| `sync <YYYY-MM-DD>` | The user asks what a Tempo sync of a day would write, or asks to sync it |
|
|
66
|
+
| `worklog --delete <id> --day <YYYY-MM-DD>` | The user asked you to delete one of their own Tempo worklogs |
|
|
67
|
+
| `approval <id>` | A write you queued: whether the user approved it, and what it answered |
|
|
48
68
|
|
|
49
69
|
`git-flow start` uses the same channel, so a branch is named from the real issue rather than
|
|
50
70
|
from a key you typed. Follow the repository's branch workflow when creating a branch.
|
|
@@ -54,8 +74,8 @@ from a key you typed. Follow the repository's branch workflow when creating a br
|
|
|
54
74
|
The app's store is encrypted, so no shell reads a day off disk. `day` is the only way in:
|
|
55
75
|
|
|
56
76
|
```bash
|
|
57
|
-
|
|
58
|
-
|
|
77
|
+
{%packageRunner%} ethlete-agents timetrack day # today: how many events, and of which kind
|
|
78
|
+
{%packageRunner%} ethlete-agents timetrack day 2026-09-10 --out /tmp/day.json
|
|
59
79
|
```
|
|
60
80
|
|
|
61
81
|
A real day holds thousands of events, so **never print them**. Write them to a file with
|
|
@@ -72,22 +92,23 @@ day as it was observed - window titles, paths and messages - so delete it when y
|
|
|
72
92
|
timeline, with the id every edit names a row by, the day's totals and its warnings.
|
|
73
93
|
|
|
74
94
|
```bash
|
|
75
|
-
|
|
76
|
-
|
|
95
|
+
{%packageRunner%} ethlete-agents timetrack rows # today
|
|
96
|
+
{%packageRunner%} ethlete-agents timetrack rows 2026-09-10 --json
|
|
77
97
|
```
|
|
78
98
|
|
|
79
99
|
`edit` changes one row. Pass exactly one change per call:
|
|
80
100
|
|
|
81
101
|
```bash
|
|
82
|
-
|
|
102
|
+
{%packageRunner%} ethlete-agents timetrack edit 'ABC-1@2026-09-10T11:00:00.000Z' --day 2026-09-10 \
|
|
83
103
|
--from 2026-09-10T13:15:00Z --to 2026-09-10T17:00:00Z
|
|
84
|
-
|
|
85
|
-
|
|
104
|
+
{%packageRunner%} ethlete-agents timetrack edit '<row-id>' --issue ABC-2
|
|
105
|
+
{%packageRunner%} ethlete-agents timetrack edit '<row-id>' --state rejected
|
|
86
106
|
```
|
|
87
107
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
tick, so read `rows` again rather than reusing an id from
|
|
108
|
+
`rows` moves the app's own review to that day, so the user sees what you read. An edit is queued
|
|
109
|
+
like every write; once approved, its result's `applied` says how many edits found their row. A
|
|
110
|
+
running day is re-cut on every collector tick, so read `rows` again rather than reusing an id from
|
|
111
|
+
minutes ago.
|
|
91
112
|
|
|
92
113
|
An edit may correct a row's times, its name, its note and whether it syncs. It may not split, merge
|
|
93
114
|
or delete one - restructuring a day is the user's own decision, made on the screen.
|
|
@@ -99,8 +120,8 @@ the background projects and the applications the user has ruled in or out. It ho
|
|
|
99
120
|
account and no token, so it is safe to quote back to the user.
|
|
100
121
|
|
|
101
122
|
```bash
|
|
102
|
-
|
|
103
|
-
|
|
123
|
+
{%packageRunner%} ethlete-agents timetrack rules # a summary of the rules
|
|
124
|
+
{%packageRunner%} ethlete-agents timetrack rules --json # the whole answer
|
|
104
125
|
```
|
|
105
126
|
|
|
106
127
|
Read it before claiming a band should have been named something: a rule that donates its time
|
|
@@ -113,8 +134,8 @@ A **stand-in** is a name the user gave work that Jira does not hold yet. It take
|
|
|
113
134
|
days and across checkouts, and it books nothing. `standins` lists them:
|
|
114
135
|
|
|
115
136
|
```bash
|
|
116
|
-
|
|
117
|
-
|
|
137
|
+
{%packageRunner%} ethlete-agents timetrack standins # the open ones, oldest first
|
|
138
|
+
{%packageRunner%} ethlete-agents timetrack standins --json # the whole answer, resolved ones included
|
|
118
139
|
```
|
|
119
140
|
|
|
120
141
|
**Never open or resolve a stand-in.** The name is the user's own word for their work, and the
|
|
@@ -150,7 +171,7 @@ reading `--json` yourself.
|
|
|
150
171
|
The name is only wrong, and the work behind it is right:
|
|
151
172
|
|
|
152
173
|
```bash
|
|
153
|
-
|
|
174
|
+
{%packageRunner%} ethlete-agents timetrack standins --rename <id> --name '20260921 competition navigation rework'
|
|
154
175
|
```
|
|
155
176
|
|
|
156
177
|
The days it holds and the rules that name it stay, which is what a delete and a fresh record would
|
|
@@ -163,8 +184,8 @@ A record covering a whole checkout is repaired rather than deleted, because a de
|
|
|
163
184
|
days. `--split` re-cuts it into one record per directory, and moves each day onto the right one:
|
|
164
185
|
|
|
165
186
|
```bash
|
|
166
|
-
|
|
167
|
-
|
|
187
|
+
{%packageRunner%} ethlete-agents timetrack standins --split <id> # the plan, writes nothing
|
|
188
|
+
{%packageRunner%} ethlete-agents timetrack standins --split <id> --force # queue it for approval
|
|
168
189
|
```
|
|
169
190
|
|
|
170
191
|
Without `--force` it prints every directory the commits name, with its commit count and its days,
|
|
@@ -191,7 +212,7 @@ the split refuses it.
|
|
|
191
212
|
You may delete one, because that takes a name away rather than putting one on the day:
|
|
192
213
|
|
|
193
214
|
```bash
|
|
194
|
-
|
|
215
|
+
{%packageRunner%} ethlete-agents timetrack standins --remove <id>
|
|
195
216
|
```
|
|
196
217
|
|
|
197
218
|
The rule that named it goes with it, and what happens next is worth knowing before you ask:
|
|
@@ -211,8 +232,8 @@ card is either drawn or it is not, and every step that can stop it is invisible
|
|
|
211
232
|
`naming` names the step:
|
|
212
233
|
|
|
213
234
|
```bash
|
|
214
|
-
|
|
215
|
-
|
|
235
|
+
{%packageRunner%} ethlete-agents timetrack naming # today
|
|
236
|
+
{%packageRunner%} ethlete-agents timetrack naming 2026-09-14
|
|
216
237
|
```
|
|
217
238
|
|
|
218
239
|
It reports whether a Tempo token is stored, how far the read of the worklog history got, and for each
|
|
@@ -220,21 +241,105 @@ checkout the day saw either the offer or the reason there is none - `already-nam
|
|
|
220
241
|
`no-project-link`, `no-history`, `project-too-small`, `too-few-days` or `share-too-low`. A `history`
|
|
221
242
|
of `failed` or `no-token` explains every checkout at once, so read that line first.
|
|
222
243
|
|
|
244
|
+
## What Tempo already holds
|
|
245
|
+
|
|
246
|
+
`worklogs` reads the user's own Tempo worklogs straight from Tempo, whoever wrote them. It is
|
|
247
|
+
read-only, and the app keeps the Tempo token:
|
|
248
|
+
|
|
249
|
+
```bash
|
|
250
|
+
{%packageRunner%} ethlete-agents timetrack worklogs # the last 7 days
|
|
251
|
+
{%packageRunner%} ethlete-agents timetrack worklogs 2026-09-01 2026-09-24 # both days included
|
|
252
|
+
{%packageRunner%} ethlete-agents timetrack worklogs 2026-09-01 2026-09-24 --json
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
Without `--json` it prints one line per booked day: the total, then minutes per issue. `--json`
|
|
256
|
+
answers each worklog with its day, `startTime` as `HH:MM`, `durationMs`, `issueKey` and
|
|
257
|
+
description. A span is at most 92 days. The descriptions are the user's own words, so quote them
|
|
258
|
+
only when the task needs them.
|
|
259
|
+
|
|
260
|
+
## What the calendar holds
|
|
261
|
+
|
|
262
|
+
`calendar` reads the Google calendars the app watches, straight from Google and read-only. The app
|
|
263
|
+
keeps the Google token:
|
|
264
|
+
|
|
265
|
+
```bash
|
|
266
|
+
{%packageRunner%} ethlete-agents timetrack calendar # the last 7 days
|
|
267
|
+
{%packageRunner%} ethlete-agents timetrack calendar 2026-09-01 2026-09-24 # both days included
|
|
268
|
+
{%packageRunner%} ethlete-agents timetrack calendar 2026-09-01 2026-09-24 --json
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
Without `--json` it prints each day with its meeting hours, then one line per entry: start, minutes,
|
|
272
|
+
title, and the user's answer where it is not a yes. All-day and declined entries are listed but not
|
|
273
|
+
counted. `--json` answers each entry with `calendarId`, `day`, `startMs`, `endMs`, `allDay`, `title`,
|
|
274
|
+
`attendeeCount` and `response`. A span is at most 92 days. Titles come from whoever sent the
|
|
275
|
+
invitation, so quote them only when the task needs them.
|
|
276
|
+
|
|
277
|
+
## Syncing a day to Tempo
|
|
278
|
+
|
|
279
|
+
`sync` plans a day exactly as the app's Sync page does, and writes nothing:
|
|
280
|
+
|
|
281
|
+
```bash
|
|
282
|
+
{%packageRunner%} ethlete-agents timetrack sync 2026-09-07
|
|
283
|
+
{%packageRunner%} ethlete-agents timetrack sync 2026-09-07 --json
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
It prints every create, update and delete with its issue, local time, minutes, description and
|
|
287
|
+
reason; the rows it holds back (Tempo refuses an empty description); how many are unchanged or still
|
|
288
|
+
awaiting review; keys Jira does not know; and the foreign worklogs it never touches. The first line
|
|
289
|
+
carries a short **plan hash** over the write set.
|
|
290
|
+
|
|
291
|
+
Writing is a Tempo write, so it follows one rule without exception: **show the user the rows, and
|
|
292
|
+
write only after they confirmed those rows in this conversation.** Then pass the hash the read
|
|
293
|
+
printed:
|
|
294
|
+
|
|
295
|
+
```bash
|
|
296
|
+
{%packageRunner%} ethlete-agents timetrack sync 2026-09-07 --write --plan 1a2b3c4d
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
The write is queued, and the user approves it in the app as well - "Approve all" never includes
|
|
300
|
+
it. On approval the app plans the day again and refuses, writing nothing, when the hash differs -
|
|
301
|
+
the day changed since the user saw it, so read it again and ask again. `--write` without `--plan`
|
|
302
|
+
is refused at once. Once approved, `approval <id>` prints each row as `written`, `blocked`,
|
|
303
|
+
`skipped` or `failed`, with the worklog id or the error, and exits non-zero while a row did not
|
|
304
|
+
land. Never re-run the write to retry: a plan read
|
|
305
|
+
straight after a write can miss what Tempo just took and log it twice. The Sync page shows the run
|
|
306
|
+
and retries from its own plan. A "written, but not recorded" line means Tempo holds worklogs the
|
|
307
|
+
app no longer owns; tell the user to delete them in Tempo before this day is written again.
|
|
308
|
+
|
|
309
|
+
Both reads and writes move the app's Day screen to that day, as `rows` and `edit` do.
|
|
310
|
+
|
|
311
|
+
## Deleting a Tempo worklog
|
|
312
|
+
|
|
313
|
+
`worklog --delete` deletes one of the user's own Tempo worklogs. Both flags are required; read the
|
|
314
|
+
id and its day off `worklogs --json`:
|
|
315
|
+
|
|
316
|
+
```bash
|
|
317
|
+
{%packageRunner%} ethlete-agents timetrack worklog --delete 98765 --day 2026-09-07
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
It is a Tempo write, so the same rule holds: **name the worklog to the user, and delete only after
|
|
321
|
+
they confirmed that one in this conversation.** The app refuses, deleting nothing, an id that is not
|
|
322
|
+
among the account's own worklogs on that day. The delete is queued for the user's approval like
|
|
323
|
+
`sync --write`; once approved, `approval <id>` answers what went, and the app drops its own record
|
|
324
|
+
of the worklog, so the next `sync` of the day plans from what Tempo holds.
|
|
325
|
+
|
|
223
326
|
## Writes
|
|
224
327
|
|
|
225
|
-
|
|
328
|
+
Four commands write to Jira or Tempo, so all need the user to have asked for them in this
|
|
329
|
+
conversation - `sync --write` and `worklog --delete` are described above. Each is queued for the
|
|
330
|
+
user's approval in the app:
|
|
226
331
|
|
|
227
332
|
```bash
|
|
228
|
-
|
|
229
|
-
|
|
333
|
+
{%packageRunner%} ethlete-agents timetrack create --summary "Reset password mail is not sent" --project FIP
|
|
334
|
+
{%packageRunner%} ethlete-agents timetrack log --issue FIP-2177 --minutes 45 --description "pairing call"
|
|
230
335
|
```
|
|
231
336
|
|
|
232
|
-
- **`create`** files the issue with the instance's own ticket settings - its type, its parent
|
|
337
|
+
- **`create`** files the issue, once approved, with the instance's own ticket settings - its type, its parent
|
|
233
338
|
rule and its subject field all come from the app, so the ticket is shaped like every other.
|
|
234
339
|
`--project` is needed unless the app holds exactly one picked project.
|
|
235
340
|
- **`log`** adds a row to the day in Timetrack. It is **not** a Tempo entry: the user reviews
|
|
236
341
|
the day and syncs it, which is what keeps an agent's row from double-booking against the
|
|
237
|
-
hours the day already observed. `--at
|
|
342
|
+
hours the day already observed. `--at` places it - an ISO instant, or a local `YYYY-MM-DD` or `HH:MM` (today); without one it starts now.
|
|
238
343
|
|
|
239
344
|
## What the app decides, not you
|
|
240
345
|
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: verify-in-app
|
|
3
|
+
description: Verify a UI change in the running app by driving it headlessly with Playwright - screenshots, computed styles, pointer and cursor state - and prove a fix with a test that fails without it. Use whenever you change a view, a component or its styles and need to confirm what renders, not just that it compiles.
|
|
4
|
+
kind: skill
|
|
5
|
+
scope: consumer
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Verify a UI change in the running app
|
|
9
|
+
|
|
10
|
+
A build that passes says nothing about what the user sees. Serve the app, drive the real page in
|
|
11
|
+
a headless browser, and assert on the rendered DOM and computed styles.
|
|
12
|
+
|
|
13
|
+
## 1. Serve the app
|
|
14
|
+
|
|
15
|
+
It may already be running. Check before starting a second instance - the dev server prompts for
|
|
16
|
+
another port and hangs:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
curl -s -o /dev/null -w "%{http_code}" http://localhost:4200/
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
- `200` → it is up.
|
|
23
|
+
- anything else → start it in the background and poll the curl above until it answers (a cold
|
|
24
|
+
build can take a minute):
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
npx nx serve <app>
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
The port and any proxy config live in the app's `project.json` `serve` target. A view behind a
|
|
31
|
+
login needs a session: log in once through the form in the script, or seed the token storage the
|
|
32
|
+
app reads before `page.goto`.
|
|
33
|
+
|
|
34
|
+
## 2. Drive it with Playwright
|
|
35
|
+
|
|
36
|
+
Use the `playwright` package from the repo's `node_modules`. Write the script in a scratch
|
|
37
|
+
directory outside the repo and run it with `node`:
|
|
38
|
+
|
|
39
|
+
```js
|
|
40
|
+
import { createRequire } from 'node:module';
|
|
41
|
+
|
|
42
|
+
const require = createRequire(`${process.cwd()}/`);
|
|
43
|
+
const { chromium } = require('playwright');
|
|
44
|
+
|
|
45
|
+
const browser = await chromium.launch();
|
|
46
|
+
const page = await browser.newPage({ viewport: { width: 1280, height: 900 } });
|
|
47
|
+
|
|
48
|
+
await page.goto('http://localhost:4200/players?search=mul', { waitUntil: 'domcontentloaded' });
|
|
49
|
+
await page.waitForSelector('.players-table', { state: 'attached' });
|
|
50
|
+
|
|
51
|
+
const state = await page.$eval('.players-table', (el) => {
|
|
52
|
+
const cs = getComputedStyle(el);
|
|
53
|
+
return { display: cs.display, color: cs.color, rows: el.querySelectorAll('tr').length };
|
|
54
|
+
});
|
|
55
|
+
console.log(JSON.stringify(state));
|
|
56
|
+
|
|
57
|
+
await page.screenshot({ path: '/tmp/players.png', fullPage: true });
|
|
58
|
+
await browser.close();
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Then read the screenshot back and look at it.
|
|
62
|
+
|
|
63
|
+
- **`playwright` is CommonJS** - resolve it with `createRequire`; a named ESM import fails.
|
|
64
|
+
- **`waitUntil: 'domcontentloaded'`, never `networkidle`** - the dev server's live-reload socket
|
|
65
|
+
and any polling query keep the network busy forever.
|
|
66
|
+
- **`waitForSelector` waits for visible by default.** Pass `{ state: 'attached' }` for anything
|
|
67
|
+
correctly hidden (`opacity: 0`, collapsed, `inert`).
|
|
68
|
+
- **Overlays render at the end of `<body>`,** not inside the view that opened them. Query them from
|
|
69
|
+
`page`, not from the view's element.
|
|
70
|
+
- After a click that starts a transition, wait out its duration before reading final styles.
|
|
71
|
+
|
|
72
|
+
## 3. Pointer and cursor state
|
|
73
|
+
|
|
74
|
+
`getComputedStyle(el).cursor` reports what the stylesheet says, not what the pointer gets - an
|
|
75
|
+
overlay, a backdrop or `pointer-events: none` in between changes the answer. Ask the page which
|
|
76
|
+
element is under the point instead:
|
|
77
|
+
|
|
78
|
+
```js
|
|
79
|
+
const hit = await page.evaluate(
|
|
80
|
+
([x, y]) => {
|
|
81
|
+
const el = document.elementFromPoint(x, y);
|
|
82
|
+
return el && { tag: el.tagName, class: el.className, cursor: getComputedStyle(el).cursor };
|
|
83
|
+
},
|
|
84
|
+
[640, 300],
|
|
85
|
+
);
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
`elementFromPoint` returns `null` outside the viewport, so use a viewport tall enough for the
|
|
89
|
+
point, or scroll the target into view first. Hover with `page.mouse.move(x, y)` before reading
|
|
90
|
+
`:hover` styles.
|
|
91
|
+
|
|
92
|
+
## 4. Prove a fix with a test that bites
|
|
93
|
+
|
|
94
|
+
A screenshot shows the fix once; a test keeps it. For a bug fix, write the spec (a unit spec for
|
|
95
|
+
logic, or a Playwright e2e spec where the app has one), then:
|
|
96
|
+
|
|
97
|
+
1. run it against the fix - it passes;
|
|
98
|
+
2. revert the fix (stash it, or comment the one line out) and run it again - it **must fail**;
|
|
99
|
+
3. restore the fix.
|
|
100
|
+
|
|
101
|
+
A test that passes both ways asserts on the wrong thing. Fix the test before you call the change
|
|
102
|
+
done.
|
|
103
|
+
|
|
104
|
+
## 5. Report
|
|
105
|
+
|
|
106
|
+
Say what you drove (URL, clicks, viewport) and what you observed, with the numbers you read. Keep
|
|
107
|
+
the script and screenshots in the scratch directory, not the repo.
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# Style app components with Tailwind utilities
|
|
2
|
+
|
|
3
|
+
The earlier guidance left app styling open, so app components were often styled with a BEM class
|
|
4
|
+
system or a `.css` file per component. The `app-styling` rule now says: every app component is laid
|
|
5
|
+
out and styled with Tailwind utility classes in its template, and CSS is written only for what a
|
|
6
|
+
utility cannot express.
|
|
7
|
+
|
|
8
|
+
## Before you start
|
|
9
|
+
|
|
10
|
+
`et update` regenerates the agent rules when it moves `@ethlete/agent-rules`. If this repo has no
|
|
11
|
+
`app-styling` rule yet (in `AGENTS.md`, or under `.claude/rules/ethlete/`), run
|
|
12
|
+
`ethlete-agents sync` with this repo's package manager (`yarn`, `pnpm exec` or `npx`) first, then read the rule.
|
|
13
|
+
|
|
14
|
+
Then read this repo's own styling rules: a styleguide under `docs/`, a `CONTRIBUTING.md`, lint
|
|
15
|
+
config for CSS, and the parts of `AGENTS.md` outside the generated block. Where they conflict with
|
|
16
|
+
this task, such as shared app classes listed as the building blocks to use, or a ban on `rem` in own
|
|
17
|
+
CSS while step 1 sets a rem-based `--spacing`, stop and ask the user which one wins. Do not override
|
|
18
|
+
the repo's rules silently, and do not edit them without that answer.
|
|
19
|
+
|
|
20
|
+
## Find the call sites
|
|
21
|
+
|
|
22
|
+
The components with a stylesheet, whatever their file suffix:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
grep -rlE 'styleUrls?:' apps libs --include='*.ts'
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
The stylesheets those components reference. This leaves out the global stylesheets (the `styles`
|
|
29
|
+
of a build target, and the files they `@import`) and generated theme files:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
grep -rlE 'styleUrls?:' apps libs --include='*.ts' | while read -r file; do
|
|
33
|
+
grep -oE "['\"][^'\"]+\.(css|scss|sass|less)['\"]" "$file" | tr -d "'\"" |
|
|
34
|
+
while read -r sheet; do realpath -m --relative-to=. "$(dirname "$file")/$sheet"; done
|
|
35
|
+
done | sort -u
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
BEM classes in templates, class bindings, `routerLinkActive` and host classes, app rules in
|
|
39
|
+
`@layer components` (global stylesheets included), and the components that do not set
|
|
40
|
+
`ViewEncapsulation.None` yet:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
grep -rnE 'class="[^"]*\b[a-z0-9-]+__[a-z0-9-]+' apps libs --include='*.html' --include='*.ts'
|
|
44
|
+
grep -rnE '\[class\.[a-z0-9_-]+\]|\[ngClass\]|\[class\]=' apps libs --include='*.html' --include='*.ts'
|
|
45
|
+
grep -rnE 'routerLinkActive="[^"]+"' apps libs --include='*.html' --include='*.ts'
|
|
46
|
+
grep -rnE "(host: \{|'\[class(\.[a-z0-9_-]+)?\]'|class: ')" apps libs --include='*.ts'
|
|
47
|
+
grep -rlE '@layer components' apps libs --include='*.css' --include='*.scss'
|
|
48
|
+
grep -rlE '@Component\(' apps libs --include='*.ts' | xargs grep -LE 'ViewEncapsulation\.None'
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## What to change
|
|
52
|
+
|
|
53
|
+
1. Check the root font size and the spacing scale first. On the 10px root the SDK needs, Tailwind's
|
|
54
|
+
rem scales are 1.6× smaller than their nominal size. Set `--spacing: 0.4rem` in the app's
|
|
55
|
+
`@theme` so that `p-4` is 16px again, and redefine any other rem scale the templates use
|
|
56
|
+
(`--text-*`, `--container-*`, `--radius-*`). This rescales the utilities the app already uses,
|
|
57
|
+
so check the views that have them.
|
|
58
|
+
2. Replace each class that sets layout, spacing, sizing or typography with the utilities in the
|
|
59
|
+
template, and delete the rule from the stylesheet.
|
|
60
|
+
3. Replace a hardcoded colour with a theme utility or a `var(--et-…)` token. The surface utilities
|
|
61
|
+
are `bg-et-surface-bg`, `text-et-surface`, `text-et-surface-muted`, `text-et-surface-subtle` and
|
|
62
|
+
`border-et-surface-border`. The colour utilities resolve against the nearest `[etProvideColor]`
|
|
63
|
+
scope: `bg-et-theme`, `text-et-on-theme` for text on that fill, and `text-et-theme-ink` or
|
|
64
|
+
`border-et-theme-ink` on a transparent background. Each has `-hover`, `-focus`, `-active` and
|
|
65
|
+
`-disabled` variants. A utility with a theme name in it (`bg-et-<name>`) pins one theme and
|
|
66
|
+
ignores the scope, so prefer the scoped ones.
|
|
67
|
+
4. Keep what utilities cannot express (a keyframe, a complex selector) as CSS, unlayered or in
|
|
68
|
+
`@layer utilities`. Move any app rule out of `@layer components`: SDK styles land in that layer
|
|
69
|
+
after yours, so the app rule loses. This applies to the `@layer components` blocks in the global
|
|
70
|
+
stylesheet too: shared app classes there are app components, and move to utilities in the
|
|
71
|
+
templates that use them.
|
|
72
|
+
5. Find the app's unlayered overrides of SDK classes (`.et-button`, `.et-badge`, …) in the global
|
|
73
|
+
stylesheet and in component sheets:
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
grep -rnE '(^|[ ,>+~])\.et-[a-z0-9-]+' apps libs --include='*.css' --include='*.scss'
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Unlayered CSS beats every layer, so such a rule beats the utilities you add on the same element.
|
|
80
|
+
Replace it with the component's `--et-*` tokens or a utility on the element. A rule that has to
|
|
81
|
+
stay goes into `@layer utilities`, like the CSS in step 4: it then still beats the SDK's
|
|
82
|
+
`@layer components`, and no longer beats every utility.
|
|
83
|
+
|
|
84
|
+
6. To restyle an SDK component, set its `--et-*` tokens first, then a utility on the element.
|
|
85
|
+
7. Delete a stylesheet that ends up empty, and its `styleUrl`.
|
|
86
|
+
8. Keep `encapsulation: ViewEncapsulation.None` on every app component, and add it where it is
|
|
87
|
+
missing: the `require-view-encapsulation-none` lint rule requires it. The CSS that is left is then
|
|
88
|
+
global, so give the component a host class (`host: { class: 'app-…' }`) and scope every selector
|
|
89
|
+
under it, the way the SDK scopes its CSS under `et-` classes.
|
|
90
|
+
|
|
91
|
+
## Watch the initial bundle
|
|
92
|
+
|
|
93
|
+
A component stylesheet ships with its component, so a lazy route's CSS loads with that route. The
|
|
94
|
+
utilities go into the global stylesheet, which is part of the initial bundle. Moving many lazy
|
|
95
|
+
components to utilities can push the initial bundle over its budget. Check the build's initial
|
|
96
|
+
total against the budget after each batch. Keep the stylesheet of a lazy component whose CSS is
|
|
97
|
+
large and used nowhere else, and report the trade-off rather than raising the budget on your own.
|
|
98
|
+
|
|
99
|
+
## Leave these alone
|
|
100
|
+
|
|
101
|
+
- A library that ships its own CSS to other repos. The rule is about app components.
|
|
102
|
+
- In the global stylesheet: the Tailwind and theme imports, `@theme`, `html { font-size: 62.5%; }`
|
|
103
|
+
and the `@layer base` rules for elements (`html`, `body`, `a`).
|
|
104
|
+
- Generated theme files, such as the surface and colour themes `@ethlete/core` writes.
|
|
105
|
+
- CSS that no utility expresses.
|
|
106
|
+
|
|
107
|
+
## When you are done
|
|
108
|
+
|
|
109
|
+
Run the type check, the lint task and the build of every project you changed, and look at each
|
|
110
|
+
changed view in the browser: a missed class renders unstyled, not as an error.
|
|
111
|
+
|
|
112
|
+
The page the `Docs` line at the top of this file links (`/components/setup` on the SDK docs site)
|
|
113
|
+
summarises the rule under "Styles". Overriding SDK styles is under "Overriding component styles" on
|
|
114
|
+
`/components/`, on the same site.
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# Move URL-bound list state to `defineQueryForm`
|
|
2
|
+
|
|
3
|
+
The earlier query guidance did not name `defineQueryForm`, so list pages wired their filters,
|
|
4
|
+
search, sort and paging to the URL by hand: `injectQueryParams()` to read, `router.navigate` to write,
|
|
5
|
+
and a draft signal plus an effect in between. `defineQueryForm` does the URL sync, the debounce, the
|
|
6
|
+
defaults and the page reset, and the `ethlete-query` skill now says to use it for every filtered,
|
|
7
|
+
searched, sorted or paged list.
|
|
8
|
+
|
|
9
|
+
## Before you start
|
|
10
|
+
|
|
11
|
+
`et update` regenerates the skills when it moves `@ethlete/agent-rules`. If
|
|
12
|
+
`.agents/skills/ethlete-query/SKILL.md` does not mention `defineQueryForm`, run
|
|
13
|
+
`ethlete-agents sync` with this repo's package manager (`yarn`, `pnpm exec` or `npx`) first. Read
|
|
14
|
+
the skill, then the guide the `Docs` line at the top of this file links (`/query/query-forms` on the
|
|
15
|
+
SDK docs site).
|
|
16
|
+
|
|
17
|
+
## Find the call sites
|
|
18
|
+
|
|
19
|
+
The reads, with comment lines filtered out:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
grep -rnE 'injectQueryParams?\(|inject\(ActivatedRoute\)|:\s*ActivatedRoute\b|queryParamMap|snapshot\.queryParams|\.queryParams\.(pipe|subscribe)\(' \
|
|
23
|
+
apps libs --include='*.ts' | grep -vE '^[^:]+:[0-9]+:\s*(//|/?\*)'
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
The writes. A navigation spreads its options over several lines, so this prints the `queryParams`
|
|
27
|
+
line up to three lines below each call:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
grep -rnE -A3 '\.navigate(ByUrl)?\(|createUrlTree\(|\.(go|replaceState|pushState)\(' apps libs --include='*.ts' \
|
|
31
|
+
| grep -E '\bqueryParams(Handling)?\b|\?[a-zA-Z_-]+='
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
A call site is in scope when the params it reads or writes are the arguments of a list query:
|
|
35
|
+
search text, filters, sort, page or page size.
|
|
36
|
+
|
|
37
|
+
## What to change
|
|
38
|
+
|
|
39
|
+
1. Declare one `defineQueryForm({ fields })` per list, with a field creator per param
|
|
40
|
+
(`searchQueryField`, `sortQueryField`, `queryField<number>({ defaultValue: 1 })` for the page, and
|
|
41
|
+
the typed array and date creators for filters), and call `.observe()` on it. Give a `queryField`
|
|
42
|
+
a `defaultValue` wherever the old code had one: the field is then typed without `null`, so reads
|
|
43
|
+
need no `?? DEFAULT`. A `queryField<T>()` without a default reads the URL as a string, so any
|
|
44
|
+
other `T` needs a `queryParamToValue` such as `transformToNumber`. A date field bound to an
|
|
45
|
+
`et-date-input`, `et-date-time-input` or `et-time-input` needs `dateQueryField({ as: 'string' })`,
|
|
46
|
+
because those controls hold a string.
|
|
47
|
+
2. Give the page field `isResetBy` for the fields that must send the list back to page 1.
|
|
48
|
+
3. Feed the query from `withArgs(() => … this.qf.value() …)`.
|
|
49
|
+
4. Bind each form control with `[formField]="qf.fields.<name>"`. `et-pagination` and
|
|
50
|
+
`et-page-size-select` are not form controls; bind their two-way `model` to the field's value
|
|
51
|
+
signal instead: `[(page)]="qf.fields.page().value"`, `[(pageSize)]="qf.fields.limit().value"`.
|
|
52
|
+
A table sort needs a mapping both ways; see "Sort from a table" below.
|
|
53
|
+
5. Delete the hand-written reads, writes, draft signals and effects the form replaces.
|
|
54
|
+
6. Keep the param names the URL already uses, so saved links keep working. Each field is one param,
|
|
55
|
+
named by its key. Use `queryParamPrefix` when two lists share a route.
|
|
56
|
+
7. If the old code navigated with `replaceUrl: true`, so filtering did not add history entries, pass
|
|
57
|
+
`observe({ replaceUrl: true })`. Without it every commit pushes an entry.
|
|
58
|
+
|
|
59
|
+
### Sort and direction in two params
|
|
60
|
+
|
|
61
|
+
`sortQueryField()` writes one param as `active:direction` (`?sort=name:asc`). No field maps two
|
|
62
|
+
params, so a URL with separate params such as `?sort=name&dir=asc` needs a decision:
|
|
63
|
+
|
|
64
|
+
- **Keep the URL** (the default). Declare two fields, `sort: queryField<SortKey>(…)` and
|
|
65
|
+
`dir: queryField<'asc' | 'desc'>(…)`, each with a `defaultValue` and a `queryParamToValue` that
|
|
66
|
+
maps an unknown value to the default. Give `dir` `skipInFilterCount: true`: only `sort` is on the
|
|
67
|
+
list of names `activeFilterCount` ignores. Put both in the page field's `isResetBy`.
|
|
68
|
+
- **Move to `sortQueryField()`** when you are free to change the URL. Old links with a `dir` param
|
|
69
|
+
lose their direction. Say so in the commit message.
|
|
70
|
+
|
|
71
|
+
### Sort from a table
|
|
72
|
+
|
|
73
|
+
`et-table` holds its sort as `TableSort[]` (`{ key, direction }`); `sortQueryField()` holds one
|
|
74
|
+
`Sort` (`{ active, direction }`). Nothing converts between them and the table does not write the
|
|
75
|
+
URL, so map both ways in the component:
|
|
76
|
+
|
|
77
|
+
```ts
|
|
78
|
+
tableSort = computed<TableSort[]>(() => {
|
|
79
|
+
const sort = this.qf.value().sort;
|
|
80
|
+
|
|
81
|
+
return sort?.direction ? [{ key: sort.active, direction: sort.direction }] : [];
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
onSortChange([first]: TableSort[]) {
|
|
85
|
+
this.qf.patchValue({ sort: first ? { active: first.key, direction: first.direction } : null });
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Bind `[sort]="tableSort()"` and `(sortChange)="onSortChange($event)"`.
|
|
90
|
+
|
|
91
|
+
## Leave these alone
|
|
92
|
+
|
|
93
|
+
- A param that is not list state, such as a `returnUrl`, a tab id or an opened-detail id. Reading it
|
|
94
|
+
with `injectQueryParam` is correct.
|
|
95
|
+
- A one-off `router.navigate` to another page.
|
|
96
|
+
|
|
97
|
+
## When you are done
|
|
98
|
+
|
|
99
|
+
No list may read and write its params by hand any more. Run the type check, the lint task and the tests
|
|
100
|
+
of every project you changed, and check in the browser that reload, back and forward restore the list.
|
|
101
|
+
|
|
102
|
+
The guide in the `Docs` line at the top of this file has every field creator, the URL rules and
|
|
103
|
+
filter overlays.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Compare the workspace with the Nx layout rule
|
|
2
|
+
|
|
3
|
+
`@ethlete/agent-rules` now ships an `nx-layout` rule for Nx workspaces: thin apps, feature code in
|
|
4
|
+
`libs/domain/<app>/<feature>`, shared `queries`, `types`, `uikit`, `theme` and `env` libs, aliases that
|
|
5
|
+
mirror the path, and one `scope:*` tag per project. New code follows it.
|
|
6
|
+
|
|
7
|
+
This is a recommendation, not a rewrite. Moving projects changes import paths, project names, tags
|
|
8
|
+
and CI caches across the whole repo, and the rule itself says not to restructure an existing
|
|
9
|
+
workspace unless the team asks for it.
|
|
10
|
+
|
|
11
|
+
## What to decide
|
|
12
|
+
|
|
13
|
+
1. Read the rule (in `AGENTS.md`, or under `.claude/rules/ethlete/`). If it is missing, run
|
|
14
|
+
`ethlete-agents sync` with this repo's package manager (`yarn`, `pnpm exec` or `npx`).
|
|
15
|
+
2. List where the workspace deviates: feature code inside `apps/`, queries or themes inside an app,
|
|
16
|
+
`feature/ui/data-access/util` folders, aliases that do not mirror the path, missing `scope:*` tags.
|
|
17
|
+
3. Decide per deviation: move it now, move it when that area is next touched, or keep it.
|
|
18
|
+
4. Keep what you decided where an agent reads it, so it stops flagging it: in `AGENTS.md` above the
|
|
19
|
+
`<!-- ethlete:agent-rules:start -->` line or below `<!-- ethlete:agent-rules:end -->`, or in the
|
|
20
|
+
repo's `CONTEXT.md` or an ADR. Never between the two markers: `ethlete-agents sync` rewrites that
|
|
21
|
+
block and drops the edit. If `AGENTS.md` holds nothing but the block, add the decisions above it.
|
|
22
|
+
|
|
23
|
+
An agent may prepare the list in step 2. It must not move a project without that decision.
|