@erclx/canon 4.53.0 → 4.54.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/README.md +8 -0
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/claude-feature/SKILL.md +1 -1
- package/claude/skills/claude-orchestrate/references/orchestrator-dispatch.md +3 -0
- package/claude/skills/claude-planner/SKILL.md +1 -1
- package/docs/agents/commands.md +1 -0
- package/docs/agents/demo.md +11 -8
- package/docs/agents/index.md +1 -1
- package/docs/agents/indexes.md +17 -1
- package/governance/rules/core/085-worktrees.md +2 -0
- package/package.json +1 -1
- package/scripts/core/regen-agent-fixture.sh +235 -0
- package/src/commands/demo.ts +25 -1
- package/src/commands/indexes.ts +67 -0
- package/src/demo/container.ts +61 -0
- package/src/demo/drive.ts +67 -9
- package/src/indexes/list.ts +64 -0
- package/src/indexes/render.ts +1 -1
- package/tooling/web/configs/scripts/screenshot.sh +1 -1
package/README.md
CHANGED
|
@@ -10,6 +10,14 @@ canon is a CLI and Claude Code plugin that stops your AI conventions drifting ap
|
|
|
10
10
|
|
|
11
11
|
The counts above are read from the catalogs when the image is built, so they're what the repo actually ships today.
|
|
12
12
|
|
|
13
|
+
## It runs on itself
|
|
14
|
+
|
|
15
|
+
The workflow this toolkit ships is the workflow that built it. Several Claude Code sessions run at once, each in its own git worktree on its own branch, and each opens its own pull request.
|
|
16
|
+
|
|
17
|
+

