@erclx/aitk 3.11.1 → 3.12.0
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/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/claude-pr-review/REQUIREMENT.md +3 -0
- package/claude/skills/claude-pr-review/SKILL.md +21 -3
- package/docs/agents/demo.md +6 -2
- package/package.json +1 -1
- package/src/commands/demo.ts +25 -0
- package/src/demo/compile.ts +35 -4
- package/src/demo/container.ts +63 -0
- package/src/demo/drive.ts +110 -7
- package/standards/pr.md +3 -3
|
@@ -11,6 +11,8 @@ Without this skill, a pull request is reviewed only by the session that wrote it
|
|
|
11
11
|
|
|
12
12
|
A finding also stops being true by argument rather than by a fix. A worker naming the plan question that already declined it, or a constraint the pass could not see, settles it in the exchange that carried the answer, and the pass that accepts the argument drops the finding from its next body. A reader then sees a finding raised once and never mentioned again, which is indistinguishable from one everyone forgot.
|
|
13
13
|
|
|
14
|
+
A request written under `## For the reviewer` also reached no reader. The authoring standard names it as what the reviewing session should confirm, and nothing on this side read the section by that name, so a branch author's question sat in the body until this skill answered it.
|
|
15
|
+
|
|
14
16
|
## Must
|
|
15
17
|
|
|
16
18
|
- Post until the review closes. A first pass opens against the whole change, and each later pass checks whether the prior findings landed.
|
|
@@ -22,6 +24,7 @@ A finding also stops being true by argument rather than by a fix. A worker namin
|
|
|
22
24
|
- State a withdrawal or a regrade on the thread with the fact that settled it, since a finding dropped in silence reads the same as one nobody answered
|
|
23
25
|
- Key the body file on the pull request number and the head commit, and on the response it answers once the head repeats, so no two passes overwrite each other
|
|
24
26
|
- Scan the comment for banned characters and internal phase labels before posting, since a finding phrased against a phase label reaches a reader with no task board
|
|
27
|
+
- Answer a `## For the reviewer` bullet in the body, bounded to the section itself rather than the Summary or the Technical Context around it, so the independent vantage stays clear of the author's framing
|
|
25
28
|
|
|
26
29
|
## Must not
|
|
27
30
|
|
|
@@ -15,7 +15,7 @@ review against the whole change, and every later pass reads only the commits
|
|
|
15
15
|
added since. The heading reports state rather than pass number: a pass carrying
|
|
16
16
|
anything owed takes `## Review`, and `## Review closed` covers a pass carrying
|
|
17
17
|
none, so the most recent comment's heading reports whether any work is owed.
|
|
18
|
-
Owed is a finding at any severity
|
|
18
|
+
Owed is a finding at any severity, a Testing question, or a reviewer request nobody has answered, defined once at Step 4. Every pass is this skill, and which one it is gets detected from
|
|
19
19
|
the thread rather than named by the caller.
|
|
20
20
|
|
|
21
21
|
## Guards
|
|
@@ -89,6 +89,10 @@ Test every unchecked box against the testing discipline in `${CLAUDE_SKILL_DIR}/
|
|
|
89
89
|
|
|
90
90
|
Ask rather than grade. Whether a human is genuinely required is a reading the branch author may hold a reason this session cannot see, so the question carries no severity and enters no count. What it does carry is the heading and the dispatch, on the rule Step 4 states, because a question the author never receives corrects nothing and the author is the only party who can answer it. Answering it closes it, and the answer may be that the requirement holds.
|
|
91
91
|
|
|
92
|
+
Read `## For the reviewer` the same way, bounded to the bullets under that heading rather than the Summary or the Technical Context around it. Those two carry the author's argument for the change, and reading them while judging it is most of what an independent pass exists to avoid, so the read stops at the section itself.
|
|
93
|
+
|
|
94
|
+
Answer each bullet in the body, on the same terms as a Testing question: no severity, no count. A request nobody can answer stays owed under the heading and dispatch Step 4 states, keeping the thread open exactly as an unanswered Testing box does. One the pass does answer carries no further weight, since the answer is discharged in the same comment that carries it.
|
|
95
|
+
|
|
92
96
|
Apply the high-signal filter: flag only what will cause incorrect behavior, break a documented rule, or mislead a downstream feature. If uncertain, do not flag.
|
|
93
97
|
|
|
94
98
|
A later pass applies the same axes to the delta, and adds one check the first pass cannot make: did each prior finding land, and did the fix regress anything it touched. Findings of its own are normal findings, stated at the same severity and counted the same way. That count is one of the two things Step 4 reads to pick the heading, so a pass raising a finding of its own is not a close-out at any severity.
|
|
@@ -160,7 +164,17 @@ A Testing box the Step 3 check raised goes in a `**Testing**` block placed after
|
|
|
160
164
|
|
|
161
165
|
Keep it to the boxes the check raised. Restating a box whose stated requirement holds teaches the branch author to skip the block.
|
|
162
166
|
|
|
163
|
-
|
|
167
|
+
Every `## For the reviewer` bullet Step 3 read goes in a `**For the reviewer**` block placed after the Testing block, one bullet per request, each followed by its answer or, where the pass could not answer it, by what would settle it. It carries no severity and enters no count.
|
|
168
|
+
|
|
169
|
+
An unanswered bullet is owed the same way an unanswered Testing box is, so a pass carrying one takes `## Review` and the full body rather than either ✅ line. A bullet the pass answered is not owed, since the answer is discharged in the same comment that carries it. A pass still posting a numeric summary line, because a finding, a Testing question, or an unanswered bullet already forces one, says so there as `plus N reviewer request(s)`. The all-answered close-out below carries the block in place of that line and needs no addition to it.
|
|
170
|
+
|
|
171
|
+
```markdown
|
|
172
|
+
**For the reviewer**
|
|
173
|
+
|
|
174
|
+
- Confirm the 401 and 403 split reads correctly for the public API. Confirmed — `AuthService.authenticate()` returns 401 for an expired token and 403 for a missing scope, and both paths are covered under `## Testing`.
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
The threshold is stated here and nowhere else, and every other surface acting on it cites this skill rather than restating the grades. One rule governs both the heading and the dispatch: a pass carrying anything owed takes `## Review` and owes a dispatch to the session holding the branch, and a pass carrying nothing at all takes `## Review closed` and owes none. Owed covers a finding at any severity, a Testing question, and a reviewer request nobody has answered alike, which is what keeps the two halves from separating. Sending that dispatch is `claude-orchestrate`'s step rather than this one, which posts and stops. Post the open heading whether it is the first pass or the fourth. A pull request thread then reads as `## Review`, the worker's answer under `## Review response` from `claude-address-review`, another `## Review` while anything stays open, and `## Review closed` when nothing does.
|
|
164
178
|
|
|
165
179
|
Keying either half on the grade was measured wrong: across 8 findings on one archived pass, 3 were posted as minor and 2 of those were defects a worker fixed rather than recorded, so a floor at should-fix loses real fixes to a grade that runs low. Splitting the two halves so the dispatch fired lower than the heading was the other candidate, and it left a thread reading closed while work was owed on it. The Testing question was first written to sit outside both, which is that same split reached from the other side, and it left the one party who could answer the question with no route to it.
|
|
166
180
|
|
|
@@ -192,7 +206,7 @@ gh pr review <number> --comment --body-file .claude/.tmp/pr-review/body-<number>
|
|
|
192
206
|
|
|
193
207
|
A pass carrying nothing at all takes `## Review closed` and a short body, with the footer line included either way. On a first pass, post `✅ No findings. Reviewed against project docs and the board.` On a later pass, post `✅ Prior findings addressed. Re-reviewed <short-sha>, N commits since the prior pass.`
|
|
194
208
|
|
|
195
|
-
A pass carrying only minors is an ordinary finding-carrying pass, so it takes the open heading and the full shape rather than either short line, since the minors have to be readable and neither line reports them. A pass carrying only Testing questions takes the same route for the same reason. Keep whichever scope sentence the pass owes on the summary line:
|
|
209
|
+
A pass carrying only minors is an ordinary finding-carrying pass, so it takes the open heading and the full shape rather than either short line, since the minors have to be readable and neither line reports them. A pass carrying only Testing questions, or only an unanswered reviewer request, takes the same route for the same reason. Keep whichever scope sentence the pass owes on the summary line:
|
|
196
210
|
|
|
197
211
|
```markdown
|
|
198
212
|
## Review
|
|
@@ -208,6 +222,8 @@ A pass carrying only minors is an ordinary finding-carrying pass, so it takes th
|
|
|
208
222
|
|
|
209
223
|
A pass that closed by withdrawing a finding rather than by reading its fix takes neither ✅ line, per the withdrawal rule in Step 3. Both claim a fix landed, and the second names it, so posting either over a withdrawal credits work nobody did on the one comment a reader treats as the verdict. Write the withdrawal and the fact that settled it in place of the canned line, keeping the heading and the footer.
|
|
210
224
|
|
|
225
|
+
A pass whose only content is a `## For the reviewer` block with every bullet answered, and that owes nothing else, takes the same shape: `## Review closed`, the block in place of the canned line, and the footer. The heading reports what the branch author still owes rather than what the pass did, and an answer discharged in the same comment owes nothing back.
|
|
226
|
+
|
|
211
227
|
Post a close-out even when there is nothing to report. A review left with no closing comment reads as one nobody answered.
|
|
212
228
|
|
|
213
229
|
## Step 5: output
|
|
@@ -218,4 +234,6 @@ X critical, Y should-fix, Z minor. Posted to PR #<number>.
|
|
|
218
234
|
|
|
219
235
|
Add `N Testing question(s) raised.` to that line when the Step 3 check raised any. The counts cover findings alone, so a pass whose only output was a question otherwise reports as silent to the session that drove it. That session is not who the question is addressed to, which is what the dispatch covers.
|
|
220
236
|
|
|
237
|
+
Add `N reviewer request(s) answered.` to that line when the PR body carried a `## For the reviewer` section. The counts and the Testing question line both cover something else, so a pass that only answered a request otherwise reports as silent too.
|
|
238
|
+
|
|
221
239
|
Report the merge decision as a plain recommendation in chat (merge, or address findings first). Do not merge.
|
package/docs/agents/demo.md
CHANGED
|
@@ -34,6 +34,10 @@ The plan is committed, not scratch. Its timing is a starting point tuned by watc
|
|
|
34
34
|
|
|
35
35
|
`aitk demo run` reads the plan, refuses if a field is still empty, and drives the application the plan's URL names. It records the whole run to `webm` and writes the still from the beat the draft calls the hero, falling back to the last beat, since a demo's final state is the payoff and a cold open is usually an empty screen.
|
|
36
36
|
|
|
37
|
+
A step's caption from the draft renders as an overlay while its hold plays, so the narration a person wrote is what shows on screen rather than the name of the action the engine performed.
|
|
38
|
+
|
|
39
|
+
When `ffmpeg` is on PATH, the run also writes an mp4 beside the webm, since webm plays in a `<video>` tag but nothing else accepts it. A target without `ffmpeg` still gets the webm and a line naming what to install, and the run does not fail over the missing converter.
|
|
40
|
+
|
|
37
41
|
| Option | Behavior |
|
|
38
42
|
| ---------------- | ----------------------------------------------------------- |
|
|
39
43
|
| `--out <dir>` | Directory to write into, overriding what the plan names |
|
|
@@ -48,7 +52,7 @@ A step waits on its `waitFor` selector becoming visible and then holds for its o
|
|
|
48
52
|
|
|
49
53
|
The browser engine's own annotation draws a red dot at the moment of a click and an action label in a corner. It paints no cursor, so a run without more looks like the pointer teleports between targets.
|
|
50
54
|
|
|
51
|
-
The recorder injects a pointer element before navigation and moves it through the engine's pointer with interpolated steps rather than through the element-clicking helper, which resolves a target and jumps to it. The step count is the whole difference between a cursor that travels and one that appears.
|
|
55
|
+
The recorder injects a pointer element before navigation and moves it through the engine's pointer with interpolated steps rather than through the element-clicking helper, which resolves a target and jumps to it. The step count is the whole difference between a cursor that travels and one that appears, and how many steps a move takes is derived from the machine's own round-trip cost rather than fixed, so the same plan glides at roughly the same pace on a loaded machine as an idle one. It also reads the element under it on every move and switches between an arrow, a hand, and a text beam, so it reflects the page the way a real cursor does.
|
|
52
56
|
|
|
53
57
|
`--cursor` points at a folder of Windows cursor resources and the browser decodes them directly, with no conversion step and no image tooling. Each resource carries a hotspot per size, and the largest entry's hotspot scaled to the drawn size is what puts the artwork's tip where the click lands. A theme contributes per state, so a folder holding an arrow and no hand still supplies its arrow and the bundled artwork covers the rest.
|
|
54
58
|
|
|
@@ -77,6 +81,6 @@ The cost is stated rather than hidden: the browser binary installs separately, s
|
|
|
77
81
|
|
|
78
82
|
## What it does not do
|
|
79
83
|
|
|
80
|
-
A generated recording is a raw take. Nothing trims it
|
|
84
|
+
A generated recording is a raw take. Nothing trims it or scores it beyond the caption each beat already carries.
|
|
81
85
|
|
|
82
86
|
It also does not replace a narrated screencast. That has a hero moment, a cut list, and a voice, none of which survives being generated. This answers the case where the alternative is recording nothing.
|
package/package.json
CHANGED
package/src/commands/demo.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { basename, dirname, extname, join, relative, resolve } from 'node:path'
|
|
|
3
3
|
import type { Command } from 'commander'
|
|
4
4
|
import { parseDraft } from '@/demo/beats'
|
|
5
5
|
import { compilePlan, parsePlan, unresolved } from '@/demo/compile'
|
|
6
|
+
import { convertToMp4, INSTALL_CONVERTER } from '@/demo/container'
|
|
6
7
|
import { DEFAULT_CURSORS } from '@/demo/cursors'
|
|
7
8
|
import { loadCursorTheme } from '@/demo/theme'
|
|
8
9
|
import { intro, logError, logInfo, logStep, logWarn, outro, plural } from '@/ui'
|
|
@@ -89,6 +90,9 @@ export function register(program: Command): void {
|
|
|
89
90
|
'Needs a browser binary. Install it with:',
|
|
90
91
|
` ${INSTALL_BROWSER}`,
|
|
91
92
|
'',
|
|
93
|
+
'Writes mp4 beside the webm when ffmpeg is on PATH, and skips it',
|
|
94
|
+
`otherwise without failing the run. Install it with: ${INSTALL_CONVERTER}`,
|
|
95
|
+
'',
|
|
92
96
|
'Exit codes:',
|
|
93
97
|
' 0 the recording and the still were written',
|
|
94
98
|
' 1 refused, with the reason on stderr',
|
|
@@ -283,14 +287,35 @@ async function runDrive(planPath: string, opts: RunOptions): Promise<number> {
|
|
|
283
287
|
logInfo(
|
|
284
288
|
`${result.steps} steps in ${Math.round(result.durationMs / 100) / 10}s`,
|
|
285
289
|
)
|
|
290
|
+
|
|
291
|
+
let mp4Path: string | undefined
|
|
292
|
+
let mp4Reason: string | undefined
|
|
293
|
+
if (result.videoPath) {
|
|
294
|
+
logStep('Container')
|
|
295
|
+
const converted = await convertToMp4(result.videoPath)
|
|
296
|
+
if (converted.status === 'converted') {
|
|
297
|
+
mp4Path = converted.mp4Path
|
|
298
|
+
logInfo(display(mp4Path))
|
|
299
|
+
} else if (converted.status === 'skipped') {
|
|
300
|
+
mp4Reason = converted.reason
|
|
301
|
+
logWarn('ffmpeg is not installed, so no mp4 was written.')
|
|
302
|
+
logWarn(`Install it with: ${INSTALL_CONVERTER}`)
|
|
303
|
+
} else {
|
|
304
|
+
mp4Reason = converted.reason
|
|
305
|
+
logWarn(`mp4 conversion failed: ${converted.reason}`)
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
286
309
|
outro()
|
|
287
310
|
|
|
288
311
|
emit(opts.json, {
|
|
289
312
|
plan: source,
|
|
290
313
|
video: result.videoPath ?? null,
|
|
314
|
+
mp4: mp4Path ?? null,
|
|
291
315
|
still: result.stillPath ?? null,
|
|
292
316
|
steps: result.steps,
|
|
293
317
|
durationMs: result.durationMs,
|
|
318
|
+
...(mp4Reason ? { mp4Reason } : {}),
|
|
294
319
|
})
|
|
295
320
|
return 0
|
|
296
321
|
}
|
package/src/demo/compile.ts
CHANGED
|
@@ -13,11 +13,42 @@ import type { Beat, Draft } from '@/demo/beats'
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
/** Hand-tuned in spike 2 against one fixture. Nothing establishes them in general. */
|
|
16
|
-
const
|
|
16
|
+
const POINTER_TRAVEL_MS = 400
|
|
17
17
|
const TYPE_DELAY_MS = 110
|
|
18
18
|
const HOLD_MS = 600
|
|
19
19
|
const FINAL_HOLD_MS = 1200
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* A step is a round trip to the browser, so a fixed count reproduces the
|
|
23
|
+
* defect under a new name if it does not respond to the round-trip cost
|
|
24
|
+
* measured at drive time. The bounds guard the extremes a bad measurement
|
|
25
|
+
* could produce: too few steps teleports rather than glides, and a
|
|
26
|
+
* near-zero measured cost cannot be trusted enough to let the count run
|
|
27
|
+
* away.
|
|
28
|
+
*
|
|
29
|
+
* Measured on 2026-08-26 against a served fixture, post-navigation: 10
|
|
30
|
+
* rounds of `page.mouse.move` averaged 16.6 milliseconds a step, well short
|
|
31
|
+
* of either bound at the 400 millisecond default (about 24 steps). A
|
|
32
|
+
* machine landing under `POINTER_TRAVEL_MS / MAX_POINTER_STEPS`, 3.33
|
|
33
|
+
* milliseconds a step here, hits the cap and stops responding to a faster
|
|
34
|
+
* one still. Nothing measured here establishes where a real machine sits
|
|
35
|
+
* relative to that boundary in general.
|
|
36
|
+
*/
|
|
37
|
+
export const MIN_POINTER_STEPS = 6
|
|
38
|
+
export const MAX_POINTER_STEPS = 120
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Pure on purpose: the round-trip cost comes from a real browser and can
|
|
42
|
+
* only be measured at drive time, so this takes it as an argument rather
|
|
43
|
+
* than measuring it itself, which is what keeps it testable against a
|
|
44
|
+
* stubbed cost.
|
|
45
|
+
*/
|
|
46
|
+
export function deriveSteps(travelMs: number, roundTripMs: number): number {
|
|
47
|
+
if (roundTripMs <= 0) return MAX_POINTER_STEPS
|
|
48
|
+
const steps = Math.round(travelMs / roundTripMs)
|
|
49
|
+
return Math.min(MAX_POINTER_STEPS, Math.max(MIN_POINTER_STEPS, steps))
|
|
50
|
+
}
|
|
51
|
+
|
|
21
52
|
const VIEWPORT = { width: 1280, height: 720 } as const
|
|
22
53
|
const ANNOTATIONS = {
|
|
23
54
|
durationMs: 900,
|
|
@@ -82,7 +113,7 @@ export interface DemoPlan {
|
|
|
82
113
|
readonly url: string
|
|
83
114
|
readonly viewport: { readonly width: number; readonly height: number }
|
|
84
115
|
readonly output: { readonly video: string; readonly still: string }
|
|
85
|
-
readonly pointer: { readonly
|
|
116
|
+
readonly pointer: { readonly travelMs: number; readonly typeDelayMs: number }
|
|
86
117
|
readonly annotations: typeof ANNOTATIONS
|
|
87
118
|
readonly steps: readonly DemoStep[]
|
|
88
119
|
}
|
|
@@ -103,7 +134,7 @@ export function compilePlan(draft: Draft, options: CompileOptions): DemoPlan {
|
|
|
103
134
|
video: `${options.outDir}/${options.slug}.webm`,
|
|
104
135
|
still: `${options.outDir}/${options.slug}.png`,
|
|
105
136
|
},
|
|
106
|
-
pointer: {
|
|
137
|
+
pointer: { travelMs: POINTER_TRAVEL_MS, typeDelayMs: TYPE_DELAY_MS },
|
|
107
138
|
annotations: ANNOTATIONS,
|
|
108
139
|
steps: draft.beats.map((beat, position) =>
|
|
109
140
|
compileStep(beat, {
|
|
@@ -218,7 +249,7 @@ export function parsePlan(text: string): PlanParse {
|
|
|
218
249
|
still: asText(output.still) || `demos/${slug || 'demo'}.png`,
|
|
219
250
|
},
|
|
220
251
|
pointer: {
|
|
221
|
-
|
|
252
|
+
travelMs: asNumber(pointer.travelMs, POINTER_TRAVEL_MS),
|
|
222
253
|
typeDelayMs: asNumber(pointer.typeDelayMs, TYPE_DELAY_MS),
|
|
223
254
|
},
|
|
224
255
|
annotations: ANNOTATIONS,
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { join, parse } from 'node:path'
|
|
2
|
+
import { execa } from 'execa'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A post-step on the file `@/demo/drive` already wrote. It touches neither
|
|
6
|
+
* the compiler nor the driving code, which is why deferring it out of the
|
|
7
|
+
* feature that shipped first cost no rework.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const CONVERTER_BIN = 'ffmpeg'
|
|
11
|
+
export const INSTALL_CONVERTER = 'ffmpeg (see https://ffmpeg.org/download.html)'
|
|
12
|
+
|
|
13
|
+
export type ContainerResult =
|
|
14
|
+
| { status: 'converted'; mp4Path: string }
|
|
15
|
+
| { status: 'skipped'; reason: 'converter-missing' }
|
|
16
|
+
| { status: 'failed'; reason: string }
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Writes mp4 beside the webm rather than instead of it, since both stated use
|
|
20
|
+
* cases are a `<video>` tag on a page the operator controls, where webm
|
|
21
|
+
* already plays, and the social platform case is what mp4 is for.
|
|
22
|
+
*
|
|
23
|
+
* A missing binary is reported as skipped rather than failed. The recording
|
|
24
|
+
* already succeeded, and failing the run over an optional step would discard
|
|
25
|
+
* a good artifact.
|
|
26
|
+
*/
|
|
27
|
+
export async function convertToMp4(
|
|
28
|
+
webmPath: string,
|
|
29
|
+
bin: string = CONVERTER_BIN,
|
|
30
|
+
): Promise<ContainerResult> {
|
|
31
|
+
const { dir, name } = parse(webmPath)
|
|
32
|
+
const mp4Path = join(dir, `${name}.mp4`)
|
|
33
|
+
|
|
34
|
+
const result = await execa(
|
|
35
|
+
bin,
|
|
36
|
+
[
|
|
37
|
+
'-y',
|
|
38
|
+
'-i',
|
|
39
|
+
webmPath,
|
|
40
|
+
'-c:v',
|
|
41
|
+
'libx264',
|
|
42
|
+
'-pix_fmt',
|
|
43
|
+
'yuv420p',
|
|
44
|
+
'-c:a',
|
|
45
|
+
'aac',
|
|
46
|
+
'-movflags',
|
|
47
|
+
'+faststart',
|
|
48
|
+
mp4Path,
|
|
49
|
+
],
|
|
50
|
+
{ reject: false },
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
if (result.failed && result.code === 'ENOENT') {
|
|
54
|
+
return { status: 'skipped', reason: 'converter-missing' }
|
|
55
|
+
}
|
|
56
|
+
if (result.exitCode !== 0) {
|
|
57
|
+
return {
|
|
58
|
+
status: 'failed',
|
|
59
|
+
reason: result.stderr?.trim() || `ffmpeg exited ${result.exitCode}`,
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return { status: 'converted', mp4Path }
|
|
63
|
+
}
|
package/src/demo/drive.ts
CHANGED
|
@@ -3,10 +3,17 @@ import { tmpdir } from 'node:os'
|
|
|
3
3
|
import { dirname, join } from 'node:path'
|
|
4
4
|
import { chromium } from 'playwright-core'
|
|
5
5
|
import type { Browser, BrowserContext, Page } from 'playwright-core'
|
|
6
|
+
import { deriveSteps } from '@/demo/compile'
|
|
6
7
|
import type { DemoPlan, DemoStep } from '@/demo/compile'
|
|
7
8
|
import type { CursorSet } from '@/demo/pointer'
|
|
8
9
|
import { pointerSource } from '@/demo/pointer'
|
|
9
10
|
|
|
11
|
+
declare global {
|
|
12
|
+
interface Window {
|
|
13
|
+
__aitk_demo_caption__?: (text: string) => void
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
10
17
|
/**
|
|
11
18
|
* Drives a running application and records what it did. Every browser reference
|
|
12
19
|
* the demo feature adds lives here, and `src/commands/demo.ts` reaches it
|
|
@@ -34,6 +41,10 @@ const POINTER_SIZE = 32
|
|
|
34
41
|
*/
|
|
35
42
|
const START = { x: 8, y: 8 }
|
|
36
43
|
const SETTLE_MS = 250
|
|
44
|
+
/** Round trips sampled to price one, on the page a step is actually about to move across. */
|
|
45
|
+
const CALIBRATION_STEPS = 8
|
|
46
|
+
/** DOM id the caption bar installs under, read back by `drive.e2e.test.ts`. */
|
|
47
|
+
export const CAPTION_ID = '__aitk_demo_caption_bar__'
|
|
37
48
|
|
|
38
49
|
/**
|
|
39
50
|
* The two output paths arrive resolved rather than as a root this re-resolves
|
|
@@ -118,8 +129,10 @@ export async function drive(options: DriveOptions): Promise<DriveResult> {
|
|
|
118
129
|
await context.addInitScript({
|
|
119
130
|
content: pointerSource(options.cursors, POINTER_SIZE),
|
|
120
131
|
})
|
|
132
|
+
await context.addInitScript({ content: captionInitScript() })
|
|
121
133
|
const page = await context.newPage()
|
|
122
134
|
const video = page.video()
|
|
135
|
+
const pace: PointerPace = {}
|
|
123
136
|
|
|
124
137
|
// The opening navigate is skipped when the plan already starts with one,
|
|
125
138
|
// because a draft written around an opening verb compiles to a `navigate`
|
|
@@ -130,7 +143,7 @@ export async function drive(options: DriveOptions): Promise<DriveResult> {
|
|
|
130
143
|
}
|
|
131
144
|
|
|
132
145
|
for (const step of plan.steps) {
|
|
133
|
-
await runStep(page, plan, step)
|
|
146
|
+
await runStep(page, plan, step, pace)
|
|
134
147
|
// The first marked step wins. One file holds one frame, so a plan a
|
|
135
148
|
// person edited to mark several would otherwise write each over the last
|
|
136
149
|
// and keep whichever ran last, with nothing saying so.
|
|
@@ -187,10 +200,24 @@ async function launch(): Promise<Launch> {
|
|
|
187
200
|
}
|
|
188
201
|
}
|
|
189
202
|
|
|
190
|
-
|
|
203
|
+
/**
|
|
204
|
+
* Holds the round trip once a step has measured it, so every `moveTo` after
|
|
205
|
+
* the first reuses the same reading rather than re-timing on every move.
|
|
206
|
+
*/
|
|
207
|
+
export interface PointerPace {
|
|
208
|
+
roundTripMs?: number
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Exported so `drive.e2e.test.ts` can drive one real step against a real
|
|
213
|
+
* caption and read it back, which is the integration a full `drive()` call
|
|
214
|
+
* cannot assert without decoding the video it writes.
|
|
215
|
+
*/
|
|
216
|
+
export async function runStep(
|
|
191
217
|
page: Page,
|
|
192
218
|
plan: DemoPlan,
|
|
193
219
|
step: DemoStep,
|
|
220
|
+
pace: PointerPace,
|
|
194
221
|
): Promise<void> {
|
|
195
222
|
switch (step.kind) {
|
|
196
223
|
case 'navigate':
|
|
@@ -198,33 +225,47 @@ async function runStep(
|
|
|
198
225
|
await page.mouse.move(START.x, START.y, { steps: 2 })
|
|
199
226
|
break
|
|
200
227
|
case 'click':
|
|
201
|
-
await moveTo(page, plan, step)
|
|
228
|
+
await moveTo(page, plan, step, pace)
|
|
202
229
|
await page.mouse.down()
|
|
203
230
|
await page.mouse.up()
|
|
204
231
|
break
|
|
205
232
|
case 'fill':
|
|
206
|
-
await moveTo(page, plan, step)
|
|
233
|
+
await moveTo(page, plan, step, pace)
|
|
207
234
|
await page.mouse.down()
|
|
208
235
|
await page.mouse.up()
|
|
209
236
|
await page.keyboard.type(step.text, { delay: plan.pointer.typeDelayMs })
|
|
210
237
|
break
|
|
211
238
|
case 'hover':
|
|
212
|
-
await moveTo(page, plan, step)
|
|
239
|
+
await moveTo(page, plan, step, pace)
|
|
213
240
|
break
|
|
214
241
|
case 'scroll':
|
|
215
242
|
await page.locator(step.target).first().scrollIntoViewIfNeeded()
|
|
216
243
|
await page.waitForTimeout(SETTLE_MS)
|
|
217
|
-
await moveTo(page, plan, step)
|
|
244
|
+
await moveTo(page, plan, step, pace)
|
|
218
245
|
break
|
|
219
246
|
case 'wait':
|
|
220
247
|
case 'hold':
|
|
221
248
|
break
|
|
222
249
|
}
|
|
223
250
|
|
|
251
|
+
// Set after the action rather than before it, so the caption shows for the
|
|
252
|
+
// hold that follows rather than for the page the action is about to leave.
|
|
253
|
+
await setCaption(page, step.caption)
|
|
224
254
|
if (step.waitFor) await page.locator(step.waitFor).first().waitFor()
|
|
225
255
|
await page.waitForTimeout(step.holdMs)
|
|
226
256
|
}
|
|
227
257
|
|
|
258
|
+
/**
|
|
259
|
+
* Timed on the page a `moveTo` is actually about to move across, never on the
|
|
260
|
+
* blank page before it, since layout, paint, and page script are what a step
|
|
261
|
+
* pays the round trip against and a blank page has none of the three.
|
|
262
|
+
*/
|
|
263
|
+
async function calibrateRoundTrip(page: Page): Promise<number> {
|
|
264
|
+
const startedAt = Date.now()
|
|
265
|
+
await page.mouse.move(START.x, START.y, { steps: CALIBRATION_STEPS })
|
|
266
|
+
return (Date.now() - startedAt) / CALIBRATION_STEPS
|
|
267
|
+
}
|
|
268
|
+
|
|
228
269
|
/**
|
|
229
270
|
* Travel is the whole point of driving the engine's pointer rather than calling
|
|
230
271
|
* the element-clicking helper, which resolves a target and jumps to it. The
|
|
@@ -237,16 +278,78 @@ async function moveTo(
|
|
|
237
278
|
page: Page,
|
|
238
279
|
plan: DemoPlan,
|
|
239
280
|
step: DemoStep,
|
|
281
|
+
pace: PointerPace,
|
|
240
282
|
): Promise<void> {
|
|
283
|
+
pace.roundTripMs ??= await calibrateRoundTrip(page)
|
|
241
284
|
const locator = page.locator(step.target).first()
|
|
242
285
|
await locator.waitFor()
|
|
243
286
|
const box = await locator.boundingBox()
|
|
244
287
|
if (!box) throw new Error(`${step.target} has no box to point at`)
|
|
245
288
|
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2, {
|
|
246
|
-
steps: plan.pointer.
|
|
289
|
+
steps: deriveSteps(plan.pointer.travelMs, pace.roundTripMs),
|
|
247
290
|
})
|
|
248
291
|
}
|
|
249
292
|
|
|
293
|
+
/**
|
|
294
|
+
* Playwright's own `showActions` overlay names the API call it made, not the
|
|
295
|
+
* beat's narration, so a caption needs an element of its own rather than
|
|
296
|
+
* reusing that annotation. Runs alongside `pointerSource`, guarded the same
|
|
297
|
+
* way against a page that already carries one.
|
|
298
|
+
*/
|
|
299
|
+
export function captionInitScript(): string {
|
|
300
|
+
return `(() => {
|
|
301
|
+
if (window.__aitk_demo_caption__) return;
|
|
302
|
+
|
|
303
|
+
let label;
|
|
304
|
+
|
|
305
|
+
const install = () => {
|
|
306
|
+
if (label || !document.body) return;
|
|
307
|
+
const bar = document.createElement('div');
|
|
308
|
+
bar.id = '${CAPTION_ID}';
|
|
309
|
+
bar.setAttribute('aria-hidden', 'true');
|
|
310
|
+
bar.style.cssText = [
|
|
311
|
+
'position:fixed',
|
|
312
|
+
'left:0',
|
|
313
|
+
'right:0',
|
|
314
|
+
'bottom:32px',
|
|
315
|
+
'display:flex',
|
|
316
|
+
'justify-content:center',
|
|
317
|
+
'pointer-events:none',
|
|
318
|
+
'z-index:2147483647',
|
|
319
|
+
].join(';');
|
|
320
|
+
label = document.createElement('span');
|
|
321
|
+
label.style.cssText = [
|
|
322
|
+
'background:rgba(16,16,20,0.85)',
|
|
323
|
+
'color:#f4f4f5',
|
|
324
|
+
'font:600 20px/1.4 system-ui,sans-serif',
|
|
325
|
+
'padding:10px 22px',
|
|
326
|
+
'border-radius:8px',
|
|
327
|
+
'max-width:80vw',
|
|
328
|
+
'text-align:center',
|
|
329
|
+
'display:none',
|
|
330
|
+
].join(';');
|
|
331
|
+
bar.appendChild(label);
|
|
332
|
+
document.body.appendChild(bar);
|
|
333
|
+
window.__aitk_demo_caption__ = (text) => {
|
|
334
|
+
label.textContent = text || '';
|
|
335
|
+
label.style.display = text ? 'inline-block' : 'none';
|
|
336
|
+
};
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
if (document.readyState === 'loading') {
|
|
340
|
+
addEventListener('DOMContentLoaded', install, { once: true });
|
|
341
|
+
} else {
|
|
342
|
+
install();
|
|
343
|
+
}
|
|
344
|
+
})();`
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
async function setCaption(page: Page, caption: string): Promise<void> {
|
|
348
|
+
await page.evaluate((text) => {
|
|
349
|
+
window.__aitk_demo_caption__?.(text)
|
|
350
|
+
}, caption)
|
|
351
|
+
}
|
|
352
|
+
|
|
250
353
|
function failed(reason: DriveRefusal, error: unknown): DriveFailure {
|
|
251
354
|
return {
|
|
252
355
|
status: 'failed',
|
package/standards/pr.md
CHANGED
|
@@ -43,7 +43,7 @@ Does not govern:
|
|
|
43
43
|
- `## Testing` (optional): specify exact commands or test cases run
|
|
44
44
|
- Omit Testing for docs, config, or trivial sync changes
|
|
45
45
|
- Use checkboxes, never prose. See Testing discipline for which box gets ticked.
|
|
46
|
-
- `## For the reviewer` (optional): what the
|
|
46
|
+
- `## For the reviewer` (optional): what the reviewing session should confirm, one bullet per request
|
|
47
47
|
- Visuals: include only when they clarify architecture, UI, or complex logic flows
|
|
48
48
|
|
|
49
49
|
## Testing discipline
|
|
@@ -88,7 +88,7 @@ Does not govern:
|
|
|
88
88
|
|
|
89
89
|
## For the reviewer
|
|
90
90
|
|
|
91
|
-
- <What the
|
|
91
|
+
- <What the reviewing session should confirm>
|
|
92
92
|
```
|
|
93
93
|
|
|
94
94
|
### Correct
|
|
@@ -134,5 +134,5 @@ This PR updates the authentication system to be more robust. # "This PR" opener
|
|
|
134
134
|
|
|
135
135
|
- Tested manually # no specific command or case
|
|
136
136
|
- [ ] `npm run test:auth` # unchecked box for a check the agent can run
|
|
137
|
-
- [ ]
|
|
137
|
+
- [ ] The reviewing session confirms the error split reads correctly # a reviewer request, belongs under `## For the reviewer`
|
|
138
138
|
```
|