@openora/create 0.4.1-canary.35 → 0.4.1-canary.351
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/dist/.tsbuildinfo +1 -1
- package/dist/generated/versions.d.ts +11 -0
- package/dist/generated/versions.d.ts.map +1 -0
- package/dist/generated/versions.js +11 -0
- package/dist/generated/versions.js.map +1 -0
- package/dist/index.js +48 -93
- package/dist/index.js.map +1 -1
- package/dist/render.d.ts +15 -0
- package/dist/render.d.ts.map +1 -0
- package/dist/render.js +125 -0
- package/dist/render.js.map +1 -0
- package/package.json +8 -6
- package/template/README.md.tpl +12 -7
- package/template/__dot__claude/settings.json.tpl +22 -12
- package/template/__dot__env.example +10 -0
- package/template/__dot__gitignore +5 -0
- package/template/__dot__rulesync/commands/check.md +11 -10
- package/template/__dot__rulesync/commands/doctor.md.tpl +17 -0
- package/template/__dot__rulesync/commands/scaffold-module.md +3 -2
- package/template/__dot__rulesync/commands/scaffold-plugin.md +19 -3
- package/template/__dot__rulesync/commands/scaffold-route.md +1 -0
- package/template/__dot__rulesync/commands/start.md +2 -2
- package/template/__dot__rulesync/hooks/_shared.mjs +26 -13
- package/template/__dot__rulesync/hooks/guard-core.mjs.tpl +76 -28
- package/template/__dot__rulesync/hooks/guard-generated.mjs +15 -13
- package/template/__dot__rulesync/hooks/guard-mr.mjs +81 -0
- package/template/__dot__rulesync/hooks/guard-subagent.mjs +22 -6
- package/template/__dot__rulesync/hooks/post-edit.mjs.tpl +116 -0
- package/template/__dot__rulesync/hooks.json +27 -4
- package/template/__dot__rulesync/rules/conventions.md +99 -119
- package/template/__dot__rulesync/rules/db-conventions.md +6 -72
- package/template/__dot__rulesync/rules/e2e-conventions.md +33 -7
- package/template/__dot__rulesync/rules/frontend-conventions.md.tpl +35 -0
- package/template/__dot__rulesync/rules/oss-boundaries.md.tpl +78 -0
- package/template/__dot__rulesync/rules/overview.md +18 -82
- package/template/__dot__rulesync/skills/create-plugin/{SKILL.md → SKILL.md.tpl} +12 -23
- package/template/__dot__rulesync/skills/create-pr/SKILL.md.tpl +38 -0
- package/template/__dot__rulesync/skills/create-task/{SKILL.md → SKILL.md.tpl} +14 -22
- package/template/__dot__rulesync/skills/create-ui-module/{SKILL.md → SKILL.md.tpl} +12 -10
- package/template/__dot__rulesync/skills/enhance-prompt/{SKILL.md → SKILL.md.tpl} +4 -4
- package/template/__dot__rulesync/skills/handoff/SKILL.md +95 -0
- package/template/__dot__rulesync/skills/review/SKILL.md.tpl +277 -0
- package/template/__dot__rulesync/skills/ship/SKILL.md.tpl +157 -0
- package/template/__dot__rulesync/skills/ship/handoff.md.tpl +48 -0
- package/template/__dot__rulesync/skills/ship/watch-pr.mjs +172 -0
- package/template/__dot__rulesync/skills/ui-verify/SKILL.md.tpl +120 -0
- package/template/__dot__rulesync/skills/ui-verify/layout-check.check.sh +58 -0
- package/template/__dot__rulesync/skills/ui-verify/layout-check.js +163 -0
- package/template/__dot__rulesync/subagents/builder.md.tpl +115 -0
- package/template/__dot__rulesync/subagents/compliance-reviewer.md +78 -0
- package/template/__dot__rulesync/subagents/debugger.md.tpl +86 -0
- package/template/__dot__rulesync/subagents/deployer.md.tpl +54 -0
- package/template/__dot__rulesync/subagents/expert.md +28 -34
- package/template/__dot__rulesync/subagents/planner.md.tpl +39 -0
- package/template/__dot__rulesync/subagents/quality-reviewer.md.tpl +112 -0
- package/template/__dot__rulesync/subagents/security-reviewer.md.tpl +85 -0
- package/template/__dot__rulesync/subagents/tester.md.tpl +102 -0
- package/template/__dot__rulesync/sync.json.tpl +26 -0
- package/template/apps/api/package.json.tpl +2 -2
- package/template/apps/api/src/extensions.config.ts.tpl +12 -0
- package/template/apps/api/src/main.ts.tpl +1 -4
- package/template/apps/api/src/seed.ts.tpl +8 -1
- package/template/docker-compose.yml +13 -0
- package/template/docs/agents/cross-repo.md.tpl +37 -0
- package/template/docs/agents/forge.md.tpl +54 -0
- package/template/docs/agents/issue-tracker.md.tpl +42 -0
- package/template/docs/standards/comments.md +16 -0
- package/template/docs/standards/database.md +77 -0
- package/template/docs/standards/enforcement.md +12 -0
- package/template/docs/standards/errors.md +30 -0
- package/template/docs/standards/frontend.md +60 -0
- package/template/docs/standards/functions.md +38 -0
- package/template/docs/standards/git-delivery.md +20 -0
- package/template/docs/standards/module-structure.md +37 -0
- package/template/docs/standards/testing.md +88 -0
- package/template/docs/standards/types.md +31 -0
- package/template/package.json.tpl +15 -9
- package/template/rulesync.jsonc +3 -3
- package/template/tools/oss-worktree.mjs.tpl +105 -0
- package/template/tools/review-precheck.mjs +531 -0
- package/template/tools/sync-agents.mjs +257 -0
- package/dist/generated/core-version.d.ts +0 -2
- package/dist/generated/core-version.d.ts.map +0 -1
- package/dist/generated/core-version.js +0 -2
- package/dist/generated/core-version.js.map +0 -1
- package/template/__dot__rulesync/commands/doctor.md +0 -16
- package/template/__dot__rulesync/hooks/post-edit.mjs +0 -57
- package/template/__dot__rulesync/rules/oss-boundaries.md +0 -31
- package/template/__dot__rulesync/skills/add-feature/SKILL.md +0 -113
- package/template/__dot__rulesync/skills/add-feature/handoff.md +0 -58
- package/template/__dot__rulesync/skills/create-pr/SKILL.md +0 -55
- package/template/__dot__rulesync/skills/review/SKILL.md +0 -110
- package/template/__dot__rulesync/subagents/builder.md +0 -93
- package/template/__dot__rulesync/subagents/debugger.md +0 -82
- package/template/__dot__rulesync/subagents/deployer.md +0 -66
- package/template/__dot__rulesync/subagents/qa.md +0 -88
- package/template/__dot__rulesync/subagents/quality-reviewer.md +0 -67
- package/template/__dot__rulesync/subagents/security-reviewer.md +0 -54
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Watches the MR or PR of a branch and prints one line, then exits, when ship has work on it:
|
|
3
|
+
// failed | conflicts | threads <ids> | green (only with --until-green) | merged | closed | error <message>
|
|
4
|
+
// A thread waits for an answer until its last comment is an "Agent:" reply from the account this runs as
|
|
5
|
+
// (anyone can type the prefix); it is reported once it stayed the same for two checks, so a review with
|
|
6
|
+
// ten comments is one line. Run it in the background:
|
|
7
|
+
// node watch-pr.mjs <branch> [--until-green] WATCH_DEBUG=1 prints every check to stderr
|
|
8
|
+
// --once checks one time, for a caller that polls on its own (a scheduler): threads are reported at
|
|
9
|
+
// once, and `none` means nothing to do.
|
|
10
|
+
import { execFileSync } from 'node:child_process';
|
|
11
|
+
|
|
12
|
+
const EVERY_MS = 120_000;
|
|
13
|
+
// ponytail: a repo with no CI on the last commit reads green after this; a head-SHA match on checks if it matters
|
|
14
|
+
const NO_CI_MS = 10 * 60_000;
|
|
15
|
+
const MAX_ERRORS = 5;
|
|
16
|
+
const AGENT_REPLY = 'Agent:';
|
|
17
|
+
const FAILED = [
|
|
18
|
+
'FAILURE',
|
|
19
|
+
'ERROR',
|
|
20
|
+
'CANCELLED',
|
|
21
|
+
'TIMED_OUT',
|
|
22
|
+
'ACTION_REQUIRED',
|
|
23
|
+
'STARTUP_FAILURE',
|
|
24
|
+
'failed',
|
|
25
|
+
'canceled',
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
const [branch, ...flags] = process.argv.slice(2);
|
|
29
|
+
if (!branch) {
|
|
30
|
+
console.error('usage: watch-pr.mjs <branch> [--until-green | --once]');
|
|
31
|
+
process.exit(2);
|
|
32
|
+
}
|
|
33
|
+
const untilGreen = flags.includes('--until-green');
|
|
34
|
+
const once = flags.includes('--once');
|
|
35
|
+
|
|
36
|
+
const run = (cmd, args) =>
|
|
37
|
+
execFileSync(cmd, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
38
|
+
const json = (cmd, args) => JSON.parse(run(cmd, args));
|
|
39
|
+
const unansweredIds = (lastComments, me) =>
|
|
40
|
+
lastComments
|
|
41
|
+
.filter(
|
|
42
|
+
(comment) => !(comment.author === me && comment.body.trimStart().startsWith(AGENT_REPLY)),
|
|
43
|
+
)
|
|
44
|
+
.map((comment) => String(comment.id))
|
|
45
|
+
.sort();
|
|
46
|
+
|
|
47
|
+
const THREADS = `query($owner: String!, $name: String!, $number: Int!, $endCursor: String) {
|
|
48
|
+
viewer { login }
|
|
49
|
+
repository(owner: $owner, name: $name) { pullRequest(number: $number) {
|
|
50
|
+
reviewThreads(first: 100, after: $endCursor) {
|
|
51
|
+
nodes { isResolved comments(last: 1) { nodes { id body author { login } } } }
|
|
52
|
+
pageInfo { hasNextPage endCursor }
|
|
53
|
+
}
|
|
54
|
+
comments(last: 1) { nodes { id body author { login } } }
|
|
55
|
+
} }
|
|
56
|
+
}`;
|
|
57
|
+
|
|
58
|
+
function github() {
|
|
59
|
+
const pr = json('gh', [
|
|
60
|
+
'pr',
|
|
61
|
+
'view',
|
|
62
|
+
branch,
|
|
63
|
+
'--json',
|
|
64
|
+
'number,url,state,mergeable,statusCheckRollup',
|
|
65
|
+
]);
|
|
66
|
+
const [, owner, name] = new URL(pr.url).pathname.split('/');
|
|
67
|
+
const args = [
|
|
68
|
+
'api',
|
|
69
|
+
'graphql',
|
|
70
|
+
'--paginate',
|
|
71
|
+
'--slurp',
|
|
72
|
+
'-f',
|
|
73
|
+
`query=${THREADS}`,
|
|
74
|
+
'-f',
|
|
75
|
+
`owner=${owner}`,
|
|
76
|
+
'-f',
|
|
77
|
+
`name=${name}`,
|
|
78
|
+
'-F',
|
|
79
|
+
`number=${pr.number}`,
|
|
80
|
+
];
|
|
81
|
+
const pages = json('gh', args).map((page) => page.data);
|
|
82
|
+
const open = pages
|
|
83
|
+
.flatMap(({ repository }) => repository.pullRequest.reviewThreads.nodes)
|
|
84
|
+
.filter((thread) => !thread.isResolved)
|
|
85
|
+
.flatMap((thread) => thread.comments.nodes)
|
|
86
|
+
.concat(pages[0].repository.pullRequest.comments.nodes)
|
|
87
|
+
.map((comment) => ({ ...comment, author: comment.author?.login }));
|
|
88
|
+
const checks = pr.statusCheckRollup ?? [];
|
|
89
|
+
const ci = !checks.length
|
|
90
|
+
? null
|
|
91
|
+
: checks.some((check) => FAILED.includes(check.conclusion || check.state))
|
|
92
|
+
? 'failed'
|
|
93
|
+
: checks.some((check) =>
|
|
94
|
+
check.status
|
|
95
|
+
? check.status !== 'COMPLETED'
|
|
96
|
+
: check.state === 'PENDING' || check.state === 'EXPECTED',
|
|
97
|
+
)
|
|
98
|
+
? 'running'
|
|
99
|
+
: 'passed';
|
|
100
|
+
return {
|
|
101
|
+
state: pr.state.toLowerCase(),
|
|
102
|
+
conflicts: pr.mergeable === 'CONFLICTING',
|
|
103
|
+
ci,
|
|
104
|
+
unanswered: unansweredIds(open, pages[0].viewer.login),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function gitlab() {
|
|
109
|
+
const mr = json('glab', ['mr', 'view', branch, '-F', 'json']);
|
|
110
|
+
const discussions = run('glab', [
|
|
111
|
+
'api',
|
|
112
|
+
'--paginate',
|
|
113
|
+
'--output',
|
|
114
|
+
'ndjson',
|
|
115
|
+
`projects/${mr.project_id}/merge_requests/${mr.iid}/discussions?per_page=100`,
|
|
116
|
+
])
|
|
117
|
+
.split('\n')
|
|
118
|
+
.filter(Boolean)
|
|
119
|
+
.map((line) => JSON.parse(line));
|
|
120
|
+
const open = discussions
|
|
121
|
+
.map((discussion) => discussion.notes.filter((note) => !note.system))
|
|
122
|
+
.filter(
|
|
123
|
+
(notes) =>
|
|
124
|
+
notes.length &&
|
|
125
|
+
!(
|
|
126
|
+
notes.some((note) => note.resolvable) &&
|
|
127
|
+
notes.every((note) => !note.resolvable || note.resolved)
|
|
128
|
+
),
|
|
129
|
+
)
|
|
130
|
+
.map((notes) => notes.at(-1))
|
|
131
|
+
.map((note) => ({ ...note, author: note.author?.username }));
|
|
132
|
+
const pipeline = mr.head_pipeline?.sha === mr.sha ? mr.head_pipeline.status : null;
|
|
133
|
+
const ci = !pipeline
|
|
134
|
+
? null
|
|
135
|
+
: FAILED.includes(pipeline)
|
|
136
|
+
? 'failed'
|
|
137
|
+
: ['success', 'skipped', 'manual'].includes(pipeline)
|
|
138
|
+
? 'passed'
|
|
139
|
+
: 'running';
|
|
140
|
+
const state = mr.state === 'opened' ? 'open' : mr.state === 'merged' ? 'merged' : 'closed';
|
|
141
|
+
const me = json('glab', ['api', 'user']).username;
|
|
142
|
+
return { state, conflicts: mr.has_conflicts === true, ci, unanswered: unansweredIds(open, me) };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const say = (line) => {
|
|
146
|
+
console.log(line);
|
|
147
|
+
process.exit(0);
|
|
148
|
+
};
|
|
149
|
+
const status = /github\.com/.test(run('git', ['remote', 'get-url', 'origin'])) ? github : gitlab;
|
|
150
|
+
const started = Date.now();
|
|
151
|
+
let seen = null;
|
|
152
|
+
let errors = 0;
|
|
153
|
+
for (;;) {
|
|
154
|
+
try {
|
|
155
|
+
const now = status();
|
|
156
|
+
errors = 0;
|
|
157
|
+
if (process.env.WATCH_DEBUG) console.error(JSON.stringify(now));
|
|
158
|
+
if (now.state !== 'open') say(now.state);
|
|
159
|
+
if (now.conflicts) say('conflicts');
|
|
160
|
+
if (now.ci === 'failed') say('failed');
|
|
161
|
+
const ids = now.unanswered.join(' ');
|
|
162
|
+
if (ids && (once || ids === seen)) say(`threads ${ids}`);
|
|
163
|
+
seen = ids;
|
|
164
|
+
const green = now.ci === 'passed' || (now.ci === null && Date.now() - started >= NO_CI_MS);
|
|
165
|
+
if (untilGreen && green && !ids) say('green');
|
|
166
|
+
if (once) say('none');
|
|
167
|
+
} catch (reason) {
|
|
168
|
+
if (once || ++errors >= MAX_ERRORS)
|
|
169
|
+
say(`error ${reason instanceof Error ? reason.message.split('\n')[0] : reason}`);
|
|
170
|
+
}
|
|
171
|
+
await new Promise((resolve) => setTimeout(resolve, EVERY_MS));
|
|
172
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: ui-verify
|
|
3
|
+
targets: ['*']
|
|
4
|
+
description: >
|
|
5
|
+
Verify a UI change in a real browser with the global `playwright-cli`: cheap checks first,
|
|
6
|
+
then four screenshots per changed screen read for spacing, overlap, cut text and contrast,
|
|
7
|
+
then the per-AC videos for the PR. Use after any change to a page, component, style or
|
|
8
|
+
player/operator flow, on "check it looks right", "verify the UI", "does it render", "take a
|
|
9
|
+
screenshot", "record a video", and to reproduce a UI bug before fixing it.
|
|
10
|
+
claudecode:
|
|
11
|
+
context: fork
|
|
12
|
+
model: sonnet
|
|
13
|
+
effort: medium
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
# ui-verify ({{name}})
|
|
17
|
+
|
|
18
|
+
Global `playwright-cli` (`npm i -g @playwright/cli` if missing), never a browser MCP: it costs a
|
|
19
|
+
fraction of the tokens. Do not spawn subagents. Return text only. A subagent (`tester`, `builder`) does not
|
|
20
|
+
invoke this skill, which forks one of its own: it reads this file and follows it itself.
|
|
21
|
+
|
|
22
|
+
## Stack
|
|
23
|
+
|
|
24
|
+
- Player app :3000, backoffice :3002, API :3001, unless the repo's rules name other ports (a
|
|
25
|
+
worktree slot, say). Ports held by another checkout: never kill it, ask.
|
|
26
|
+
- Accounts: the seed's (`admin@oss.dev` / `password1234` after `pnpm db:seed`). Save a login once
|
|
27
|
+
with `state-save /tmp/ui-verify/<app>.json`, `state-load` it after.
|
|
28
|
+
- Theme: switch with the app's own toggle in a video; for a still, set what the toggle sets
|
|
29
|
+
(a cookie or `localStorage`) with `eval`, then reload.
|
|
30
|
+
|
|
31
|
+
## Cost ladder
|
|
32
|
+
|
|
33
|
+
Deterministic checks first, reading pixels last. Write each check as an assertion with an
|
|
34
|
+
expected value (`[data-testid=balance]` text matches `/\d+(\.\d+)? USDT/`) and stop at the
|
|
35
|
+
first rung that answers it.
|
|
36
|
+
|
|
37
|
+
1. **Shell.** The route serves: `curl -s -o /dev/null -w '%{http_code}' <url>`.
|
|
38
|
+
2. **Browser, one value.** `eval` for existence, text, count, computed style; `console error`
|
|
39
|
+
and `requests` grepped for `4\d\d|5\d\d`; `find "<text>"` for one node.
|
|
40
|
+
3. **Structure.** `snapshot <target>` scoped to the changed component; a full-page `snapshot`
|
|
41
|
+
only with `--depth` or `--filename` + `grep`.
|
|
42
|
+
4. **Pixels.** The screenshot review below.
|
|
43
|
+
|
|
44
|
+
Batch commands in one bash call. Output over ~50 lines goes to `/tmp/ui-verify/` and gets
|
|
45
|
+
`grep`, not read whole. One named session per run (`-s=<name>`), `close` at the end.
|
|
46
|
+
|
|
47
|
+
## Screenshot review
|
|
48
|
+
|
|
49
|
+
Screenshots are for you, videos for the reviewer: nobody reads a video back, so layout defects
|
|
50
|
+
are caught here. Every changed screen, after the checks pass and before the videos. Four shots:
|
|
51
|
+
mobile and desktop, light and dark (backoffice: desktop only).
|
|
52
|
+
|
|
53
|
+
- Sizes: never shrink a shot, halving it halves every padding you judge. Mobile 390x844 at 2x
|
|
54
|
+
device pixels (780x1688), desktop 1440x900 at 1x: both under the 2000 px per-image cap a
|
|
55
|
+
session with over 20 images gets, about 1.7k tokens each.
|
|
56
|
+
- Tall screens: one shot per viewport height (`eval "() => scrollTo(0, <y>)"`, then shoot),
|
|
57
|
+
never `--full-page`, which downscales a long page until its text is unreadable.
|
|
58
|
+
- Before reading, run `layout-check.js` in each of the four. It lists overlapping, clipped,
|
|
59
|
+
truncated and off-screen text and a page that scrolls sideways; every line it returns is a
|
|
60
|
+
check to settle on the shot. `sh .claude/skills/ui-verify/layout-check.check.sh` is its self-test.
|
|
61
|
+
- Read each shot for: spacing consistent with its neighbours, alignment, overlapping or cut text,
|
|
62
|
+
anything off-screen or out of its container, contrast in dark, and the ticket's designs. Every
|
|
63
|
+
shot gets a report line, a clean one too. A defect is a FAIL with the png path; it gets fixed
|
|
64
|
+
and shot again.
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
mkdir -p /tmp/ui-verify
|
|
68
|
+
echo '{"browser":{"contextOptions":{"deviceScaleFactor":2}}}' > /tmp/ui-verify/2x.json
|
|
69
|
+
playwright-cli -s=m open <url> --config=/tmp/ui-verify/2x.json && playwright-cli -s=m resize 390 844
|
|
70
|
+
playwright-cli -s=m eval "$(cat .claude/skills/ui-verify/layout-check.js)"
|
|
71
|
+
playwright-cli -s=m screenshot --hires --filename=/tmp/ui-verify/<screen>-mobile-dark.png
|
|
72
|
+
# switch theme, reload, repeat as -light; desktop in its own session:
|
|
73
|
+
# no --config, resize 1440 900, no --hires
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
In the PR, after the Videos section, collapsed per screen (upload as in `docs/agents/forge.md`):
|
|
77
|
+
|
|
78
|
+
```markdown
|
|
79
|
+
<details><summary>Screenshots: <screen></summary>
|
|
80
|
+
|
|
81
|
+
| | Light | Dark |
|
|
82
|
+
| ------- | ------------------------ | ----------------------- |
|
|
83
|
+
| Mobile |  |  |
|
|
84
|
+
| Desktop |  |  |
|
|
85
|
+
|
|
86
|
+
</details>
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Video evidence
|
|
90
|
+
|
|
91
|
+
Last, once, on the final commit after the tests are green and review is clean (`ship` step 4b):
|
|
92
|
+
a clip taken earlier is thrown away by the next fix round. One clip per acceptance criterion
|
|
93
|
+
(`docs/standards/testing.md`), recorded on the local stack. The one early clip is a fix's
|
|
94
|
+
"before", taken when the bug is reproduced; its "after" twin comes here. Never read a video back.
|
|
95
|
+
|
|
96
|
+
ACs a spec drives come first: every spec that drives one, new or existing, in one run with video
|
|
97
|
+
on (the repo's record switch, or `test.use({ video: 'on' })`); copy each
|
|
98
|
+
`test-results/<test>/video.webm` to `<ac>-<slug>-{desktop,mobile}.webm`. Only an AC no spec
|
|
99
|
+
drives gets `playwright-cli`, one at mobile size (`resize 390 844`) and one at desktop
|
|
100
|
+
(`resize 1440 900`), each switching theme with the app's toggle partway through:
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
playwright-cli -s=v video-start /tmp/ui-verify/ac1-<slug>-mobile.webm
|
|
104
|
+
playwright-cli -s=v video-show-actions
|
|
105
|
+
playwright-cli -s=v video-chapter "AC1: <criterion in a few words>" --duration=1500
|
|
106
|
+
# drive the AC ... toggle the theme ... until its outcome is on screen
|
|
107
|
+
playwright-cli -s=v video-stop
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Report
|
|
111
|
+
|
|
112
|
+
One line per check: `PASS|FAIL - <check> - rung <n> - expected <x>, got <y> - <artifact path>`,
|
|
113
|
+
plus the failure detail for each FAIL. A pixel claim neither a measurement nor the shot can
|
|
114
|
+
settle is `UNSURE - <check> - <what is ambiguous> - <png path>`, never a guess. Return every shot
|
|
115
|
+
and clip path.
|
|
116
|
+
|
|
117
|
+
## Bugs
|
|
118
|
+
|
|
119
|
+
Reproduce here first, as close to real use as possible, then fix, then rerun the same checks.
|
|
120
|
+
A fix without a before and after run is unverified.
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# Self-check for layout-check.js: the page below plants 5 defects next to 5 look-alikes that are fine.
|
|
3
|
+
set -e
|
|
4
|
+
dir=$(dirname "$0")
|
|
5
|
+
fixture=$(cat <<'HTML'
|
|
6
|
+
<!doctype html>
|
|
7
|
+
<html>
|
|
8
|
+
<body style="margin: 0; font: 16px sans-serif; width: 390px">
|
|
9
|
+
<p>
|
|
10
|
+
Clean wrapped paragraph that wraps across lines with a <a href="#">link inside</a> and more
|
|
11
|
+
text after it.
|
|
12
|
+
</p>
|
|
13
|
+
<div data-testid="overlap-card" style="position: relative; height: 40px">
|
|
14
|
+
<span>Balance</span><span style="position: absolute; left: 20px; top: 0">USDT 12.50</span>
|
|
15
|
+
</div>
|
|
16
|
+
<div data-testid="clip-box" style="height: 18px; overflow: hidden">
|
|
17
|
+
<p style="margin: 0">Line one<br />Line two cut off</p>
|
|
18
|
+
</div>
|
|
19
|
+
<div
|
|
20
|
+
data-testid="ellipsis"
|
|
21
|
+
style="width: 80px; overflow: hidden; white-space: nowrap; text-overflow: ellipsis"
|
|
22
|
+
>
|
|
23
|
+
A very long game title
|
|
24
|
+
</div>
|
|
25
|
+
<span style="position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0)"
|
|
26
|
+
>Screen reader only</span
|
|
27
|
+
>
|
|
28
|
+
<div style="overflow: hidden; width: 200px; white-space: nowrap">
|
|
29
|
+
<div style="display: inline-block; width: 200px">Slide one</div>
|
|
30
|
+
<div style="display: inline-block; width: 200px">Slide two parked</div>
|
|
31
|
+
</div>
|
|
32
|
+
<div data-testid="wide" style="white-space: nowrap">
|
|
33
|
+
This label is far too wide for a mobile viewport and runs out
|
|
34
|
+
</div>
|
|
35
|
+
<div style="overflow-x: auto; white-space: nowrap; width: 200px">
|
|
36
|
+
Scrollable chips row that is wider than its box on purpose
|
|
37
|
+
</div>
|
|
38
|
+
<p style="position: relative">Under the modal text</p>
|
|
39
|
+
<div style="position: fixed; top: 0; left: 0; width: 390px; height: 60px; background: #fff">
|
|
40
|
+
<p style="margin: 4px">Modal title over page text</p>
|
|
41
|
+
</div>
|
|
42
|
+
</body>
|
|
43
|
+
</html>
|
|
44
|
+
HTML
|
|
45
|
+
)
|
|
46
|
+
url="data:text/html;base64,$(printf '%s' "$fixture" | base64 | tr -d '\n')"
|
|
47
|
+
playwright-cli -s=layout-check open "$url" >/dev/null 2>&1
|
|
48
|
+
playwright-cli -s=layout-check resize 390 844 >/dev/null
|
|
49
|
+
out=$(playwright-cli -s=layout-check eval "$(cat "$dir/layout-check.js")")
|
|
50
|
+
playwright-cli -s=layout-check close >/dev/null
|
|
51
|
+
for expected in PAGE-SCROLLS-X 'CLIPPED.*clip-box' 'TRUNCATED.*ellipsis' 'OFFSCREEN.*wide' 'OVERLAP.*overlap-card.*with.*overlap-card'; do
|
|
52
|
+
echo "$out" | grep -q "$expected" || { echo "FAIL: missing $expected"; echo "$out"; exit 1; }
|
|
53
|
+
done
|
|
54
|
+
# Findings never carry page text: a real screen can show player data.
|
|
55
|
+
! echo "$out" | grep -qE 'Balance|USDT|Line two|game title|too wide' || { echo "FAIL: page text in findings"; echo "$out"; exit 1; }
|
|
56
|
+
count=$(echo "$out" | grep -cE '^ "(PAGE|CLIPPED|TRUNCATED|OFFSCREEN|OVERLAP)')
|
|
57
|
+
[ "$count" -eq 5 ] || { echo "FAIL: $count findings, expected 5"; echo "$out"; exit 1; }
|
|
58
|
+
echo PASS
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// Layout defects a screenshot read can miss: overlapping text, text cut by an
|
|
2
|
+
// `overflow: hidden|clip` box, text sticking out of the viewport sideways.
|
|
3
|
+
// Run: playwright-cli -s=v eval "$(cat .claude/skills/ui-verify/layout-check.js)"
|
|
4
|
+
// Returns one line per finding; [] is clean. Findings are leads for the screenshot
|
|
5
|
+
// review, not verdicts: a carousel peek or a deliberate ellipsis can show up here.
|
|
6
|
+
// A finding names a selector and a position, never the text: a screen can show player data.
|
|
7
|
+
// playwright-cli eval takes a bare function expression.
|
|
8
|
+
// oxlint-disable-next-line no-unused-expressions
|
|
9
|
+
() => {
|
|
10
|
+
const EDGE_PX = 1;
|
|
11
|
+
const OVERLAP_PX = 2;
|
|
12
|
+
const MAX_TEXTS = 800;
|
|
13
|
+
const MAX_FINDINGS = 40;
|
|
14
|
+
|
|
15
|
+
const describe = (el) => {
|
|
16
|
+
const tagged = el.closest('[data-testid]');
|
|
17
|
+
const testId = tagged ? `[data-testid="${tagged.getAttribute('data-testid')}"] ` : '';
|
|
18
|
+
const classes = [...el.classList]
|
|
19
|
+
.slice(0, 2)
|
|
20
|
+
.map((name) => `.${name}`)
|
|
21
|
+
.join('');
|
|
22
|
+
return `${testId}${el.tagName.toLowerCase()}${classes}`;
|
|
23
|
+
};
|
|
24
|
+
const clips = (style) => /hidden|clip/.test(style.overflowX + style.overflowY);
|
|
25
|
+
const scrolls = (style) => /auto|scroll/.test(style.overflowX + style.overflowY);
|
|
26
|
+
const overflowBy = (inner, outer) =>
|
|
27
|
+
[
|
|
28
|
+
['top', outer.top - inner.top],
|
|
29
|
+
['right', inner.right - outer.right],
|
|
30
|
+
['bottom', inner.bottom - outer.bottom],
|
|
31
|
+
['left', outer.left - inner.left],
|
|
32
|
+
].filter(([, px]) => px > EDGE_PX);
|
|
33
|
+
const intersects = (a, b) =>
|
|
34
|
+
a.left < b.right && b.left < a.right && a.top < b.bottom && b.top < a.bottom;
|
|
35
|
+
const isOpaque = (el) => {
|
|
36
|
+
const style = getComputedStyle(el);
|
|
37
|
+
const alpha = style.backgroundColor.match(/[\d.]+/g)?.[3] ?? '1';
|
|
38
|
+
return (
|
|
39
|
+
style.backgroundImage !== 'none' ||
|
|
40
|
+
style.backdropFilter !== 'none' ||
|
|
41
|
+
(style.backgroundColor !== 'transparent' && Number(alpha) >= 0.5)
|
|
42
|
+
);
|
|
43
|
+
};
|
|
44
|
+
// A modal or sheet over the page puts an opaque layer between the two texts in the paint stack.
|
|
45
|
+
const isLayerBetween = (a, b, x, y) => {
|
|
46
|
+
const stack = document.elementsFromPoint(x, y);
|
|
47
|
+
const [upper, lower] = [stack.indexOf(a), stack.indexOf(b)].sort((p, q) => p - q);
|
|
48
|
+
if (upper < 0) {return false;}
|
|
49
|
+
const below = stack[lower];
|
|
50
|
+
return stack.slice(upper + 1, lower).some((el) => !el.contains(below) && isOpaque(el));
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const findings = [];
|
|
54
|
+
const at = (r) =>
|
|
55
|
+
`at ${Math.round(r.left)},${Math.round(r.top)} ${Math.round(r.width)}x${Math.round(r.height)}`;
|
|
56
|
+
const report = (kind, el, box, detail) =>
|
|
57
|
+
findings.push(`${kind} - ${describe(el)} ${at(box)} - ${detail}`);
|
|
58
|
+
|
|
59
|
+
const texts = [];
|
|
60
|
+
const range = document.createRange();
|
|
61
|
+
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
|
|
62
|
+
for (let node = walker.nextNode(); node && texts.length < MAX_TEXTS; node = walker.nextNode()) {
|
|
63
|
+
const el = node.parentElement;
|
|
64
|
+
if (!node.textContent.trim() || !el || el.closest('script,style,noscript,template')) {continue;}
|
|
65
|
+
if (!el.checkVisibility({ opacityProperty: true, visibilityProperty: true })) {continue;}
|
|
66
|
+
range.selectNodeContents(node);
|
|
67
|
+
const lines = [...range.getClientRects()].filter((r) => r.width >= 1 && r.height >= 1);
|
|
68
|
+
if (!lines.length) {continue;}
|
|
69
|
+
const box = range.getBoundingClientRect();
|
|
70
|
+
|
|
71
|
+
let hiddenByDesign = false;
|
|
72
|
+
let clippedBy = null;
|
|
73
|
+
let insideScroller = false;
|
|
74
|
+
for (
|
|
75
|
+
let a = el, child = null;
|
|
76
|
+
a && a !== document.body && !insideScroller;
|
|
77
|
+
child = a, a = a.parentElement
|
|
78
|
+
) {
|
|
79
|
+
const style = getComputedStyle(a);
|
|
80
|
+
const outer = a.getBoundingClientRect();
|
|
81
|
+
if (!clips(style)) {
|
|
82
|
+
insideScroller = scrolls(style);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
// sr-only boxes and slides parked fully outside their track are meant to be unseen;
|
|
86
|
+
// a block only partly inside its clipper has lines cut off.
|
|
87
|
+
const shown = child ? child.getBoundingClientRect() : box;
|
|
88
|
+
if (outer.width <= 1 || outer.height <= 1 || !intersects(shown, outer)) {
|
|
89
|
+
hiddenByDesign = true;
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
// Glyphs taller than a tight line-height poke past the box without any layout overflow.
|
|
93
|
+
const overflows =
|
|
94
|
+
a.scrollWidth > a.clientWidth + EDGE_PX || a.scrollHeight > a.clientHeight + EDGE_PX;
|
|
95
|
+
if (!clippedBy && overflows && overflowBy(box, outer).length) {clippedBy = { a, style, outer };}
|
|
96
|
+
}
|
|
97
|
+
if (hiddenByDesign) {continue;}
|
|
98
|
+
|
|
99
|
+
if (clippedBy) {
|
|
100
|
+
const edges = overflowBy(box, clippedBy.outer).map(
|
|
101
|
+
([edge, px]) => `${edge} ${Math.round(px)}px`,
|
|
102
|
+
);
|
|
103
|
+
const isEllipsis =
|
|
104
|
+
clippedBy.style.textOverflow === 'ellipsis' || clippedBy.style.webkitLineClamp !== 'none';
|
|
105
|
+
const kind = isEllipsis ? 'TRUNCATED' : 'CLIPPED';
|
|
106
|
+
report(kind, el, box, `cut by ${describe(clippedBy.a)} at ${edges.join(', ')}`);
|
|
107
|
+
}
|
|
108
|
+
if (
|
|
109
|
+
!clippedBy &&
|
|
110
|
+
!insideScroller &&
|
|
111
|
+
(box.right > innerWidth + EDGE_PX || box.left < -EDGE_PX)
|
|
112
|
+
) {
|
|
113
|
+
report(
|
|
114
|
+
'OFFSCREEN',
|
|
115
|
+
el,
|
|
116
|
+
box,
|
|
117
|
+
`x ${Math.round(box.left)}..${Math.round(box.right)}, viewport ${innerWidth}`,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
// Only the part left visible can overlap anything.
|
|
121
|
+
const shownLines = clippedBy
|
|
122
|
+
? lines
|
|
123
|
+
.map((r) => ({
|
|
124
|
+
left: Math.max(r.left, clippedBy.outer.left),
|
|
125
|
+
top: Math.max(r.top, clippedBy.outer.top),
|
|
126
|
+
right: Math.min(r.right, clippedBy.outer.right),
|
|
127
|
+
bottom: Math.min(r.bottom, clippedBy.outer.bottom),
|
|
128
|
+
}))
|
|
129
|
+
.filter((r) => r.right > r.left && r.bottom > r.top)
|
|
130
|
+
: lines;
|
|
131
|
+
texts.push({ el, box, lines: shownLines });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const overlapOf = (ra, rb) => {
|
|
135
|
+
const left = Math.max(ra.left, rb.left);
|
|
136
|
+
const top = Math.max(ra.top, rb.top);
|
|
137
|
+
const width = Math.min(ra.right, rb.right) - left;
|
|
138
|
+
const height = Math.min(ra.bottom, rb.bottom) - top;
|
|
139
|
+
return width > OVERLAP_PX && height > OVERLAP_PX
|
|
140
|
+
? { x: left + width / 2, y: top + height / 2 }
|
|
141
|
+
: null;
|
|
142
|
+
};
|
|
143
|
+
// ponytail: O(n^2) over line boxes, fine under MAX_TEXTS; bucket by row if pages outgrow it.
|
|
144
|
+
for (let i = 0; i < texts.length; i++) {
|
|
145
|
+
for (let j = i + 1; j < texts.length; j++) {
|
|
146
|
+
const a = texts[i];
|
|
147
|
+
const b = texts[j];
|
|
148
|
+
const point = a.lines.flatMap((ra) => b.lines.map((rb) => overlapOf(ra, rb))).find(Boolean);
|
|
149
|
+
if (point && !isLayerBetween(a.el, b.el, point.x, point.y)) {
|
|
150
|
+
report('OVERLAP', a.el, a.box, `with ${describe(b.el)} ${at(b.box)}`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const page = document.documentElement;
|
|
156
|
+
if (page.scrollWidth > page.clientWidth + EDGE_PX) {
|
|
157
|
+
findings.unshift(
|
|
158
|
+
`PAGE-SCROLLS-X - html - scrollWidth ${page.scrollWidth} > ${page.clientWidth}`,
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
const extra = findings.length - MAX_FINDINGS;
|
|
162
|
+
return extra > 0 ? [...findings.slice(0, MAX_FINDINGS), `+${extra} more`] : findings;
|
|
163
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
---
|
|
2
|
+
targets:
|
|
3
|
+
- '*'
|
|
4
|
+
name: builder
|
|
5
|
+
description: >-
|
|
6
|
+
Senior fullstack engineer for a downstream igaming built on @openora/*. Configures
|
|
7
|
+
extensions.config.ts, authors overlay plugins, swaps vendor adapters (KYC,
|
|
8
|
+
PSP, notifications), and builds UI in apps/web, apps/backoffice, and packages/ui.
|
|
9
|
+
Use this agent to build or extend features in a consumer igaming repo that wraps
|
|
10
|
+
the OSS platform.
|
|
11
|
+
claudecode:
|
|
12
|
+
model: sonnet
|
|
13
|
+
effort: high
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
You are a senior fullstack engineer building a downstream igaming on top of the OSS platform (`@openora/*`). You never modify OSS core - you extend it from the outside via the plugin system. The repo rule files (conventions, oss-boundaries, db-conventions) apply to everything you write.
|
|
17
|
+
|
|
18
|
+
## Ground first
|
|
19
|
+
|
|
20
|
+
1. `catalog-overview` (oss MCP) - what the platform already ships; don't rebuild it.
|
|
21
|
+
2. `list-adapters` - which vendor seams exist to override (KYC, PSP, notifications, ...).
|
|
22
|
+
3. Read `apps/api/src/extensions.config.ts` - everything active is registered there.
|
|
23
|
+
4. Read `AGENTS.md` at the repo root - it is the canonical brief for this consumer repo.
|
|
24
|
+
5. Library API in doubt (Next, React, Drizzle, Zod, `@openora/*`)? Check current docs via context7/web search - don't code from memory.
|
|
25
|
+
|
|
26
|
+
## Local platform checkout
|
|
27
|
+
|
|
28
|
+
`@openora/*` resolves to the published package by default. When the work depends on a core
|
|
29
|
+
change that is not released yet, or when you are iterating against core, link the local
|
|
30
|
+
checkout instead of waiting for a canary: `pnpm link:oss` (points at `{{ossDir}}`),
|
|
31
|
+
`pnpm unlink:oss` to go back.
|
|
32
|
+
|
|
33
|
+
Prefer linking whenever a core-side fix and a consumer-side fix are likely to bounce off each
|
|
34
|
+
other. Waiting on a publish per round trip is the slow path.
|
|
35
|
+
|
|
36
|
+
While linked:
|
|
37
|
+
|
|
38
|
+
- The lockfile is hidden, so do NOT add, remove or change a dependency and do NOT run
|
|
39
|
+
`pnpm install`. Run `pnpm unlink:oss` first if you must.
|
|
40
|
+
- Core source is not what you import - `dist` is. A core edit is invisible here until core is
|
|
41
|
+
rebuilt: `pnpm -F @openora/core build` in the checkout. For hot reload, run
|
|
42
|
+
`pnpm -F @openora/core watch` there once and leave it running.
|
|
43
|
+
- Linking lets you CONSUME an unreleased core. To change core, link an OSS worktree instead
|
|
44
|
+
of the main checkout: `pnpm oss:worktree <branch> --link` (see Rules below).
|
|
45
|
+
|
|
46
|
+
## Consumer repo structure
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
{{name}}/
|
|
50
|
+
apps/
|
|
51
|
+
api/ # thin wrapper: createApp from '@openora/core/server'
|
|
52
|
+
src/extensions.config.ts # registers all plugins (OSS defaults + overrides)
|
|
53
|
+
src/extensions/ # overlay plugins
|
|
54
|
+
my-kyc/plugin.ts # swaps KYC_ADAPTER
|
|
55
|
+
my-psp/plugin.ts # swaps PSP_ADAPTER
|
|
56
|
+
web/ # player app, consumes the API via @openora/core/react
|
|
57
|
+
backoffice/ # admin app
|
|
58
|
+
packages/ui/ # {{scope}}/ui shared components
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Swap a vendor adapter
|
|
62
|
+
|
|
63
|
+
1. `list-adapters` for the token and interface.
|
|
64
|
+
2. Create `apps/api/src/extensions/<vendor>/plugin.ts`:
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
import type { CoreTokenCatalog, Plugin } from '@openora/core/server';
|
|
68
|
+
import { KYC_ADAPTER } from '@openora/core/contracts';
|
|
69
|
+
import { MyKycAdapter } from './src/my-kyc-adapter.js';
|
|
70
|
+
|
|
71
|
+
export default {
|
|
72
|
+
id: 'my-kyc',
|
|
73
|
+
dependsOn: ['identity'], // always load after the default-binding module
|
|
74
|
+
register(ctx) {
|
|
75
|
+
ctx.provide(KYC_ADAPTER, () => new MyKycAdapter());
|
|
76
|
+
},
|
|
77
|
+
} as const satisfies Plugin<CoreTokenCatalog>;
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
3. Register it in `extensions.config.ts` AFTER the module that owns the default binding - last registration of a DI token wins.
|
|
81
|
+
|
|
82
|
+
## Add a feature route
|
|
83
|
+
|
|
84
|
+
1. `list-routes` + `describe-module` to confirm it doesn't already exist.
|
|
85
|
+
2. In a plugin: `ctx.routers.add('my-feature', myFeatureRouter)`.
|
|
86
|
+
3. Zod schemas live in the plugin folder - never in `@openora/core/contracts`.
|
|
87
|
+
|
|
88
|
+
## UI work
|
|
89
|
+
|
|
90
|
+
The platform is headless - the frontends are this operator's own, consuming the API over HTTP via `@openora/core/react` (data hooks, auth, realtime transport, typed client). Follow the `frontend-conventions` rule (and `docs/standards/frontend.md` for the deep dive): feature modules under `src/modules/<m>/`, presentation-only components, logic in hooks, daisyUI + theme tokens, co-located `locales/`. Extend shared pages through the app's plugin layer (nav items, dashboard tiles, table columns) instead of forking them.
|
|
91
|
+
|
|
92
|
+
## Escalate
|
|
93
|
+
|
|
94
|
+
You start no subagents of your own. What you cannot settle yourself goes in your reply, and the orchestrator routes it:
|
|
95
|
+
|
|
96
|
+
- Domain question (wagering calc, KYC threshold) -> a "Questions for expert" list; build what the answer does not change.
|
|
97
|
+
- Bug or gap in OSS core -> fix it in an OSS worktree per "Changing OSS core" in the `oss-boundaries` rule; never copy core source into this repo.
|
|
98
|
+
- Can't finish at all -> reply `Blocked:` on the first line and the reason.
|
|
99
|
+
|
|
100
|
+
## Tests
|
|
101
|
+
|
|
102
|
+
Your change ships with its tests in the same branch - never a separate test PR. You write the unit and integration tests for the plan's test cases at the tier `docs/standards/testing.md` names: pure logic gets a co-located `__tests__/` test, a route its API E2E (`apps/e2e/tests/api/<domain>/<scenario>.spec.ts`, happy + one hostile path). Never an in-process test that mocks the database or a sibling service. `tester` drives the running stack, attacks the change and adds the browser specs; you do not run a manual pass or record evidence.
|
|
103
|
+
|
|
104
|
+
## Done means
|
|
105
|
+
|
|
106
|
+
1. The repo's gate (`/check`: typecheck + lint + unit) passes on HEAD, and the specs you touched pass (`pnpm -F {{scope}}/e2e test <spec>`). A spec that fails: run it on the base commit too - failing there as well is pre-existing, say so; failing only here is yours to fix.
|
|
107
|
+
2. Commit with the repo's commit style, staging only the files the work needs: no test output, videos, screenshots, logs, scratch scripts, unrelated formatting. `git status` is clean before you reply.
|
|
108
|
+
3. Reply with what changed per plan item, the tests you added (one line each, tier and path), the gate command and the tail of its output from HEAD, then `Worktree: <absolute path>` and `Branch: <name>`. Never write "passed" without that output.
|
|
109
|
+
|
|
110
|
+
## Rules
|
|
111
|
+
|
|
112
|
+
- Never write to `node_modules/**` or the main linked `{{ossDir}}` checkout - the `guard-core` hook denies it; don't route around it with `sed` or shell redirection. A local patch to a published dependency is lost on reinstall and diverges from every other operator. If a change is only possible in core, make it in an OSS worktree (`pnpm oss:worktree <branch>`) under the OSS repo's own rules, per the `oss-boundaries` rule.
|
|
113
|
+
- Never copy core module source into this repo - depend on the package.
|
|
114
|
+
- `extensions.config.ts` is the single registry - no auto-discovery.
|
|
115
|
+
- Commit on the ticket branch; never push - the orchestrator does.
|