|
|
18
|
+
|
|
19
|
+
That is the landing page's own agent view, recorded from a local build by `canon demo run`. The session rows behind it are a real `canon sessions list --json` read. The activity text and the pull request number are transcribed, because no verb here reports either.
|
|
20
|
+
|
|
13
21
|
## Why
|
|
14
22
|
|
|
15
23
|
If you work across more than one repository and your AI setup has started to drift between them, this is for you. Every AI coding setup accumulates the same assets. Prompts to reuse, rules agents should follow, slash commands, skills, seed docs, sync scripts. Once you have enough projects, your copies drift and your agents stop getting consistent signals.
|
|
@@ -93,7 +93,7 @@ Omit empty sections. Do not print `None identified.` in chat.
|
|
|
93
93
|
|
|
94
94
|
Derive a 2-to-4-word kebab-case slug from the feature description. Write the full plan to `.canon/plans/feature-<slug>.md` at the main worktree root, not the current worktree. Resolve that root the way `claude-worktree` does. Create the directory if it does not exist.
|
|
95
95
|
|
|
96
|
-
From a linked worktree the file-editing tools refuse that path, so the plan goes out through `Bash`. Send the `mkdir -p` and the heredoc as two plain commands rather than joining them with `&&`, which is refused as compound.
|
|
96
|
+
From a linked worktree, or from a background session sitting at the main root with none entered, the file-editing tools refuse that path, so the plan goes out through `Bash`. Send the `mkdir -p` and the heredoc as two plain commands rather than joining them with `&&`, which is refused as compound.
|
|
97
97
|
|
|
98
98
|
The file follows the template in `${CLAUDE_SKILL_DIR}/../../standards/plan.md`. Copy the shape from there rather than from this body, so one edit to the standard moves every plan.
|
|
99
99
|
|
|
@@ -227,6 +227,9 @@ build shape above reaches `claude-worker`.
|
|
|
227
227
|
No branch and no worktree exist here and none is created. A planner writes one
|
|
228
228
|
gitignored file at the main worktree root, so this shape names the row's task
|
|
229
229
|
file rather than a branch and opens with the role instead of a worktree call.
|
|
230
|
+
That write meets the isolation guard the same way a linked worktree's
|
|
231
|
+
main-root write does, with no worktree here to redirect it to, so
|
|
232
|
+
`claude-planner` sends it as a `Bash` heredoc rather than through `Write`.
|
|
230
233
|
|
|
231
234
|
```bash
|
|
232
235
|
claude --bg --model <model> -n "planner-<project>-<slug>" "Run /canon:claude-planner, then /canon:claude-feature <task>. Your controller is the session whose sessionId is <dispatcher-id>. Resolve its current name from that id through canon sessions list --json, which carries sessionId per row, at the moment you send, and never resolve an addressee by name prefix. Message it when the plan lands, carrying the path and what the task file got wrong, and message it again if you stop on a question."
|
|
@@ -21,7 +21,7 @@ from here.
|
|
|
21
21
|
|
|
22
22
|
## Where the session stands
|
|
23
23
|
|
|
24
|
-
- Write one file, the plan, at `.canon/plans/feature-<slug>.md` on the main worktree root. Everything else this session touches is a read.
|
|
24
|
+
- Write one file, the plan, at `.canon/plans/feature-<slug>.md` on the main worktree root. Everything else this session touches is a read. This session holds no worktree, so send the file as a plain `Bash` heredoc past the same main-root refusal a linked worktree meets. The route skips the write-matched hooks, and they are no-ops on a plan file.
|
|
25
25
|
- Never enter a worktree, never create a branch, and never write a tracked file. A planner that builds has stopped being one, and the row loses the independent read the worker's own session was going to bring to it.
|
|
26
26
|
- Resolve `.canon/plans/`, `.canon/tasks/`, and `.canon/review/` at the main worktree root. Those folders are gitignored, so a copy beside a linked worktree is absent rather than empty.
|
|
27
27
|
- Plan the row the launch named. Report a second row met while reading rather than planning it, since which rows run is the controller's call.
|
package/docs/agents/commands.md
CHANGED
|
@@ -20,6 +20,7 @@ Full help: `canon <command> --help`. Behavior notes for the install and sync ver
|
|
|
20
20
|
| `canon sandbox check` | Score a provisioned sandbox against a scenario expectation (`--json` for the verdict) |
|
|
21
21
|
| `canon sandbox coverage` | Report which scenarios declare expectations (`--json`, `--strict`, `--skills`) |
|
|
22
22
|
| `canon indexes regen` | Regenerate `index.md` files from sibling frontmatter |
|
|
23
|
+
| `canon indexes list [path]` | Flatten every folder index under a path into one `{path, title, description}` catalog (`--json`) |
|
|
23
24
|
| `canon docs [topic]` | Emit toolkit reference docs (`list`, or a topic by name) |
|
|
24
25
|
| `canon design render` | Render `.claude/DESIGN.md` tokens to HTML and CSS |
|
|
25
26
|
| `canon design regen` | Rewrite this repository's `.claude/DESIGN.md` and `src/design/base.css` from `src/design/tokens.ts` |
|
package/docs/agents/demo.md
CHANGED
|
@@ -38,19 +38,22 @@ A step's caption from the draft renders as an overlay while its hold plays, so t
|
|
|
38
38
|
|
|
39
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
40
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
|
44
|
-
|
|
|
45
|
-
| `--
|
|
46
|
-
| `--
|
|
47
|
-
| `--
|
|
41
|
+
`--gif` adds a third file from the same converter, and it is opt-in where the mp4 is not. A gif runs several times the size of the webm it derives from, and the destination that needs one is a README on a host that strips `<video>`, which GitHub does. The filter generates a palette from the source and then applies it, rather than quantizing per frame, since a per-frame palette is what makes a recording of flat interface colors band and shimmer. It applies that palette without dithering, because the command records applications rather than photographs and the dither writes noise the encoder then stores. One recording measured 3,193,715 bytes dithered against 2,701,941 without, with the same text region cropped from both and read as identical. A missing converter is reported the same way it is for the mp4, so an absent `ffmpeg` costs the gif and not the run.
|
|
42
|
+
|
|
43
|
+
| Option | Behavior |
|
|
44
|
+
| ---------------- | ------------------------------------------------------------------- |
|
|
45
|
+
| `--out <dir>` | Directory to write into, overriding what the plan names |
|
|
46
|
+
| `--cursor <dir>` | Cursor theme folder to draw the pointer from |
|
|
47
|
+
| `--no-video` | Write only the still |
|
|
48
|
+
| `--no-still` | Write only the recording |
|
|
49
|
+
| `--gif` | Also write a gif, for a host that strips video |
|
|
50
|
+
| `--json` | Add a record on stdout carrying every path written and the duration |
|
|
48
51
|
|
|
49
52
|
A step waits on its `waitFor` selector becoming visible and then holds for its own `holdMs`, which is what puts a finished state on screen long enough to read. `navigate` uses the plan's URL unless the step names its own.
|
|
50
53
|
|
|
51
54
|
## The pointer is painted inside the page
|
|
52
55
|
|
|
53
|
-
The browser engine
|
|
56
|
+
The browser engine offers an annotation of its own that draws a dot on the interacted element and a title naming the API call it made, and this recorder does not turn it on. It paints no cursor, so a run relying on it looks like the pointer teleports between targets, and the two overlays below supersede it: a real cursor where the dot is a marker, and the beat's narration where the title reads `Mouse move`. Running both put four overlays on the frame, and the two the engine drew were the two a viewer reads as noise.
|
|
54
57
|
|
|
55
58
|
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.
|
|
56
59
|
|
package/docs/agents/index.md
CHANGED
|
@@ -20,7 +20,7 @@ CLI catalog and invocation rules for agents, split by command domain. Start with
|
|
|
20
20
|
- [Docs](docs.md): How canon docs resolves the toolkit's own reference surface from an install root, and how a split domain is named
|
|
21
21
|
- [Driver](driver.md): Walking a page through named interactions, the probe catalog and the false finding each one carries, why viewport heights are never defaulted, and what each refusal reports
|
|
22
22
|
- [Merge gate](gate.md): Running the gate this repository verifies a branch with, what the stage table holds and what stays a script, how the changed set scopes three stages, and why a stage that cannot read its input reports rather than passing
|
|
23
|
-
- [Indexes](indexes.md): Flags, exit codes, and JSON shape for canon indexes regen, plus when
|
|
23
|
+
- [Indexes](indexes.md): Flags, exit codes, and JSON shape for canon indexes regen and canon indexes list, plus when regen auto-stages what it rewrote
|
|
24
24
|
- [Install and sync](install-and-sync.md): What each install and sync verb writes, refuses, or leaves alone, and how drift is attributed in a target project
|
|
25
25
|
- [Intake](intake.md): Reading intake folder counts and items, the three read states an item can be in, landing a batch of selections in one cluster, the refusal reasons, and why a call is scoped to one file
|
|
26
26
|
- [Key Changes bijection](key-changes.md): Comparing the files a pull request body's Key Changes names against its own diff, the two directions and the split inside each that decides what is worth raising, the span rules the extractor was measured into, and the three refusals that separate a clean pass from a read that produced nothing
|
package/docs/agents/indexes.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
title: Indexes
|
|
3
|
-
description: Flags, exit codes, and JSON shape for canon indexes regen, plus when
|
|
3
|
+
description: Flags, exit codes, and JSON shape for canon indexes regen and canon indexes list, plus when regen auto-stages what it rewrote
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Indexes
|
|
@@ -32,4 +32,20 @@ Skills can parse drift without branching on exit code:
|
|
|
32
32
|
canon indexes regen --dry-run --json | jq '.results[] | select(.action == "would-write")'
|
|
33
33
|
```
|
|
34
34
|
|
|
35
|
+
## List
|
|
36
|
+
|
|
37
|
+
`canon indexes list [path]` walks every folder index under `path` (default: cwd) and flattens each folder's own `title`/`subtitle` plus every sibling's `title`/`description` into one catalog, sorted by path relative to the walk root.
|
|
38
|
+
|
|
39
|
+
| Option | Behavior |
|
|
40
|
+
| -------- | ------------------------------------------ |
|
|
41
|
+
| `--json` | Emit `{ root, entries, errors }` on stdout |
|
|
42
|
+
|
|
43
|
+
Each entry is `{ path, title, description }`. `path` is relative to the resolved root, and an `index.md` file's own row uses its `subtitle` as `description`.
|
|
44
|
+
|
|
45
|
+
A folder failing frontmatter validation lands its message in `errors` and drops out of `entries` rather than failing the whole walk, matching `regen`'s per-folder isolation. Every mode writes one `ERROR:` line per error to stderr.
|
|
46
|
+
|
|
47
|
+
Exit codes: `0` no errors, `1` root not a directory, or a folder failed frontmatter validation.
|
|
48
|
+
|
|
49
|
+
A git-ignored indexed folder, such as `.canon/tasks/` or `.canon/memory/`, never appears in the output. `listIndexes` filters candidates through `git check-ignore` the same way `regen`'s whole-repo walk does, which is the right default for a documentation lookup since those rows are session scratch, but it is a gap against `regen`'s positional-path mode, which bypasses that filter for those two folders.
|
|
50
|
+
|
|
35
51
|
For the system rationale, frontmatter contract, when to adopt, and bootstrap path, see `.claude/context/indexes.md`.
|
|
@@ -15,3 +15,5 @@ description: Route tracked-file writes and shared session scratch correctly from
|
|
|
15
15
|
- From a linked worktree, `Edit` and `Write` are refused for every main-root path, session scratch included. The refusal names session isolation and points at the worktree copy, which is a second gitignored file no later session reads, so never take that redirect.
|
|
16
16
|
- `Read` resolves against the main root normally from a linked worktree. A main-root write reaches it only through `Bash`, as one plain command rather than a compound one, which is refused for complexity.
|
|
17
17
|
- Route a main-root write by what it does to the file. Creating a whole file goes out as one plain `Bash` command carrying a heredoc. Changing a line inside a file that already exists goes through a command that resolves the main root in-process, because the shell route for that case is the stream editor this file bans.
|
|
18
|
+
- A background session that entered no worktree at all meets the identical refusal writing to the main root, since the guard gates on isolation rather than on sitting inside a linked worktree. A planner writing its plan file from no worktree takes the same heredoc route for that reason.
|
|
19
|
+
- The heredoc route costs nothing on `.canon/plans/` and `.canon/review/`, which no `PostToolUse` hook watches, and costs the index on `.canon/tasks/` and `.canon/memory/`, since a hook matching `Write|Edit|MultiEdit` never fires on `Bash`. Regenerate positionally: in-process where a verb such as `canon tasks archive` already calls it, elsewhere by an explicit `canon indexes regen` call.
|
package/package.json
CHANGED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Writes web/src/fixtures/agent-view.json from a real `canon sessions list
|
|
3
|
+
# --json` read of this repository's own sessions.
|
|
4
|
+
#
|
|
5
|
+
# The page it feeds re-creates an orchestrator dispatching workers, which is the
|
|
6
|
+
# one thing this toolkit does that no other surface here shows. A re-creation
|
|
7
|
+
# earns nothing if the rows behind it are invented, so the session half is a
|
|
8
|
+
# live listing and never a literal.
|
|
9
|
+
#
|
|
10
|
+
# Every role in the roster appears, not the workers alone. The controlling
|
|
11
|
+
# session is pinned above the bands and planners sit in them beside workers,
|
|
12
|
+
# which is what the surface being re-created does. An earlier version of this
|
|
13
|
+
# script filtered the listing down to sessions holding a feature branch, and
|
|
14
|
+
# that dropped the orchestrator and every planner, since a controlling session
|
|
15
|
+
# and a planning session both sit on the default branch. A page about a dispatch
|
|
16
|
+
# that excludes the dispatcher is the defect that filter shipped.
|
|
17
|
+
#
|
|
18
|
+
# Two fields cannot come from the listing and are transcribed by hand into the
|
|
19
|
+
# table below. The activity text is written by Claude Code into its own status
|
|
20
|
+
# line and no verb here reports it. The pull request number sits on a task file
|
|
21
|
+
# with nothing joining a session record to it, which the plan behind this script
|
|
22
|
+
# took as a trade rather than building the join. Both are recorded as
|
|
23
|
+
# transcribed in the fixture's own header, so a reader of the committed file can
|
|
24
|
+
# tell which half was measured and which was typed.
|
|
25
|
+
#
|
|
26
|
+
# The honesty cost this leaves open is the one assets/captures/install.html.tmpl
|
|
27
|
+
# already documents for the terminal frames: a hand-edit after generation
|
|
28
|
+
# defeats the discipline silently, and nothing here detects one.
|
|
29
|
+
#
|
|
30
|
+
# Clone-only. It reads this repository's own sessions, and a registry install
|
|
31
|
+
# carries no such listing.
|
|
32
|
+
set -e
|
|
33
|
+
set -o pipefail
|
|
34
|
+
|
|
35
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
36
|
+
PROJECT_ROOT="${PROJECT_ROOT:-$(cd "$SCRIPT_DIR/../.." && pwd)}"
|
|
37
|
+
|
|
38
|
+
OUT="$PROJECT_ROOT/web/src/fixtures/agent-view.json"
|
|
39
|
+
|
|
40
|
+
# Transcribed half, keyed by session name. The name is the only key that reaches
|
|
41
|
+
# every row: a controlling session and a planning session both sit on the
|
|
42
|
+
# default branch, so a branch cannot tell them apart or from each other. A
|
|
43
|
+
# dispatched session is named `<role>-<project>-<slug>` after the work it was
|
|
44
|
+
# given rather than after what it turned out to be doing, which is what makes
|
|
45
|
+
# the name stable enough to key on here.
|
|
46
|
+
#
|
|
47
|
+
# Each row is `name|activity|pullRequest|state|age`. The state is `pinned`,
|
|
48
|
+
# `working`, or `completed`, and it is where the row renders. An empty pull
|
|
49
|
+
# request field is a session that had not reached its ship step when the
|
|
50
|
+
# snapshot was taken.
|
|
51
|
+
#
|
|
52
|
+
# A pinned or working row has to appear in the live listing or this refuses, and
|
|
53
|
+
# its age comes from the listing rather than the table, so the trailing field is
|
|
54
|
+
# empty for those. A completed row names a session that has already exited, so
|
|
55
|
+
# no listing carries it, its age is transcribed with the rest of the row, and
|
|
56
|
+
# leaving that field blank would print an empty column on half the list.
|
|
57
|
+
# Re-transcribe this table against a fresh listing rather than editing the
|
|
58
|
+
# generated file.
|
|
59
|
+
#
|
|
60
|
+
# A planner belongs in this table as much as a worker does. `claude-planner`
|
|
61
|
+
# forbids entering a worktree, so a planner registers on the default branch at
|
|
62
|
+
# launch and stays there for its whole life, which is why the branch filter this
|
|
63
|
+
# script used to carry dropped every one of them permanently rather than
|
|
64
|
+
# occasionally.
|
|
65
|
+
ROWS=(
|
|
66
|
+
"orchestrator-canon-lead|dispatching the planners that need a plan||pinned|"
|
|
67
|
+
"worker-canon-agent-view-and-deploy|PR #1510 open (landing page)|1510|working|" # canon-allow-reference: transcribed status text
|
|
68
|
+
"planner-canon-context-wireframe-draft|drafting the context and wireframe plan||working|"
|
|
69
|
+
"planner-canon-skill-coverage-pointers|reading the skill coverage pointers||working|"
|
|
70
|
+
"planner-canon-screenshot-trap-guard|planning the screenshot trap guard||working|"
|
|
71
|
+
"planner-canon-write-route-hook-bypass|plan written; tested write probe||completed|32m"
|
|
72
|
+
"planner-canon-indexes-list-lookup|plan and task board consistent||completed|13m"
|
|
73
|
+
"worker-canon-labels-scan-body-file|address-review pass finished|1508|completed|21m"
|
|
74
|
+
"worker-canon-web-context-entry|PR #1507 addressed with commit|1507|completed|20m" # canon-allow-reference: transcribed status text
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
# The listing reports a session's repository as the main `.git` directory, which
|
|
78
|
+
# is what this resolves whether the script runs from the main checkout or from a
|
|
79
|
+
# linked worktree. Comparing against PROJECT_ROOT instead would match nothing
|
|
80
|
+
# from a worktree, where the two differ.
|
|
81
|
+
MAIN_GIT_DIR="$(cd "$PROJECT_ROOT" && git rev-parse --path-format=absolute --git-common-dir)"
|
|
82
|
+
|
|
83
|
+
# `bun src/cli.ts` rather than `canon`, since a globally linked binary resolves
|
|
84
|
+
# to the main checkout no matter which worktree is running, and this script is
|
|
85
|
+
# run from a worktree as often as not.
|
|
86
|
+
SESSIONS_JSON="$(cd "$PROJECT_ROOT" && CANON_NON_INTERACTIVE=1 bun src/cli.ts sessions list --json 2>/dev/null)"
|
|
87
|
+
|
|
88
|
+
if [ -z "$SESSIONS_JSON" ]; then
|
|
89
|
+
echo "regen-agent-fixture: the session listing returned nothing, refusing to write an empty fixture" >&2
|
|
90
|
+
exit 1
|
|
91
|
+
fi
|
|
92
|
+
|
|
93
|
+
# The listing crosses into the eval as a file rather than an environment entry,
|
|
94
|
+
# for the reason regen-hero.sh records: Linux caps a single env string at 128KB
|
|
95
|
+
# and a machine running many sessions can cross it, which fails the exec with
|
|
96
|
+
# E2BIG before any check can report a thing.
|
|
97
|
+
SESSIONS_FILE="$(mktemp)"
|
|
98
|
+
trap 'rm -f "$SESSIONS_FILE"' EXIT
|
|
99
|
+
printf '%s' "$SESSIONS_JSON" >"$SESSIONS_FILE"
|
|
100
|
+
|
|
101
|
+
TRANSCRIBED_ROWS="$(printf '%s\n' "${ROWS[@]}")"
|
|
102
|
+
READ_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
|
103
|
+
|
|
104
|
+
export SESSIONS_FILE OUT PROJECT_ROOT READ_AT TRANSCRIBED_ROWS MAIN_GIT_DIR
|
|
105
|
+
|
|
106
|
+
bun --eval '
|
|
107
|
+
const { readFileSync } = require("node:fs")
|
|
108
|
+
|
|
109
|
+
const { SESSIONS_FILE, OUT, PROJECT_ROOT, READ_AT, TRANSCRIBED_ROWS, MAIN_GIT_DIR } =
|
|
110
|
+
process.env
|
|
111
|
+
|
|
112
|
+
const listing = JSON.parse(readFileSync(SESSIONS_FILE, "utf8"))
|
|
113
|
+
|
|
114
|
+
const rows = TRANSCRIBED_ROWS.split("\n")
|
|
115
|
+
.map((line) => line.replace(/\s*#\s*canon-allow-reference:.*$/, "").trim())
|
|
116
|
+
.filter((line) => line !== "")
|
|
117
|
+
.map((line) => {
|
|
118
|
+
const [name, activity, pullRequest, state, age] = line.split("|")
|
|
119
|
+
return {
|
|
120
|
+
name,
|
|
121
|
+
activity,
|
|
122
|
+
pullRequest: pullRequest ? Number(pullRequest) : null,
|
|
123
|
+
state,
|
|
124
|
+
age: age || null,
|
|
125
|
+
}
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
// Narrowed to this repository, and the reason is disclosure rather than scope.
|
|
129
|
+
// `canon sessions list` is machine-wide, so an unfiltered read carries the
|
|
130
|
+
// session names and worktree paths of every other project on the machine, which
|
|
131
|
+
// here includes paths under a folder named `private` and several client names.
|
|
132
|
+
// This fixture renders on a public page, so the filter is what keeps them off
|
|
133
|
+
// it. The cost is a fidelity gap stated plainly: the real surface is
|
|
134
|
+
// machine-wide and the depiction is one repository, and that is a deliberate
|
|
135
|
+
// narrowing rather than an oversight.
|
|
136
|
+
//
|
|
137
|
+
// Keyed by name for the reason the shell comment gives: a session on the
|
|
138
|
+
// default branch has no other identity, and the orchestrator and every planner
|
|
139
|
+
// sit there.
|
|
140
|
+
const live = new Map(
|
|
141
|
+
listing.sessions
|
|
142
|
+
.filter((session) => session.repository === MAIN_GIT_DIR)
|
|
143
|
+
.map((session) => [session.name, session]),
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
// The real surface shows an age per row, so the fixture carries one. It is
|
|
147
|
+
// frozen at the read rather than live, which the rendered provenance line dates
|
|
148
|
+
// so a reader is never told a stale figure is current.
|
|
149
|
+
const ageLabel = (startedAt) => {
|
|
150
|
+
const minutes = Math.round(
|
|
151
|
+
(Date.parse(READ_AT) - Date.parse(startedAt)) / 60000,
|
|
152
|
+
)
|
|
153
|
+
if (!Number.isFinite(minutes) || minutes < 1) return "now"
|
|
154
|
+
if (minutes < 60) return `${minutes}m`
|
|
155
|
+
const hours = Math.round(minutes / 60)
|
|
156
|
+
return hours < 24 ? `${hours}h` : `${Math.round(hours / 24)}d`
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// A row the page shows as running has to be running. Transcribing one that is
|
|
160
|
+
// not is the failure the live half exists to prevent, so it refuses rather than
|
|
161
|
+
// rendering.
|
|
162
|
+
const absent = rows.filter(
|
|
163
|
+
(row) => row.state !== "completed" && !live.has(row.name),
|
|
164
|
+
)
|
|
165
|
+
if (absent.length > 0) {
|
|
166
|
+
console.error(
|
|
167
|
+
"regen-agent-fixture: no live session for " +
|
|
168
|
+
absent.map((row) => row.name).join(", ") +
|
|
169
|
+
". Re-transcribe the table in this script against a fresh listing rather than editing the generated fixture.",
|
|
170
|
+
)
|
|
171
|
+
process.exit(1)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const sessions = rows.map((row) => {
|
|
175
|
+
const session = live.get(row.name)
|
|
176
|
+
return {
|
|
177
|
+
name: row.name,
|
|
178
|
+
branch: session ? session.branch : null,
|
|
179
|
+
state: row.state,
|
|
180
|
+
activity: row.activity,
|
|
181
|
+
pullRequest: row.pullRequest,
|
|
182
|
+
age: session ? ageLabel(session.startedAt) : row.age,
|
|
183
|
+
}
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
const ageless = sessions.filter((entry) => !entry.age)
|
|
187
|
+
if (ageless.length > 0) {
|
|
188
|
+
console.error(
|
|
189
|
+
"regen-agent-fixture: no age for " +
|
|
190
|
+
ageless.map((entry) => entry.name).join(", ") +
|
|
191
|
+
". A completed row carries its age in the table, since the session has exited and no listing reports one.",
|
|
192
|
+
)
|
|
193
|
+
process.exit(1)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// What the real surface prints above its own list. The remainder is every
|
|
197
|
+
// session on the machine this page has no room to show, which is a real count.
|
|
198
|
+
const summary = {
|
|
199
|
+
working: sessions.filter((entry) => entry.state === "working").length,
|
|
200
|
+
completed: sessions.filter((entry) => entry.state === "completed").length,
|
|
201
|
+
more: Math.max(0, listing.sessions.length - live.size),
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const fixture = {
|
|
205
|
+
// Read by web/src/components/AgentView.astro. Every field below is either a
|
|
206
|
+
// live read or a hand transcription, and this header is what says which.
|
|
207
|
+
generatedBy: "scripts/core/regen-agent-fixture.sh",
|
|
208
|
+
readAt: READ_AT,
|
|
209
|
+
source: {
|
|
210
|
+
command: "canon sessions list --json",
|
|
211
|
+
live: [
|
|
212
|
+
"name",
|
|
213
|
+
"branch",
|
|
214
|
+
"age",
|
|
215
|
+
"that every pinned and working row is running",
|
|
216
|
+
"the remainder count",
|
|
217
|
+
],
|
|
218
|
+
transcribed: {
|
|
219
|
+
activity:
|
|
220
|
+
"written by Claude Code into its own status line, which no verb here reports",
|
|
221
|
+
pullRequest:
|
|
222
|
+
"read off a task file by hand, since nothing joins a session record to one",
|
|
223
|
+
state:
|
|
224
|
+
"a completed session has already exited, so no listing carries the row at all",
|
|
225
|
+
},
|
|
226
|
+
},
|
|
227
|
+
summary,
|
|
228
|
+
sessions,
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
await Bun.write(OUT, JSON.stringify(fixture, null, 2) + "\n")
|
|
232
|
+
console.error(
|
|
233
|
+
`regen-agent-fixture: wrote ${sessions.length} session rows to ${OUT.replace(PROJECT_ROOT + "/", "")}`,
|
|
234
|
+
)
|
|
235
|
+
'
|
package/src/commands/demo.ts
CHANGED
|
@@ -4,7 +4,7 @@ import type { Command } from 'commander'
|
|
|
4
4
|
import { INSTALL_BROWSER, isEngineMissing } from '@/browser/engine'
|
|
5
5
|
import { parseDraft } from '@/demo/beats'
|
|
6
6
|
import { compilePlan, parsePlan, unresolved } from '@/demo/compile'
|
|
7
|
-
import { convertToMp4, INSTALL_CONVERTER } from '@/demo/container'
|
|
7
|
+
import { convertToGif, convertToMp4, INSTALL_CONVERTER } from '@/demo/container'
|
|
8
8
|
import { DEFAULT_CURSORS } from '@/demo/cursors'
|
|
9
9
|
import { loadCursorTheme } from '@/demo/theme'
|
|
10
10
|
import { intro, logError, logInfo, logStep, logWarn, outro, plural } from '@/ui'
|
|
@@ -30,6 +30,7 @@ interface RunOptions {
|
|
|
30
30
|
readonly cursor?: string
|
|
31
31
|
readonly video: boolean
|
|
32
32
|
readonly still: boolean
|
|
33
|
+
readonly gif?: boolean
|
|
33
34
|
readonly json?: boolean
|
|
34
35
|
}
|
|
35
36
|
|
|
@@ -82,6 +83,7 @@ export function register(program: Command): void {
|
|
|
82
83
|
)
|
|
83
84
|
.option('--no-video', 'Skip the recording and write only the still')
|
|
84
85
|
.option('--no-still', 'Skip the still and write only the recording')
|
|
86
|
+
.option('--gif', 'Also write a gif, for a host that strips video')
|
|
85
87
|
.option('--json', 'Add a machine-readable record on stdout')
|
|
86
88
|
.addHelpText(
|
|
87
89
|
'after',
|
|
@@ -93,6 +95,9 @@ export function register(program: Command): void {
|
|
|
93
95
|
'Writes mp4 beside the webm when ffmpeg is on PATH, and skips it',
|
|
94
96
|
`otherwise without failing the run. Install it with: ${INSTALL_CONVERTER}`,
|
|
95
97
|
'',
|
|
98
|
+
'A gif is opt-in behind --gif, since it is far larger than the webm',
|
|
99
|
+
'and only a host that strips video needs one. GitHub is that host.',
|
|
100
|
+
'',
|
|
96
101
|
'Exit codes:',
|
|
97
102
|
' 0 the recording and the still were written',
|
|
98
103
|
' 1 refused, with the reason on stderr',
|
|
@@ -306,16 +311,35 @@ async function runDrive(planPath: string, opts: RunOptions): Promise<number> {
|
|
|
306
311
|
}
|
|
307
312
|
}
|
|
308
313
|
|
|
314
|
+
let gifPath: string | undefined
|
|
315
|
+
let gifReason: string | undefined
|
|
316
|
+
if (result.videoPath && opts.gif) {
|
|
317
|
+
const converted = await convertToGif(result.videoPath)
|
|
318
|
+
if (converted.status === 'converted') {
|
|
319
|
+
gifPath = converted.gifPath
|
|
320
|
+
logInfo(display(gifPath))
|
|
321
|
+
} else if (converted.status === 'skipped') {
|
|
322
|
+
gifReason = converted.reason
|
|
323
|
+
logWarn('ffmpeg is not installed, so no gif was written.')
|
|
324
|
+
logWarn(`Install it with: ${INSTALL_CONVERTER}`)
|
|
325
|
+
} else {
|
|
326
|
+
gifReason = converted.reason
|
|
327
|
+
logWarn(`gif conversion failed: ${converted.reason}`)
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
309
331
|
outro()
|
|
310
332
|
|
|
311
333
|
emit(opts.json, {
|
|
312
334
|
plan: source,
|
|
313
335
|
video: result.videoPath ?? null,
|
|
314
336
|
mp4: mp4Path ?? null,
|
|
337
|
+
gif: gifPath ?? null,
|
|
315
338
|
still: result.stillPath ?? null,
|
|
316
339
|
steps: result.steps,
|
|
317
340
|
durationMs: result.durationMs,
|
|
318
341
|
...(mp4Reason ? { mp4Reason } : {}),
|
|
342
|
+
...(gifReason ? { gifReason } : {}),
|
|
319
343
|
})
|
|
320
344
|
return 0
|
|
321
345
|
}
|
package/src/commands/indexes.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { stat } from 'node:fs/promises'
|
|
|
2
2
|
import { dirname, resolve } from 'node:path'
|
|
3
3
|
import { $ } from 'bun'
|
|
4
4
|
import type { Command } from 'commander'
|
|
5
|
+
import { buildIndexCatalog } from '@/indexes/list'
|
|
5
6
|
import { exitCodeFor, type RegenResult, regenOne } from '@/indexes/regen'
|
|
6
7
|
import { gitEnv } from '@/git-env'
|
|
7
8
|
import { findIndexedAncestor, isIgnored, listIndexes } from '@/indexes/walk'
|
|
@@ -14,6 +15,10 @@ interface RegenCommandOptions {
|
|
|
14
15
|
readonly stage?: boolean
|
|
15
16
|
}
|
|
16
17
|
|
|
18
|
+
interface ListCommandOptions {
|
|
19
|
+
readonly json?: boolean
|
|
20
|
+
}
|
|
21
|
+
|
|
17
22
|
export function register(program: Command): void {
|
|
18
23
|
const indexes = program
|
|
19
24
|
.command('indexes')
|
|
@@ -51,6 +56,30 @@ export function register(program: Command): void {
|
|
|
51
56
|
.action(async (paths: string[], opts: RegenCommandOptions) => {
|
|
52
57
|
process.exitCode = await runRegen(paths, opts)
|
|
53
58
|
})
|
|
59
|
+
|
|
60
|
+
indexes
|
|
61
|
+
.command('list')
|
|
62
|
+
.description('Flatten every folder index under a path into one catalog')
|
|
63
|
+
.argument('[path]', 'Folder to walk (default: cwd)')
|
|
64
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
65
|
+
.option('--json', 'Emit a machine-readable catalog on stdout')
|
|
66
|
+
.addHelpText(
|
|
67
|
+
'after',
|
|
68
|
+
[
|
|
69
|
+
'',
|
|
70
|
+
'Exit codes:',
|
|
71
|
+
' 0 no frontmatter errors',
|
|
72
|
+
' 1 root not a directory, or a folder failed frontmatter validation',
|
|
73
|
+
'',
|
|
74
|
+
'Examples:',
|
|
75
|
+
' canon indexes list',
|
|
76
|
+
' canon indexes list --json docs/',
|
|
77
|
+
'',
|
|
78
|
+
].join('\n'),
|
|
79
|
+
)
|
|
80
|
+
.action(async (path: string | undefined, opts: ListCommandOptions) => {
|
|
81
|
+
process.exitCode = await runList(path, opts)
|
|
82
|
+
})
|
|
54
83
|
}
|
|
55
84
|
|
|
56
85
|
async function runRegen(
|
|
@@ -115,6 +144,44 @@ async function runRegen(
|
|
|
115
144
|
return exitCodeFor(results, { dryRun })
|
|
116
145
|
}
|
|
117
146
|
|
|
147
|
+
async function runList(
|
|
148
|
+
path: string | undefined,
|
|
149
|
+
opts: ListCommandOptions,
|
|
150
|
+
): Promise<number> {
|
|
151
|
+
const root = resolve(path ?? process.cwd())
|
|
152
|
+
const emitJson = opts.json ?? false
|
|
153
|
+
|
|
154
|
+
if (!(await isDirectory(root))) {
|
|
155
|
+
if (emitJson) {
|
|
156
|
+
process.stdout.write(
|
|
157
|
+
`${JSON.stringify({ error: `root not a directory: ${root}` })}\n`,
|
|
158
|
+
)
|
|
159
|
+
return 1
|
|
160
|
+
}
|
|
161
|
+
process.stderr.write(`Root is not a directory: ${root}\n`)
|
|
162
|
+
return 1
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const catalog = await buildIndexCatalog(root)
|
|
166
|
+
|
|
167
|
+
for (const message of catalog.errors) {
|
|
168
|
+
process.stderr.write(`ERROR: ${message}\n`)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (emitJson) {
|
|
172
|
+
process.stdout.write(`${JSON.stringify({ root, ...catalog })}\n`)
|
|
173
|
+
} else {
|
|
174
|
+
intro('canon indexes list')
|
|
175
|
+
logStep('Catalog')
|
|
176
|
+
for (const entry of catalog.entries) {
|
|
177
|
+
logInfo(`${entry.path}: ${entry.title} — ${entry.description}`)
|
|
178
|
+
}
|
|
179
|
+
outro()
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return catalog.errors.length > 0 ? 1 : 0
|
|
183
|
+
}
|
|
184
|
+
|
|
118
185
|
function collectFromPaths(paths: string[], root: string): string[] {
|
|
119
186
|
const dirs: string[] = []
|
|
120
187
|
|
package/src/demo/container.ts
CHANGED
|
@@ -15,6 +15,11 @@ export type ContainerResult =
|
|
|
15
15
|
| { status: 'skipped'; reason: 'converter-missing' }
|
|
16
16
|
| { status: 'failed'; reason: string }
|
|
17
17
|
|
|
18
|
+
export type GifResult =
|
|
19
|
+
| { status: 'converted'; gifPath: string }
|
|
20
|
+
| { status: 'skipped'; reason: 'converter-missing' }
|
|
21
|
+
| { status: 'failed'; reason: string }
|
|
22
|
+
|
|
18
23
|
/**
|
|
19
24
|
* Writes mp4 beside the webm rather than instead of it, since both stated use
|
|
20
25
|
* cases are a `<video>` tag on a page the operator controls, where webm
|
|
@@ -61,3 +66,59 @@ export async function convertToMp4(
|
|
|
61
66
|
}
|
|
62
67
|
return { status: 'converted', mp4Path }
|
|
63
68
|
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Opt-in rather than written beside every recording, because a gif is an order
|
|
72
|
+
* of magnitude larger than the webm it derives from and only one destination
|
|
73
|
+
* needs one: a README on a host that strips `<video>`, which GitHub does. The
|
|
74
|
+
* mp4 above is unconditional for the opposite reason, being cheap and wanted
|
|
75
|
+
* wherever the webm already plays.
|
|
76
|
+
*
|
|
77
|
+
* A palette is generated from the source and then applied, rather than letting
|
|
78
|
+
* ffmpeg quantize per frame. A per-frame palette is what makes a screen
|
|
79
|
+
* recording of flat UI colors band and shimmer, which is precisely the artifact
|
|
80
|
+
* that would make a page look worse in the README than it does in a browser.
|
|
81
|
+
*
|
|
82
|
+
* Dithering is off because this command records applications rather than
|
|
83
|
+
* photographs. A global palette already covers flat interface colors, so the
|
|
84
|
+
* dither only writes noise the encoder then has to store: measured at 3.19MB
|
|
85
|
+
* against 2.70MB on one recording, with the same text region cropped from both
|
|
86
|
+
* and read as visually identical. Width is the larger lever at 2.38MB for 800
|
|
87
|
+
* pixels, and it is not taken, since the terminal rows are what the recording
|
|
88
|
+
* exists to have read. Frame rate is almost no lever at all.
|
|
89
|
+
*
|
|
90
|
+
* The missing-binary and failure handling matches `convertToMp4` exactly: the
|
|
91
|
+
* recording already succeeded by the time this runs, so an optional step never
|
|
92
|
+
* fails the run.
|
|
93
|
+
*/
|
|
94
|
+
export async function convertToGif(
|
|
95
|
+
webmPath: string,
|
|
96
|
+
bin: string = CONVERTER_BIN,
|
|
97
|
+
): Promise<GifResult> {
|
|
98
|
+
const { dir, name } = parse(webmPath)
|
|
99
|
+
const gifPath = join(dir, `${name}.gif`)
|
|
100
|
+
|
|
101
|
+
const result = await execa(
|
|
102
|
+
bin,
|
|
103
|
+
[
|
|
104
|
+
'-y',
|
|
105
|
+
'-i',
|
|
106
|
+
webmPath,
|
|
107
|
+
'-filter_complex',
|
|
108
|
+
'[0:v] fps=12,scale=960:-1:flags=lanczos,split [a][b];[a] palettegen [p];[b][p] paletteuse=dither=none',
|
|
109
|
+
gifPath,
|
|
110
|
+
],
|
|
111
|
+
{ reject: false },
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
if (result.failed && result.code === 'ENOENT') {
|
|
115
|
+
return { status: 'skipped', reason: 'converter-missing' }
|
|
116
|
+
}
|
|
117
|
+
if (result.exitCode !== 0) {
|
|
118
|
+
return {
|
|
119
|
+
status: 'failed',
|
|
120
|
+
reason: result.stderr?.trim() || `ffmpeg exited ${result.exitCode}`,
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return { status: 'converted', gifPath }
|
|
124
|
+
}
|
package/src/demo/drive.ts
CHANGED
|
@@ -38,6 +38,27 @@ const POINTER_SIZE = 32
|
|
|
38
38
|
* first target. A corner keeps that first move out of the way of the content.
|
|
39
39
|
*/
|
|
40
40
|
const START = { x: 8, y: 8 }
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Whether any step will move the pointer. The injected element installs on
|
|
44
|
+
* `DOMContentLoaded` and paints itself off-screen, so it costs nothing until
|
|
45
|
+
* something moves it, and seeding `START` is what first makes it visible.
|
|
46
|
+
*
|
|
47
|
+
* A plan that only navigates, scrolls and holds has nothing to point at, and
|
|
48
|
+
* seeding it anyway parked a cursor in the corner for the whole recording. That
|
|
49
|
+
* reads as a stuck artifact rather than a pointer, which is the same defect as
|
|
50
|
+
* resting it on a row of text, moved to a corner rather than fixed.
|
|
51
|
+
*
|
|
52
|
+
* `scroll` is absent deliberately. It is a targeted verb the compiler groups
|
|
53
|
+
* with the rest, and it stopped moving the pointer when a scroll step began
|
|
54
|
+
* leaving it where it was.
|
|
55
|
+
*/
|
|
56
|
+
function movesPointer(plan: DemoPlan): boolean {
|
|
57
|
+
return plan.steps.some(
|
|
58
|
+
(step) =>
|
|
59
|
+
step.kind === 'click' || step.kind === 'fill' || step.kind === 'hover',
|
|
60
|
+
)
|
|
61
|
+
}
|
|
41
62
|
const SETTLE_MS = 250
|
|
42
63
|
/** Round trips sampled to price one, on the page a step is actually about to move across. */
|
|
43
64
|
const CALIBRATION_STEPS = 8
|
|
@@ -100,16 +121,22 @@ export async function drive(options: DriveOptions): Promise<DriveResult> {
|
|
|
100
121
|
// Pointed the opposite way from a test. A recording wants the motion the
|
|
101
122
|
// interface was designed with, where a test wants it suppressed.
|
|
102
123
|
reducedMotion: 'no-preference',
|
|
124
|
+
// `showActions` is deliberately not passed. Playwright's annotation draws
|
|
125
|
+
// a dot on the interacted element and a title naming the API call it
|
|
126
|
+
// made, and this recorder already supersedes both: the injected pointer
|
|
127
|
+
// is a real cursor where the dot is a marker, and the caption bar carries
|
|
128
|
+
// the beat's narration where the title carries `Mouse move`. Leaving it
|
|
129
|
+
// on ran four overlays where two were wanted, and the two redundant ones
|
|
130
|
+
// were the two a viewer reads as noise. `plan.annotations` survives as a
|
|
131
|
+
// shape the type still carries and nothing now reads. It is not a record
|
|
132
|
+
// of what a committed plan configured, because `parsePlan` has always
|
|
133
|
+
// replaced it with the `ANNOTATIONS` constant rather than reading the
|
|
134
|
+
// file's own values, so that path never existed to preserve.
|
|
103
135
|
...(videoDir
|
|
104
136
|
? {
|
|
105
137
|
recordVideo: {
|
|
106
138
|
dir: videoDir,
|
|
107
139
|
size: plan.viewport,
|
|
108
|
-
showActions: {
|
|
109
|
-
duration: plan.annotations.durationMs,
|
|
110
|
-
position: plan.annotations.position,
|
|
111
|
-
fontSize: plan.annotations.fontSize,
|
|
112
|
-
},
|
|
113
140
|
},
|
|
114
141
|
}
|
|
115
142
|
: {}),
|
|
@@ -137,7 +164,9 @@ export async function drive(options: DriveOptions): Promise<DriveResult> {
|
|
|
137
164
|
// step for the same URL and the second load is a visible reload.
|
|
138
165
|
if (plan.steps[0]?.kind !== 'navigate') {
|
|
139
166
|
await page.goto(plan.url)
|
|
140
|
-
|
|
167
|
+
if (movesPointer(plan)) {
|
|
168
|
+
await page.mouse.move(START.x, START.y, { steps: 2 })
|
|
169
|
+
}
|
|
141
170
|
}
|
|
142
171
|
|
|
143
172
|
for (const step of plan.steps) {
|
|
@@ -217,7 +246,9 @@ export async function runStep(
|
|
|
217
246
|
switch (step.kind) {
|
|
218
247
|
case 'navigate':
|
|
219
248
|
await page.goto(step.target || plan.url)
|
|
220
|
-
|
|
249
|
+
if (movesPointer(plan)) {
|
|
250
|
+
await page.mouse.move(START.x, START.y, { steps: 2 })
|
|
251
|
+
}
|
|
221
252
|
break
|
|
222
253
|
case 'click':
|
|
223
254
|
await moveTo(page, plan, step, pace)
|
|
@@ -234,9 +265,36 @@ export async function runStep(
|
|
|
234
265
|
await moveTo(page, plan, step, pace)
|
|
235
266
|
break
|
|
236
267
|
case 'scroll':
|
|
237
|
-
|
|
268
|
+
// Centred rather than `scrollIntoViewIfNeeded`, which scrolls the least
|
|
269
|
+
// it can and leaves a target taller than the remaining space flush
|
|
270
|
+
// against the bottom edge. Measured on this repository's own recording at
|
|
271
|
+
// 225 pixels of dead space above the content and 2 below, where centring
|
|
272
|
+
// splits it 114 and 113. A recording frames its subject, so the least
|
|
273
|
+
// scroll that technically reveals it is the wrong amount.
|
|
274
|
+
await page
|
|
275
|
+
.locator(step.target)
|
|
276
|
+
.first()
|
|
277
|
+
.evaluate((node) =>
|
|
278
|
+
node.scrollIntoView({
|
|
279
|
+
block: 'center',
|
|
280
|
+
inline: 'nearest',
|
|
281
|
+
// Explicit, because this runs in the page where
|
|
282
|
+
// `scrollIntoViewIfNeeded` ran through the debugging protocol and
|
|
283
|
+
// was always instant. An in-page scroll honours the document's own
|
|
284
|
+
// `scroll-behavior`, so a project setting it to `smooth` gets a
|
|
285
|
+
// call that returns before the scroll finishes, with only
|
|
286
|
+
// `SETTLE_MS` behind it. Nothing in this repository sets it, which
|
|
287
|
+
// is exactly why the recorder cannot catch this on its own pages.
|
|
288
|
+
behavior: 'instant',
|
|
289
|
+
}),
|
|
290
|
+
)
|
|
238
291
|
await page.waitForTimeout(SETTLE_MS)
|
|
239
|
-
|
|
292
|
+
// The pointer stays where it was. A scroll is not a pointing action, so
|
|
293
|
+
// gliding the cursor to the target's centre parks it on top of whatever
|
|
294
|
+
// the scroll just revealed and covers a row of it. A real session scrolls
|
|
295
|
+
// with a wheel and leaves the cursor alone. On a plan that points at
|
|
296
|
+
// nothing, `movesPointer` then keeps it off-screen for the whole run
|
|
297
|
+
// rather than parked in a corner.
|
|
240
298
|
break
|
|
241
299
|
case 'wait':
|
|
242
300
|
case 'hold':
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { dirname, relative, resolve } from 'node:path'
|
|
2
|
+
import { readField, readFrontmatter } from '@/indexes/frontmatter'
|
|
3
|
+
import { collectEntries } from '@/indexes/render'
|
|
4
|
+
import { listIndexes } from '@/indexes/walk'
|
|
5
|
+
|
|
6
|
+
export interface CatalogEntry {
|
|
7
|
+
readonly path: string
|
|
8
|
+
readonly title: string
|
|
9
|
+
readonly description: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface IndexCatalog {
|
|
13
|
+
readonly entries: CatalogEntry[]
|
|
14
|
+
readonly errors: string[]
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Flattens every folder index under `root` into one queryable catalog.
|
|
19
|
+
*
|
|
20
|
+
* A folder's own frontmatter error lands in `errors` without dropping the
|
|
21
|
+
* rest of the walk, the same isolation `regenOne` gives one folder.
|
|
22
|
+
*/
|
|
23
|
+
export async function buildIndexCatalog(root: string): Promise<IndexCatalog> {
|
|
24
|
+
const indexPaths = await listIndexes(root)
|
|
25
|
+
const entries: CatalogEntry[] = []
|
|
26
|
+
const errors: string[] = []
|
|
27
|
+
|
|
28
|
+
for (const indexPath of indexPaths) {
|
|
29
|
+
const dir = dirname(indexPath)
|
|
30
|
+
const frontmatter = await readFrontmatter(indexPath)
|
|
31
|
+
const title = readField(frontmatter, 'title')
|
|
32
|
+
const subtitle = readField(frontmatter, 'subtitle')
|
|
33
|
+
|
|
34
|
+
if (!title || !subtitle) {
|
|
35
|
+
errors.push(
|
|
36
|
+
`missing frontmatter field "title" or "subtitle" in ${indexPath}`,
|
|
37
|
+
)
|
|
38
|
+
} else {
|
|
39
|
+
entries.push({
|
|
40
|
+
path: relative(root, indexPath),
|
|
41
|
+
title,
|
|
42
|
+
description: subtitle,
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const collected = await collectEntries(dir)
|
|
47
|
+
if (!collected.ok) {
|
|
48
|
+
errors.push(...collected.errors)
|
|
49
|
+
continue
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
for (const entry of collected.entries) {
|
|
53
|
+
entries.push({
|
|
54
|
+
path: relative(root, resolve(dir, entry.name)),
|
|
55
|
+
title: entry.title,
|
|
56
|
+
description: entry.description,
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
entries.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))
|
|
62
|
+
|
|
63
|
+
return { entries, errors }
|
|
64
|
+
}
|
package/src/indexes/render.ts
CHANGED
|
@@ -64,7 +64,7 @@ async function readIndexHead(
|
|
|
64
64
|
return { ok: true, head: { raw: frontmatter.raw, title, subtitle } }
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
async function collectEntries(
|
|
67
|
+
export async function collectEntries(
|
|
68
68
|
dir: string,
|
|
69
69
|
): Promise<
|
|
70
70
|
{ ok: true; entries: IndexEntry[] } | { ok: false; errors: string[] }
|
|
@@ -11,7 +11,7 @@ fi
|
|
|
11
11
|
bun run build
|
|
12
12
|
bun run preview >/dev/null 2>&1 &
|
|
13
13
|
PREVIEW_PID=$!
|
|
14
|
-
trap 'kill "$PREVIEW_PID" 2>/dev/null; wait "$PREVIEW_PID" 2>/dev/null || true' EXIT
|
|
14
|
+
trap 'kill "$PREVIEW_PID" 2>/dev/null || true; wait "$PREVIEW_PID" 2>/dev/null || true' EXIT
|
|
15
15
|
|
|
16
16
|
for _ in $(seq 1 40); do
|
|
17
17
|
if curl -sSf "http://localhost:$PREVIEW_PORT/" >/dev/null 2>&1; then
|