···11+---
22+name: dagger-chores
33+description: Handle quick, repeatable Dagger repository maintenance chores. Use when the user asks for small operational changes and wants the same established edits and commit style applied quickly.
44+---
55+66+# Dagger Chores
77+88+## Go Version Bump
99+1010+Use this checklist when asked to bump Go.
1111+1212+1. Update the Go version string in `engine/distconsts/consts.go`:
1313+1414+- `GolangVersion = "X.Y.Z"`
1515+1616+1. Update the Go default version string in `toolchains/go/main.go`:
1717+1818+- `// +default="X.Y.Z"` on the `version` argument in `New(...)`
1919+2020+1. Use a short commit message in this format:
2121+2222+- `chore: bump to go <major.minor>`
2323+- Example: `chore: bump to go 1.26`
2424+2525+1. Create a signed commit:
2626+2727+- `git commit -s -m "chore: bump to go <major.minor>"`
2828+2929+1. Tell the user to double-check whether new Go version locations have been introduced since the last bump, and mention they can ask the agent for help finding them.
3030+3131+- Suggested wording: `Please double-check if any additional Go version strings were added in new files; these locations can change over time. If helpful, I can also help search for those locations.`
3232+3333+## Regenerate Generated Files
3434+3535+Use this checklist when asked to regenerate generated files.
3636+3737+1. From the Dagger repo root, create a temp file for command output and store its path in `tmp_log`.
3838+3939+1. Run generation and redirect all output to the temp file:
4040+4141+- `dagger --progress=plain call generate layer export --path . >"$tmp_log" 2>&1`
4242+4343+1. Search the temp file as needed instead of printing full output.
4444+4545+1. Delete the temp file when done.
4646+4747+## Regenerate Golden Tests
4848+4949+Use this checklist when asked to regenerate telemetry golden tests.
5050+5151+1. From the Dagger repo root, run `dagger -c 'engine-dev | test-telemetry --update | export .'`
+169
.agents/skills/dagger-design-proposals/SKILL.md
···11+---
22+name: dagger-design-proposals
33+description: Write design proposals for Dagger features. Use when asked to draft, review, or iterate on Dagger design documents, RFCs, or proposals.
44+---
55+66+# Dagger Design Proposals
77+88+Guidelines for writing design proposals for Dagger features.
99+1010+## Before Writing
1111+1212+**Always research first:**
1313+1414+1. Check relevant internal docs, such as `internal-docs/dagger-codegen.md`, for context
1515+2. Look at related code in the Dagger codebase:
1616+ - GraphQL schema: `core/schema/*.go`
1717+ - CLI commands: `cmd/dagger/*.go`
1818+ - Core types: `core/*.go`
1919+3. For public API or workspace proposals, read `internal-docs/version-gating.md`
2020+4. Understand existing patterns before proposing new ones
2121+2222+## Structure
2323+2424+````markdown
2525+# Part N: Title
2626+2727+*Builds on [Part N-1: Title](link)*
2828+2929+## Table of Contents
3030+- [Problem](#problem)
3131+- [Solution](#solution)
3232+- [Core Concept](#core-concept)
3333+- [CLI](#cli)
3434+- [Status](#status)
3535+3636+## Problem
3737+3838+Numbered, concise limitations:
3939+4040+1. **Short title** - One sentence explanation.
4141+2. **Short title** - One sentence explanation.
4242+4343+## Solution
4444+4545+One paragraph summary.
4646+4747+## Core Concept
4848+4949+GraphQL type definitions with inline docstrings:
5050+5151+```graphql
5252+"""
5353+Type description here.
5454+"""
5555+type Example {
5656+ """Method description."""
5757+ method(arg: String!): Result!
5858+}
5959+```
6060+6161+Go for implementation examples:
6262+6363+```go
6464+func New(ws dagger.Workspace) *Example {
6565+ // ...
6666+}
6767+```
6868+6969+## CLI
7070+7171+Real command examples:
7272+7373+```bash
7474+$ dagger command --flag
7575+OUTPUT
7676+```
7777+7878+## Status
7979+8080+One line.
8181+8282+---
8383+8484+- Previous: [Part N-1](link)
8585+- Next: [Part N+1](link) or "Part N+1: Title (coming soon)"
8686+8787+````
8888+8989+## Style
9090+9191+- **Concise** - Trust the reader. Remove fluff.
9292+- **Tables** - Use for comparisons.
9393+- **GraphQL for APIs** - Type definitions, not Go interfaces.
9494+- **Go for implementation** - Examples showing how modules use the API.
9595+- **Real examples** - go-toolchain, node-toolchain, not abstract Foo/Bar.
9696+- **Less is more** - Remove sections when challenged.
9797+9898+## What to Avoid
9999+100100+- Separate "Methods" sections (use GraphQL docstrings)
101101+- "Design Rationale" sections unless specifically valuable
102102+- "Open Questions" that aren't real blockers
103103+- Go for type definitions (use GraphQL)
104104+- Layout examples that might confuse
105105+- Over-explaining
106106+107107+## Process
108108+109109+1. **Gists as source of truth** - Publish early, iterate in gist
110110+2. **Link parts together** - Previous/Next at bottom, "Builds on" at top
111111+3. **Each part stands alone** - But builds on previous
112112+4. **Iterate quickly** - User feedback drives changes
113113+114114+## Iterating with User
115115+116116+When you have clarifying questions or notes:
117117+118118+1. **List them first** - Present a high-level numbered list of all questions/notes
119119+2. **One at a time** - Walk through each item individually, waiting for user response
120120+3. **Don't dump** - Never present all questions with full details at once
121121+122122+## Codebase References
123123+124124+When writing proposals, reference actual Dagger code:
125125+126126+| Topic | Location |
127127+|-------|----------|
128128+| GraphQL schema definitions | `core/schema/*.go` |
129129+| CLI commands | `cmd/dagger/*.go` |
130130+| Core types (Directory, File, etc.) | `core/*.go` |
131131+| Engine internals | `engine/*.go` |
132132+| SDK codegen | `internal-docs/dagger-codegen.md`, `cmd/codegen/*.go` |
133133+| API version gates | `internal-docs/version-gating.md`, `core/schema/*.go` |
134134+135135+Example: To understand how `Host.findUp` works before proposing `Workspace.findUp`:
136136+137137+```bash
138138+# Find the schema definition
139139+grep -r "findUp" core/schema/host.go
140140+141141+# Find the implementation
142142+grep -r "FindUp" core/host.go
143143+```
144144+145145+## Publishing
146146+147147+```bash
148148+# Create new gist
149149+gh gist create file.md --desc "Dagger Design: Part N - Title" --public
150150+151151+# Update existing gist
152152+gh gist edit GIST_ID file.md
153153+154154+# Post changelog comment (always do this after updates)
155155+gh api --method POST /gists/GIST_ID/comments -f body="## Changelog
156156+- Change 1
157157+- Change 2"
158158+```
159159+160160+**Always post a changelog comment** after updating a gist with significant changes.
161161+162162+## Related Skills
163163+164164+Check for other Dagger skills that may help with research:
165165+166166+- `engine-debugging` - Engine debugging workflows, trace replay, cache snapshots
167167+- `internal-docs/dagger-codegen.md` - SDK codegen, templates, bindings
168168+- `internal-docs/version-gating.md` - schema views and public API version gates
169169+- `internal-docs/` - Cache and engine implementation references
+351
.agents/skills/engine-debugging/SKILL.md
···11+---
22+name: engine-debugging
33+description: Run Dagger repo tests and debug Dagger engine, core, dagql, filesync, cache, CI trace, panic, hang, leak, and performance issues. Use whenever an agent needs to run tests, choose a test command, or interpret test output in this repository, even before a failure is diagnosed; also use for engine-dev tests, Dagger Cloud trace replay, debug endpoints or pprof, goroutine dumps, panics, hangs, leaks, performance issues, and /debug/dagql/cache snapshots.
44+---
55+66+# Engine Debugging
77+88+This is the default guide for running Dagger engine/core tests and for debugging
99+the failures those tests expose.
1010+1111+Start from evidence, not broad guesses.
1212+1313+## Core Loop
1414+1515+1. Write down the expected flow through the subsystem being debugged.
1616+2. Log actual values at each boundary.
1717+3. Find the first divergence.
1818+4. Decide whether the bug is in identity construction, lookup, lifecycle,
1919+ compatibility behavior, or an external integration boundary.
2020+2121+Use focused repros, recorded traces, and small log windows. Avoid dumping full
2222+test output into the conversation.
2323+2424+Prefer small, high-signal log lines over broad dumps. Good debug logs identify
2525+the boundary being checked and include the relevant stable IDs, digests, keys,
2626+hit path, or lifecycle state needed to compare expected and actual behavior.
2727+2828+## Repro First
2929+3030+Use a tight test repro before adding logs.
3131+3232+Recommended integration command format:
3333+3434+```bash
3535+dagger --progress=plain call engine-dev test --pkg ./core/integration --run='<TestSuiteName>/<SubtestName>'
3636+```
3737+3838+This command rebuilds the dev engine, runs it as an ephemeral service, and then
3939+runs tests against it. Output includes:
4040+4141+- dev engine build output
4242+- test runner output
4343+- engine logs/printlns
4444+- test logs, such as `t.Logf`
4545+4646+Capture output to a file under `/tmp` to avoid overwhelming terminal context:
4747+4848+```bash
4949+dagger --progress=plain call engine-dev test --pkg ./core/integration --run='<TestSuiteName>/<SubtestName>' > /tmp/engine-debug.log 2>&1
5050+rg -n "panic:|--- FAIL:|^FAIL\s" /tmp/engine-debug.log
5151+```
5252+5353+During long runs, periodically grep for panics. If the engine panics, tests may
5454+hang indefinitely:
5555+5656+```bash
5757+rg -n "panic:|fatal error:|SIGSEGV|stack trace" /tmp/engine-debug.log
5858+```
5959+6060+If a test appears hung, capture a goroutine dump from the inner dev engine
6161+process with `SIGQUIT`. Follow this closely so SIGQUIT is not sent to the wrong
6262+process:
6363+6464+```bash
6565+engine_ctr="$(docker ps --format '{{.Names}}' | rg '^dagger-engine-v' | head -n1)"
6666+docker exec "$engine_ctr" sh -lc '
6767+for p in /proc/[0-9]*; do
6868+ pid=${p#/proc/}
6969+ [ "$pid" = "1" ] && continue
7070+ cmd="$(tr "\0" " " < "$p/cmdline" 2>/dev/null || true)"
7171+ case "$cmd" in
7272+ *"/usr/local/bin/dagger-engine"*)
7373+ echo "sending SIGQUIT to inner dagger-engine pid=$pid" >&2
7474+ kill -QUIT "$pid"
7575+ exit 0
7676+ ;;
7777+ esac
7878+done
7979+echo "no inner dagger-engine process found" >&2
8080+exit 1
8181+'
8282+```
8383+8484+Then inspect the same run log for the dump:
8585+8686+```bash
8787+rg -n "goroutine [0-9]+|fatal error:|SIGQUIT|chan receive|chan send|semacquire|sync\\.Mutex|deadlock" /tmp/engine-debug.log
8888+```
8989+9090+After sending SIGQUIT, the tests may hang. Once you confirm the log has SIGQUIT
9191+stack traces, you are done and do not need to wait for the test hang to end.
9292+9393+To compare behavior against an engine from another git ref:
9494+9595+```bash
9696+dagger --progress=plain call engine-dev --source 'https://github.com/dagger/dagger#main' test --pkg ./core/integration --run='TestSomeSuite/TestSomeSubtestYouWant'
9797+```
9898+9999+Do not run multiple suites in parallel unless necessary. Each suite is CPU-heavy
100100+and concurrent runs significantly degrade performance.
101101+102102+Do not use broad `./...` when running tests during engine-debug loops. You can
103103+accidentally capture integration tests or other tests you did not mean to run.
104104+105105+`./core/integration`, `./dagql/idtui`, and `./dagql/idtui/multiprefixw` are
106106+integration-style test packages, not quick unit loops. Avoid running them during
107107+tight debug cycles unless you explicitly need those integration paths.
108108+109109+## CI Trace Replay
110110+111111+When a failure happens in CI, start from the trace if one is available. The user
112112+may provide either a raw trace ID or a command copied from the web UI, such as:
113113+114114+```bash
115115+dagger trace <trace-id>
116116+```
117117+118118+Replay that trace locally with plain progress and capture it to a temp file:
119119+120120+```bash
121121+dagger --progress=plain trace <trace-id> > /tmp/ci-trace-<trace-id>.log 2>&1
122122+```
123123+124124+This does not rerun the CI job. It fetches and prints the recorded trace in the
125125+same style as local `--progress=plain` output. Keep the full output in `/tmp`,
126126+inspect it with `rg`, and avoid dumping the whole trace into the conversation.
127127+128128+### Finding Trace IDs From GitHub PR Checks
129129+130130+If the user gives a GitHub PR URL instead of a trace ID, first inspect the PR's
131131+commit statuses and collect the Dagger Cloud target URLs for the checks of
132132+interest. With GitHub CLI this usually looks like:
133133+134134+```bash
135135+pr_url='https://github.com/dagger/dagger/pull/13119'
136136+head_sha="$(gh pr view "$pr_url" --json headRefOid --jq .headRefOid)"
137137+gh api "repos/dagger/dagger/commits/$head_sha/status" \
138138+ --jq '.statuses[] | select(.target_url | startswith("https://dagger.cloud/")) | [.state, .context, .target_url] | @tsv'
139139+```
140140+141141+For failed checks, add `select(.state != "success")`. A Dagger status target URL
142142+has this shape:
143143+144144+```text
145145+https://dagger.cloud/{org}/checks/{moduleRef}@{moduleVersion}?check={checkName}
146146+```
147147+148148+For public repos, the Cloud GraphQL API can map that URL data to check IDs and
149149+trace IDs without rerunning anything:
150150+151151+```bash
152152+curl -sS -X POST https://api.dagger.cloud/query \
153153+ -H 'Content-Type: application/json' \
154154+ --data '{
155155+ "query": "query($org:String!,$moduleRef:String!,$moduleVersion:String!){ org(name:$org){ moduleChecks(moduleRef:$moduleRef,moduleVersion:$moduleVersion){ commitSHA checks { id name status traceId spanId moduleRef moduleVersion } } } }",
156156+ "variables": {
157157+ "org": "dagger",
158158+ "moduleRef": "github.com/dagger/dagger",
159159+ "moduleVersion": "e7600fda40142627a4206ec04de3a5f702be5a45"
160160+ }
161161+ }' > /tmp/ci-checks.json
162162+163163+jq -r --arg check 'test-split:test-base' \
164164+ '.data.org.moduleChecks[].checks[]
165165+ | select(.name == $check)
166166+ | [.status, .name, .id, .traceId]
167167+ | @tsv' /tmp/ci-checks.json
168168+```
169169+170170+If the Dagger Cloud URL contains `run=<checkID>`, prefer that exact check ID.
171171+Current GitHub status URLs often only include `check=<name>`, so the lookup is
172172+"latest matching check for this org/module/version/name"; be careful after
173173+reruns and prefer the non-success/latest row that matches the status being
174174+debugged.
175175+176176+Once you have the trace ID, replay it with `dagger --progress=plain trace ...`
177177+and capture output to `/tmp` as described above.
178178+179179+Start with the usual failure scan:
180180+181181+```bash
182182+rg -n "panic:|fatal error:|SIGSEGV|--- FAIL:|^FAIL\s|Error:|error:" /tmp/ci-trace-<trace-id>.log
183183+```
184184+185185+Then inspect around the interesting spans:
186186+187187+```bash
188188+rg -n "TestName|FieldName|module name|command text" /tmp/ci-trace-<trace-id>.log
189189+sed -n '<start>,<end>p' /tmp/ci-trace-<trace-id>.log
190190+```
191191+192192+Use the replayed trace to identify the exact failing call, subtest, generated
193193+command, or engine error. Once the failing surface is clear, decide whether to
194194+reproduce it locally with a tight `dagger --progress=plain call engine-dev ...`
195195+command or debug directly from the recorded CI trace.
196196+197197+## Performance Debugging With Persistent Dev Engine
198198+199199+For most testing/debugging flows, prefer ephemeral engines via:
200200+201201+```bash
202202+dagger --progress=plain call engine-dev ...
203203+```
204204+205205+For performance debugging, such as pprof snapshots, repeated profiling loops, or
206206+endpoint inspection, use a persistent dev engine running in Docker.
207207+208208+### Start Persistent Dev Engine
209209+210210+```bash
211211+docker rm -fv dagger-engine.dev
212212+docker volume rm dagger-engine.dev
213213+./hack/dev
214214+```
215215+216216+Notes:
217217+218218+- The container is named `dagger-engine.dev`.
219219+- This engine persists across commands/runs, so it is better for iterative perf
220220+ investigation.
221221+- A clean reset is often desirable for consistent baselines, but is not always
222222+ required; it depends on whether cache/warm state is part of what you are
223223+ measuring.
224224+225225+### Run Commands Against Persistent Engine
226226+227227+Use `./hack/with-dev` to target the running `dagger-engine.dev`:
228228+229229+```bash
230230+./hack/with-dev go test -v -count=1 -run='TestWorkspace/TestWorkspaceContentAddressed/storing_a_Directory' ./core/integration/
231231+```
232232+233233+You can also run Dagger commands through the same wrapper:
234234+235235+```bash
236236+./hack/with-dev ./bin/dagger ...
237237+```
238238+239239+Important CLI gotcha:
240240+241241+- If you do `./hack/with-dev bash -c 'dagger ...'`, you may accidentally pick
242242+ up a non-dev `dagger` binary from `PATH`.
243243+- In shell-wrapped commands, explicitly use `./bin/dagger` to avoid ambiguity.
244244+245245+### Docker-Level Debugging
246246+247247+Because the engine is a normal Docker container, you can use standard Docker
248248+tools:
249249+250250+- `docker logs dagger-engine.dev`
251251+- `docker exec -it dagger-engine.dev sh`
252252+- `docker kill -s <SIGNAL> dagger-engine.dev`
253253+254254+### pprof and Debug Endpoints
255255+256256+The dev engine exposes debug endpoints on `localhost:6060`.
257257+258258+- Current routes are defined in `cmd/engine/debug.go`.
259259+- Use whichever endpoint/tooling fits the question: point-in-time snapshots,
260260+ time-window captures, pprof profiles, or debug endpoint snapshots.
261261+262262+Example heap profile capture over 15 seconds:
263263+264264+```bash
265265+curl 'http://localhost:6060/debug/pprof/heap?seconds=15' > /tmp/heap.pprof
266266+```
267267+268268+Then inspect with:
269269+270270+```bash
271271+go tool pprof /tmp/heap.pprof
272272+```
273273+274274+General profiling guidance:
275275+276276+- Choose profile type and capture window based on the symptom.
277277+- For long-running or phase-specific regressions, align profile capture timing
278278+ with the relevant test phase.
279279+- Keep artifacts organized by run so diffs/comparisons are straightforward.
280280+281281+## Metrics-First Leak Triage
282282+283283+When debugging leaked dagql cache refs, start with Prometheus metrics before
284284+adding deep logs.
285285+286286+Enable metrics on the target engine:
287287+288288+```bash
289289+_EXPERIMENTAL_DAGGER_METRICS_ADDR=0.0.0.0:9090
290290+_EXPERIMENTAL_DAGGER_METRICS_CACHE_UPDATE_INTERVAL=1s
291291+```
292292+293293+Current high-signal metrics:
294294+295295+- `dagger_connected_clients`
296296+- `dagger_dagql_cache_entries`
297297+298298+Interpretation:
299299+300300+1. If `dagger_connected_clients` is `0` but `dagger_dagql_cache_entries` stays
301301+ above the warmed baseline, refs may still be retained.
302302+2. `dagger_dagql_cache_entries` is an index-entry count, not a unique-result
303303+ count. The same shared result may appear in multiple indexes.
304304+305305+Practical scrape tip for nested-engine integration tests:
306306+307307+- Prefer scraping via a container bound to the engine service, such as
308308+ `curl http://dev-engine:9090/metrics`.
309309+- Scraping from the test process via endpoint hostname may fail DNS resolution
310310+ in some test networks.
311311+312312+Useful correlation log during session teardown:
313313+314314+- `engine/server/session.go` logs `released dagql cache refs for session` with
315315+ `beforeEntries` and `afterEntries`.
316316+- If `afterEntries` trends upward across completed sessions, session close may
317317+ not be releasing all refs.
318318+319319+## Internal Docs
320320+321321+Detailed implementation docs live in `../../internal-docs/`. These docs are
322322+useful when debugging a specific subsystem and needing the current mental model:
323323+324324+- `cachebasics.md`: result model, `GetOrInitCall`, dependencies, public cache APIs
325325+- `egraph.md`: symbolic equivalence, terms, eq-classes, hit selection
326326+- `cache_persistence.md`: startup/shutdown persistence model
327327+- `cache_pruning.md`: retention roots, persisted-edge pruning, size accounting
328328+- `lazy_evaluation.md`: lazy result evaluation and object materialization
329329+- `session_resources.md`: secret/socket handle model and session-compatible hits
330330+- `filesync.md`: host/engine sync protocol and mirror/change-cache behavior
331331+- `mutablecache.md`: mutable-backed objects such as HTTP, git mirrors, filesync mirrors, cache volumes
332332+- `typedefs.md`: typedef identity and caching hot paths
333333+- `dynamicinputs.md`: dynamic inputs and implicit cache scoping
334334+- `dagqltypes.md`: nullable/list cache behavior
335335+- `writingcoreapis.md`: practical guide for cache-aware core/schema APIs
336336+- `version-gating.md`: schema views, `engineVersion` gates, workspace v1 test fixtures
337337+338338+Treat internal docs as context, not authority over the code. If you are changing
339339+the implementation, your edits may make the docs stale; verify behavior against
340340+the current code and tests.
341341+342342+## Cache Snapshot Analyzer
343343+344344+Use the bundled analyzer for streamed `/debug/dagql/cache` snapshots:
345345+346346+```bash
347347+go run ./skills/engine-debugging/scripts/dagql-cache-analyzer.go /tmp/dagql.cache.1
348348+```
349349+350350+It summarizes retained roots, result categories, and approximate cumulative
351351+closures so large cache snapshots can be inspected offline.
···11+{
22+ "eval_id": 0,
33+ "eval_name": "review-existing-workflows",
44+ "prompt": "Please review our Tangled CI setup in .tangled/workflows. The build workflow is supposed to build a production Docker image and then run docker compose smoke tests on main and PRs. Tell me if the Spindle YAML looks right and suggest minimal fixes if not.",
55+ "assertions": [
66+ "The output recognizes Tangled CI as Spindle and discusses .tangled/workflows.",
77+ "The output references both build.yml and check.yml or their roles.",
88+ "The output identifies engine: microvm, image: nixos, and Docker-in-microVM requirements for the build workflow.",
99+ "The output preserves the repo conventions such as devenv shell and proposes minimal/focused fixes rather than a broad rewrite."
1010+ ]
1111+}
···11+# Review: current Spindle workflows
22+33+Tangled CI is Spindle, and this repository already has two Spindle workflow files under `.tangled/workflows/`:
44+55+- `.tangled/workflows/check.yml` — runs Dagger checks.
66+- `.tangled/workflows/build.yml` — builds the production Docker image and runs Docker Compose smoke tests.
77+88+## What looks right
99+1010+Both workflows use the expected Spindle shape:
1111+1212+```yaml
1313+when:
1414+ - event: ["push", "manual"]
1515+ branch: ["main"]
1616+ - event: ["pull_request"]
1717+ branch: ["main"]
1818+engine: microvm
1919+image: nixos
2020+clone:
2121+ skip: false
2222+ depth: 1
2323+dependencies:
2424+ - devenv
2525+```
2626+2727+That matches the repo goal: run on pushes to `main`, manual runs, and PRs targeting `main`, using a NixOS microVM with `devenv` available.
2828+2929+For `.tangled/workflows/build.yml`, this is also correct for Docker-based smoke tests:
3030+3131+```yaml
3232+virtualisation:
3333+ docker: true
3434+```
3535+3636+That enables a real Docker daemon inside the microVM, which is what the `docker-buildx` and `docker-compose` steps need.
3737+3838+## Minimal fixes / checks I would consider
3939+4040+1. **Keep `virtualisation.docker: true` in `build.yml`.** It is required for the production image build and Compose smoke test.
4141+4242+2. **Keep the existing `devenv shell -- bash -c '...'` wrapping.** This repo provides `docker-buildx`, `docker-compose`, and Dagger through `devenv`, so the current convention is appropriate.
4343+4444+3. **Consider adding `set -euo pipefail` to `check.yml`.** The current command is short:
4545+4646+ ```yaml
4747+ command: DAGGER_NO_NAG=1 devenv shell dagger check
4848+ ```
4949+5050+ It is probably fine as-is, but if it grows into a multi-line script later, use:
5151+5252+ ```yaml
5353+ command: |
5454+ set -euo pipefail
5555+ DAGGER_NO_NAG=1 devenv shell dagger check
5656+ ```
5757+5858+4. **Verify the production Compose step really uses the image loaded by the previous step.** The build step correctly uses `docker-buildx build --load --target frankenphp_prod --tag app-php-prod .`. The later Compose command uses `--no-build`, so `compose.prod.yaml` must reference `app-php-prod` exactly. If the smoke test fails with “image not found”, this tag/reference is the first thing to check.
5959+6060+5. **If CI is slow during `devenv shell true`, prefer cache work over YAML rewrites.** The current microVM/NixOS setup is structurally right; slow setup is more likely a Nix/cache issue than a Spindle YAML issue.
6161+6262+## Verdict
6363+6464+The Spindle YAML looks broadly correct. I would not rewrite the workflow. The most important parts for the build workflow are already present: `engine: microvm`, `image: nixos`, `dependencies: [devenv]`, and `virtualisation.docker: true`.
···11+{
22+ "eval_id": 1,
33+ "eval_name": "debug-push-log-command",
44+ "prompt": "git push printed this: remote: \u2192 Browse CI logs in your terminal: remote: ssh -t -p 3333 tangled.org did:plc:limfqbuucqmoafpeuc7diz6c 8f22c8f46d9b1b3b17ee4bf8138c38631c4cccf8. What should I do to debug the failed pipeline?",
55+ "assertions": [
66+ "The output recognizes the SSH command as the Spindle/Tangled CI terminal log viewer.",
77+ "The output tells the user to run the exact ssh -t -p 3333 tangled.org command.",
88+ "The output recommends using timeout or caution for non-interactive/log-following contexts.",
99+ "The output gives a practical pipeline triage checklist."
1010+ ]
1111+}
···11+# Debugging the failed Spindle pipeline
22+33+That `remote:` hint is Tangled's terminal log viewer for Spindle, the Tangled CI system.
44+55+Run the exact command it printed:
66+77+```bash
88+ssh -t -p 3333 tangled.org did:plc:limfqbuucqmoafpeuc7diz6c 8f22c8f46d9b1b3b17ee4bf8138c38631c4cccf8
99+```
1010+1111+If you are running it from a script or another non-interactive tool, wrap it with a timeout so it does not follow logs forever:
1212+1313+```bash
1414+timeout 180 ssh -t -p 3333 tangled.org did:plc:limfqbuucqmoafpeuc7diz6c 8f22c8f46d9b1b3b17ee4bf8138c38631c4cccf8
1515+```
1616+1717+## What to look for in the logs
1818+1919+1. **Which workflow ran**
2020+ - Match the failing run to `.tangled/workflows/check.yml` or `.tangled/workflows/build.yml`.
2121+ - Confirm whether the trigger was `push`, `pull_request`, or `manual`.
2222+2323+2. **Where it failed**
2424+ - Clone/setup failure
2525+ - NixOS microVM activation / dependency realization
2626+ - `devenv shell true`
2727+ - Dagger check
2828+ - Docker buildx production image build
2929+ - Docker Compose smoke test
3030+3131+3. **If Docker fails**
3232+ - Confirm the workflow has:
3333+ ```yaml
3434+ engine: microvm
3535+ image: nixos
3636+ virtualisation:
3737+ docker: true
3838+ ```
3939+ - Check whether the image was built with `--load` before Compose uses `--no-build`.
4040+4141+4. **If a tool is missing**
4242+ - In this repo, tools should normally come from `devenv shell ...`.
4343+ - If the missing tool is needed before `devenv` is available, add it to Spindle `dependencies`.
4444+4545+5. **If setup is just slow**
4646+ - Look for Nix/devenv cache misses.
4747+ - Prefer fixing Cachix/Nix caching rather than rewriting the Spindle workflow.
4848+4949+After you capture the failing step and error text, paste that log excerpt back and we can make the smallest workflow or project fix.
···11+{
22+ "eval_id": 2,
33+ "eval_name": "create-rust-postgres-workflow",
44+ "prompt": "Add a new Tangled workflow for a Rust service that needs Postgres and OpenSSL. It should run on PRs to main and pushes to main, use the microvm engine, and keep secrets out of the YAML.",
55+ "assertions": [
66+ "The output provides a Tangled workflow YAML under .tangled/workflows for a Rust service.",
77+ "The YAML uses engine: microvm and image: nixos.",
88+ "The YAML triggers on pushes to main and pull requests targeting main.",
99+ "The YAML includes Rust/OpenSSL-related dependencies such as cargo, rustc, pkg-config, and openssl.",
1010+ "The YAML configures services.postgresql with the spindle-workflow database/user peer-auth pattern.",
1111+ "The output avoids putting secrets in the public workflow YAML."
1212+ ]
1313+}
···11+# Notes
22+33+Save the YAML as `.tangled/workflows/rust.yml` if this repository has a Rust service to test.
44+55+Secrets are intentionally not included in the workflow file. Add secrets through Tangled repository Settings → Secrets, then reference those environment variables from step commands if needed.
66+77+The PostgreSQL setup uses the `spindle-workflow` user/database pattern because Spindle steps run as the `spindle-workflow` Unix user. With Postgres over `/run/postgresql`, peer auth works without putting a database password in CI YAML.
···11+<!DOCTYPE html>
22+<html lang="en">
33+<head>
44+ <meta charset="UTF-8">
55+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
66+ <title>Eval Review</title>
77+ <link rel="preconnect" href="https://fonts.googleapis.com">
88+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
99+ <link href="https://fonts.googleapis.com/css2?family=Poppins:wght@500;600&family=Lora:wght@400;500&display=swap" rel="stylesheet">
1010+ <script src="https://cdn.sheetjs.com/xlsx-0.20.3/package/dist/xlsx.full.min.js" integrity="sha384-EnyY0/GSHQGSxSgMwaIPzSESbqoOLSexfnSMN2AP+39Ckmn92stwABZynq1JyzdT" crossorigin="anonymous"></script>
1111+ <style>
1212+ :root {
1313+ --bg: #faf9f5;
1414+ --surface: #ffffff;
1515+ --border: #e8e6dc;
1616+ --text: #141413;
1717+ --text-muted: #b0aea5;
1818+ --accent: #d97757;
1919+ --accent-hover: #c4613f;
2020+ --green: #788c5d;
2121+ --green-bg: #eef2e8;
2222+ --red: #c44;
2323+ --red-bg: #fceaea;
2424+ --header-bg: #141413;
2525+ --header-text: #faf9f5;
2626+ --radius: 6px;
2727+ }
2828+2929+ * { box-sizing: border-box; margin: 0; padding: 0; }
3030+3131+ body {
3232+ font-family: 'Lora', Georgia, serif;
3333+ background: var(--bg);
3434+ color: var(--text);
3535+ height: 100vh;
3636+ display: flex;
3737+ flex-direction: column;
3838+ }
3939+4040+ /* ---- Header ---- */
4141+ .header {
4242+ background: var(--header-bg);
4343+ color: var(--header-text);
4444+ padding: 1rem 2rem;
4545+ display: flex;
4646+ justify-content: space-between;
4747+ align-items: center;
4848+ flex-shrink: 0;
4949+ }
5050+ .header h1 {
5151+ font-family: 'Poppins', sans-serif;
5252+ font-size: 1.25rem;
5353+ font-weight: 600;
5454+ }
5555+ .header .instructions {
5656+ font-size: 0.8rem;
5757+ opacity: 0.7;
5858+ margin-top: 0.25rem;
5959+ }
6060+ .header .progress {
6161+ font-size: 0.875rem;
6262+ opacity: 0.8;
6363+ text-align: right;
6464+ }
6565+6666+ /* ---- Main content ---- */
6767+ .main {
6868+ flex: 1;
6969+ overflow-y: auto;
7070+ padding: 1.5rem 2rem;
7171+ display: flex;
7272+ flex-direction: column;
7373+ gap: 1.25rem;
7474+ }
7575+7676+ /* ---- Sections ---- */
7777+ .section {
7878+ background: var(--surface);
7979+ border: 1px solid var(--border);
8080+ border-radius: var(--radius);
8181+ flex-shrink: 0;
8282+ }
8383+ .section-header {
8484+ font-family: 'Poppins', sans-serif;
8585+ padding: 0.75rem 1rem;
8686+ font-size: 0.75rem;
8787+ font-weight: 500;
8888+ text-transform: uppercase;
8989+ letter-spacing: 0.05em;
9090+ color: var(--text-muted);
9191+ border-bottom: 1px solid var(--border);
9292+ background: var(--bg);
9393+ }
9494+ .section-body {
9595+ padding: 1rem;
9696+ }
9797+9898+ /* ---- Config badge ---- */
9999+ .config-badge {
100100+ display: inline-block;
101101+ padding: 0.2rem 0.625rem;
102102+ border-radius: 9999px;
103103+ font-family: 'Poppins', sans-serif;
104104+ font-size: 0.6875rem;
105105+ font-weight: 600;
106106+ text-transform: uppercase;
107107+ letter-spacing: 0.03em;
108108+ margin-left: 0.75rem;
109109+ vertical-align: middle;
110110+ }
111111+ .config-badge.config-primary {
112112+ background: rgba(33, 150, 243, 0.12);
113113+ color: #1976d2;
114114+ }
115115+ .config-badge.config-baseline {
116116+ background: rgba(255, 193, 7, 0.15);
117117+ color: #f57f17;
118118+ }
119119+120120+ /* ---- Prompt ---- */
121121+ .prompt-text {
122122+ white-space: pre-wrap;
123123+ font-size: 0.9375rem;
124124+ line-height: 1.6;
125125+ }
126126+127127+ /* ---- Outputs ---- */
128128+ .output-file {
129129+ border: 1px solid var(--border);
130130+ border-radius: var(--radius);
131131+ overflow: hidden;
132132+ }
133133+ .output-file + .output-file {
134134+ margin-top: 1rem;
135135+ }
136136+ .output-file-header {
137137+ padding: 0.5rem 0.75rem;
138138+ font-size: 0.8rem;
139139+ font-weight: 600;
140140+ color: var(--text-muted);
141141+ background: var(--bg);
142142+ border-bottom: 1px solid var(--border);
143143+ font-family: 'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace;
144144+ display: flex;
145145+ justify-content: space-between;
146146+ align-items: center;
147147+ }
148148+ .output-file-header .dl-btn {
149149+ font-size: 0.7rem;
150150+ color: var(--accent);
151151+ text-decoration: none;
152152+ cursor: pointer;
153153+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
154154+ font-weight: 500;
155155+ opacity: 0.8;
156156+ }
157157+ .output-file-header .dl-btn:hover {
158158+ opacity: 1;
159159+ text-decoration: underline;
160160+ }
161161+ .output-file-content {
162162+ padding: 0.75rem;
163163+ overflow-x: auto;
164164+ }
165165+ .output-file-content pre {
166166+ font-size: 0.8125rem;
167167+ line-height: 1.5;
168168+ white-space: pre-wrap;
169169+ word-break: break-word;
170170+ font-family: 'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace;
171171+ }
172172+ .output-file-content img {
173173+ max-width: 100%;
174174+ height: auto;
175175+ border-radius: 4px;
176176+ }
177177+ .output-file-content iframe {
178178+ width: 100%;
179179+ height: 600px;
180180+ border: none;
181181+ }
182182+ .output-file-content table {
183183+ border-collapse: collapse;
184184+ font-size: 0.8125rem;
185185+ width: 100%;
186186+ }
187187+ .output-file-content table td,
188188+ .output-file-content table th {
189189+ border: 1px solid var(--border);
190190+ padding: 0.375rem 0.5rem;
191191+ text-align: left;
192192+ }
193193+ .output-file-content table th {
194194+ background: var(--bg);
195195+ font-weight: 600;
196196+ }
197197+ .output-file-content .download-link {
198198+ display: inline-flex;
199199+ align-items: center;
200200+ gap: 0.5rem;
201201+ padding: 0.5rem 1rem;
202202+ background: var(--bg);
203203+ border: 1px solid var(--border);
204204+ border-radius: 4px;
205205+ color: var(--accent);
206206+ text-decoration: none;
207207+ font-size: 0.875rem;
208208+ cursor: pointer;
209209+ }
210210+ .output-file-content .download-link:hover {
211211+ background: var(--border);
212212+ }
213213+ .empty-state {
214214+ color: var(--text-muted);
215215+ font-style: italic;
216216+ padding: 2rem;
217217+ text-align: center;
218218+ }
219219+220220+ /* ---- Feedback ---- */
221221+ .prev-feedback {
222222+ background: var(--bg);
223223+ border: 1px solid var(--border);
224224+ border-radius: 4px;
225225+ padding: 0.625rem 0.75rem;
226226+ margin-top: 0.75rem;
227227+ font-size: 0.8125rem;
228228+ color: var(--text-muted);
229229+ line-height: 1.5;
230230+ }
231231+ .prev-feedback-label {
232232+ font-size: 0.7rem;
233233+ font-weight: 600;
234234+ text-transform: uppercase;
235235+ letter-spacing: 0.04em;
236236+ margin-bottom: 0.25rem;
237237+ color: var(--text-muted);
238238+ }
239239+ .feedback-textarea {
240240+ width: 100%;
241241+ min-height: 100px;
242242+ padding: 0.75rem;
243243+ border: 1px solid var(--border);
244244+ border-radius: 4px;
245245+ font-family: inherit;
246246+ font-size: 0.9375rem;
247247+ line-height: 1.5;
248248+ resize: vertical;
249249+ color: var(--text);
250250+ }
251251+ .feedback-textarea:focus {
252252+ outline: none;
253253+ border-color: var(--accent);
254254+ box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
255255+ }
256256+ .feedback-status {
257257+ font-size: 0.75rem;
258258+ color: var(--text-muted);
259259+ margin-top: 0.5rem;
260260+ min-height: 1.1em;
261261+ }
262262+263263+ /* ---- Grades (collapsible) ---- */
264264+ .grades-toggle {
265265+ display: flex;
266266+ align-items: center;
267267+ cursor: pointer;
268268+ user-select: none;
269269+ }
270270+ .grades-toggle:hover {
271271+ color: var(--accent);
272272+ }
273273+ .grades-toggle .arrow {
274274+ margin-right: 0.5rem;
275275+ transition: transform 0.15s;
276276+ font-size: 0.75rem;
277277+ }
278278+ .grades-toggle .arrow.open {
279279+ transform: rotate(90deg);
280280+ }
281281+ .grades-content {
282282+ display: none;
283283+ margin-top: 0.75rem;
284284+ }
285285+ .grades-content.open {
286286+ display: block;
287287+ }
288288+ .grades-summary {
289289+ font-size: 0.875rem;
290290+ margin-bottom: 0.75rem;
291291+ display: flex;
292292+ align-items: center;
293293+ gap: 0.5rem;
294294+ }
295295+ .grade-badge {
296296+ display: inline-block;
297297+ padding: 0.125rem 0.5rem;
298298+ border-radius: 9999px;
299299+ font-size: 0.75rem;
300300+ font-weight: 600;
301301+ }
302302+ .grade-pass { background: var(--green-bg); color: var(--green); }
303303+ .grade-fail { background: var(--red-bg); color: var(--red); }
304304+ .assertion-list {
305305+ list-style: none;
306306+ }
307307+ .assertion-item {
308308+ padding: 0.625rem 0;
309309+ border-bottom: 1px solid var(--border);
310310+ font-size: 0.8125rem;
311311+ }
312312+ .assertion-item:last-child { border-bottom: none; }
313313+ .assertion-status {
314314+ font-weight: 600;
315315+ margin-right: 0.5rem;
316316+ }
317317+ .assertion-status.pass { color: var(--green); }
318318+ .assertion-status.fail { color: var(--red); }
319319+ .assertion-evidence {
320320+ color: var(--text-muted);
321321+ font-size: 0.75rem;
322322+ margin-top: 0.25rem;
323323+ padding-left: 1.5rem;
324324+ }
325325+326326+ /* ---- View tabs ---- */
327327+ .view-tabs {
328328+ display: flex;
329329+ gap: 0;
330330+ padding: 0 2rem;
331331+ background: var(--bg);
332332+ border-bottom: 1px solid var(--border);
333333+ flex-shrink: 0;
334334+ }
335335+ .view-tab {
336336+ font-family: 'Poppins', sans-serif;
337337+ padding: 0.625rem 1.25rem;
338338+ font-size: 0.8125rem;
339339+ font-weight: 500;
340340+ cursor: pointer;
341341+ border: none;
342342+ background: none;
343343+ color: var(--text-muted);
344344+ border-bottom: 2px solid transparent;
345345+ transition: all 0.15s;
346346+ }
347347+ .view-tab:hover { color: var(--text); }
348348+ .view-tab.active {
349349+ color: var(--accent);
350350+ border-bottom-color: var(--accent);
351351+ }
352352+ .view-panel { display: none; }
353353+ .view-panel.active { display: flex; flex-direction: column; flex: 1; overflow: hidden; }
354354+355355+ /* ---- Benchmark view ---- */
356356+ .benchmark-view {
357357+ padding: 1.5rem 2rem;
358358+ overflow-y: auto;
359359+ flex: 1;
360360+ }
361361+ .benchmark-table {
362362+ border-collapse: collapse;
363363+ background: var(--surface);
364364+ border: 1px solid var(--border);
365365+ border-radius: var(--radius);
366366+ font-size: 0.8125rem;
367367+ width: 100%;
368368+ margin-bottom: 1.5rem;
369369+ }
370370+ .benchmark-table th, .benchmark-table td {
371371+ padding: 0.625rem 0.75rem;
372372+ text-align: left;
373373+ border: 1px solid var(--border);
374374+ }
375375+ .benchmark-table th {
376376+ font-family: 'Poppins', sans-serif;
377377+ background: var(--header-bg);
378378+ color: var(--header-text);
379379+ font-weight: 500;
380380+ font-size: 0.75rem;
381381+ text-transform: uppercase;
382382+ letter-spacing: 0.04em;
383383+ }
384384+ .benchmark-table tr:hover { background: var(--bg); }
385385+ .benchmark-table tr.benchmark-row-with { background: rgba(33, 150, 243, 0.06); }
386386+ .benchmark-table tr.benchmark-row-without { background: rgba(255, 193, 7, 0.06); }
387387+ .benchmark-table tr.benchmark-row-with:hover { background: rgba(33, 150, 243, 0.12); }
388388+ .benchmark-table tr.benchmark-row-without:hover { background: rgba(255, 193, 7, 0.12); }
389389+ .benchmark-table tr.benchmark-row-avg { font-weight: 600; border-top: 2px solid var(--border); }
390390+ .benchmark-table tr.benchmark-row-avg.benchmark-row-with { background: rgba(33, 150, 243, 0.12); }
391391+ .benchmark-table tr.benchmark-row-avg.benchmark-row-without { background: rgba(255, 193, 7, 0.12); }
392392+ .benchmark-delta-positive { color: var(--green); font-weight: 600; }
393393+ .benchmark-delta-negative { color: var(--red); font-weight: 600; }
394394+ .benchmark-notes {
395395+ background: var(--surface);
396396+ border: 1px solid var(--border);
397397+ border-radius: var(--radius);
398398+ padding: 1rem;
399399+ }
400400+ .benchmark-notes h3 {
401401+ font-family: 'Poppins', sans-serif;
402402+ font-size: 0.875rem;
403403+ margin-bottom: 0.75rem;
404404+ }
405405+ .benchmark-notes ul {
406406+ list-style: disc;
407407+ padding-left: 1.25rem;
408408+ }
409409+ .benchmark-notes li {
410410+ font-size: 0.8125rem;
411411+ line-height: 1.6;
412412+ margin-bottom: 0.375rem;
413413+ }
414414+ .benchmark-empty {
415415+ color: var(--text-muted);
416416+ font-style: italic;
417417+ text-align: center;
418418+ padding: 3rem;
419419+ }
420420+421421+ /* ---- Navigation ---- */
422422+ .nav {
423423+ display: flex;
424424+ justify-content: space-between;
425425+ align-items: center;
426426+ padding: 1rem 2rem;
427427+ border-top: 1px solid var(--border);
428428+ background: var(--surface);
429429+ flex-shrink: 0;
430430+ }
431431+ .nav-btn {
432432+ font-family: 'Poppins', sans-serif;
433433+ padding: 0.5rem 1.25rem;
434434+ border: 1px solid var(--border);
435435+ border-radius: var(--radius);
436436+ background: var(--surface);
437437+ cursor: pointer;
438438+ font-size: 0.875rem;
439439+ font-weight: 500;
440440+ color: var(--text);
441441+ transition: all 0.15s;
442442+ }
443443+ .nav-btn:hover:not(:disabled) {
444444+ background: var(--bg);
445445+ border-color: var(--text-muted);
446446+ }
447447+ .nav-btn:disabled {
448448+ opacity: 0.4;
449449+ cursor: not-allowed;
450450+ }
451451+ .done-btn {
452452+ font-family: 'Poppins', sans-serif;
453453+ padding: 0.5rem 1.5rem;
454454+ border: 1px solid var(--border);
455455+ border-radius: var(--radius);
456456+ background: var(--surface);
457457+ color: var(--text);
458458+ cursor: pointer;
459459+ font-size: 0.875rem;
460460+ font-weight: 500;
461461+ transition: all 0.15s;
462462+ }
463463+ .done-btn:hover {
464464+ background: var(--bg);
465465+ border-color: var(--text-muted);
466466+ }
467467+ .done-btn.ready {
468468+ border: none;
469469+ background: var(--accent);
470470+ color: white;
471471+ font-weight: 600;
472472+ }
473473+ .done-btn.ready:hover {
474474+ background: var(--accent-hover);
475475+ }
476476+ /* ---- Done overlay ---- */
477477+ .done-overlay {
478478+ display: none;
479479+ position: fixed;
480480+ inset: 0;
481481+ background: rgba(0, 0, 0, 0.5);
482482+ z-index: 100;
483483+ justify-content: center;
484484+ align-items: center;
485485+ }
486486+ .done-overlay.visible {
487487+ display: flex;
488488+ }
489489+ .done-card {
490490+ background: var(--surface);
491491+ border-radius: 12px;
492492+ padding: 2rem 3rem;
493493+ text-align: center;
494494+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
495495+ max-width: 500px;
496496+ }
497497+ .done-card h2 {
498498+ font-size: 1.5rem;
499499+ margin-bottom: 0.5rem;
500500+ }
501501+ .done-card p {
502502+ color: var(--text-muted);
503503+ margin-bottom: 1.5rem;
504504+ line-height: 1.5;
505505+ }
506506+ .done-card .btn-row {
507507+ display: flex;
508508+ gap: 0.5rem;
509509+ justify-content: center;
510510+ }
511511+ .done-card button {
512512+ padding: 0.5rem 1.25rem;
513513+ border: 1px solid var(--border);
514514+ border-radius: var(--radius);
515515+ background: var(--surface);
516516+ cursor: pointer;
517517+ font-size: 0.875rem;
518518+ }
519519+ .done-card button:hover {
520520+ background: var(--bg);
521521+ }
522522+ /* ---- Toast ---- */
523523+ .toast {
524524+ position: fixed;
525525+ bottom: 5rem;
526526+ left: 50%;
527527+ transform: translateX(-50%);
528528+ background: var(--header-bg);
529529+ color: var(--header-text);
530530+ padding: 0.625rem 1.25rem;
531531+ border-radius: var(--radius);
532532+ font-size: 0.875rem;
533533+ opacity: 0;
534534+ transition: opacity 0.3s;
535535+ pointer-events: none;
536536+ z-index: 200;
537537+ }
538538+ .toast.visible {
539539+ opacity: 1;
540540+ }
541541+ </style>
542542+</head>
543543+<body>
544544+ <div id="app" style="height:100vh; display:flex; flex-direction:column;">
545545+ <div class="header">
546546+ <div>
547547+ <h1>Eval Review: <span id="skill-name"></span></h1>
548548+ <div class="instructions">Review each output and leave feedback below. Navigate with arrow keys or buttons. When done, copy feedback and paste into Claude Code.</div>
549549+ </div>
550550+ <div class="progress" id="progress"></div>
551551+ </div>
552552+553553+ <!-- View tabs (only shown when benchmark data exists) -->
554554+ <div class="view-tabs" id="view-tabs" style="display:none;">
555555+ <button class="view-tab active" onclick="switchView('outputs')">Outputs</button>
556556+ <button class="view-tab" onclick="switchView('benchmark')">Benchmark</button>
557557+ </div>
558558+559559+ <!-- Outputs panel (qualitative review) -->
560560+ <div class="view-panel active" id="panel-outputs">
561561+ <div class="main">
562562+ <!-- Prompt -->
563563+ <div class="section">
564564+ <div class="section-header">Prompt <span class="config-badge" id="config-badge" style="display:none;"></span></div>
565565+ <div class="section-body">
566566+ <div class="prompt-text" id="prompt-text"></div>
567567+ </div>
568568+ </div>
569569+570570+ <!-- Outputs -->
571571+ <div class="section">
572572+ <div class="section-header">Output</div>
573573+ <div class="section-body" id="outputs-body">
574574+ <div class="empty-state">No output files found</div>
575575+ </div>
576576+ </div>
577577+578578+ <!-- Previous Output (collapsible) -->
579579+ <div class="section" id="prev-outputs-section" style="display:none;">
580580+ <div class="section-header">
581581+ <div class="grades-toggle" onclick="togglePrevOutputs()">
582582+ <span class="arrow" id="prev-outputs-arrow">▶</span>
583583+ Previous Output
584584+ </div>
585585+ </div>
586586+ <div class="grades-content" id="prev-outputs-content"></div>
587587+ </div>
588588+589589+ <!-- Grades (collapsible) -->
590590+ <div class="section" id="grades-section" style="display:none;">
591591+ <div class="section-header">
592592+ <div class="grades-toggle" onclick="toggleGrades()">
593593+ <span class="arrow" id="grades-arrow">▶</span>
594594+ Formal Grades
595595+ </div>
596596+ </div>
597597+ <div class="grades-content" id="grades-content"></div>
598598+ </div>
599599+600600+ <!-- Feedback -->
601601+ <div class="section">
602602+ <div class="section-header">Your Feedback</div>
603603+ <div class="section-body">
604604+ <textarea
605605+ class="feedback-textarea"
606606+ id="feedback"
607607+ placeholder="What do you think of this output? Any issues, suggestions, or things that look great?"
608608+ ></textarea>
609609+ <div class="feedback-status" id="feedback-status"></div>
610610+ <div class="prev-feedback" id="prev-feedback" style="display:none;">
611611+ <div class="prev-feedback-label">Previous feedback</div>
612612+ <div id="prev-feedback-text"></div>
613613+ </div>
614614+ </div>
615615+ </div>
616616+ </div>
617617+618618+ <div class="nav" id="outputs-nav">
619619+ <button class="nav-btn" id="prev-btn" onclick="navigate(-1)">← Previous</button>
620620+ <button class="done-btn" id="done-btn" onclick="showDoneDialog()">Submit All Reviews</button>
621621+ <button class="nav-btn" id="next-btn" onclick="navigate(1)">Next →</button>
622622+ </div>
623623+ </div><!-- end panel-outputs -->
624624+625625+ <!-- Benchmark panel (quantitative stats) -->
626626+ <div class="view-panel" id="panel-benchmark">
627627+ <div class="benchmark-view" id="benchmark-content">
628628+ <div class="benchmark-empty">No benchmark data available. Run a benchmark to see quantitative results here.</div>
629629+ </div>
630630+ </div>
631631+ </div>
632632+633633+ <!-- Done overlay -->
634634+ <div class="done-overlay" id="done-overlay">
635635+ <div class="done-card">
636636+ <h2>Review Complete</h2>
637637+ <p>Your feedback has been saved. Go back to your Claude Code session and tell Claude you're done reviewing.</p>
638638+ <div class="btn-row">
639639+ <button onclick="closeDoneDialog()">OK</button>
640640+ </div>
641641+ </div>
642642+ </div>
643643+644644+ <!-- Toast -->
645645+ <div class="toast" id="toast"></div>
646646+647647+ <script>
648648+ // ---- Embedded data (injected by generate_review.py) ----
649649+ const EMBEDDED_DATA = {"skill_name": "spindle", "runs": [{"id": "eval-0-review-existing-workflows-with_skill-run-1", "prompt": "(No prompt found)", "eval_id": null, "outputs": [{"name": "review.md", "type": "text", "content": "# Review: current Spindle workflows\n\nTangled CI is Spindle, and this repository already has two Spindle workflow files under `.tangled/workflows/`:\n\n- `.tangled/workflows/check.yml` \u2014 runs Dagger checks.\n- `.tangled/workflows/build.yml` \u2014 builds the production Docker image and runs Docker Compose smoke tests.\n\n## What looks right\n\nBoth workflows use the expected Spindle shape:\n\n```yaml\nwhen:\n - event: [\"push\", \"manual\"]\n branch: [\"main\"]\n - event: [\"pull_request\"]\n branch: [\"main\"]\nengine: microvm\nimage: nixos\nclone:\n skip: false\n depth: 1\ndependencies:\n - devenv\n```\n\nThat matches the repo goal: run on pushes to `main`, manual runs, and PRs targeting `main`, using a NixOS microVM with `devenv` available.\n\nFor `.tangled/workflows/build.yml`, this is also correct for Docker-based smoke tests:\n\n```yaml\nvirtualisation:\n docker: true\n```\n\nThat enables a real Docker daemon inside the microVM, which is what the `docker-buildx` and `docker-compose` steps need.\n\n## Minimal fixes / checks I would consider\n\n1. **Keep `virtualisation.docker: true` in `build.yml`.** It is required for the production image build and Compose smoke test.\n\n2. **Keep the existing `devenv shell -- bash -c '...'` wrapping.** This repo provides `docker-buildx`, `docker-compose`, and Dagger through `devenv`, so the current convention is appropriate.\n\n3. **Consider adding `set -euo pipefail` to `check.yml`.** The current command is short:\n\n ```yaml\n command: DAGGER_NO_NAG=1 devenv shell dagger check\n ```\n\n It is probably fine as-is, but if it grows into a multi-line script later, use:\n\n ```yaml\n command: |\n set -euo pipefail\n DAGGER_NO_NAG=1 devenv shell dagger check\n ```\n\n4. **Verify the production Compose step really uses the image loaded by the previous step.** The build step correctly uses `docker-buildx build --load --target frankenphp_prod --tag app-php-prod .`. The later Compose command uses `--no-build`, so `compose.prod.yaml` must reference `app-php-prod` exactly. If the smoke test fails with \u201cimage not found\u201d, this tag/reference is the first thing to check.\n\n5. **If CI is slow during `devenv shell true`, prefer cache work over YAML rewrites.** The current microVM/NixOS setup is structurally right; slow setup is more likely a Nix/cache issue than a Spindle YAML issue.\n\n## Verdict\n\nThe Spindle YAML looks broadly correct. I would not rewrite the workflow. The most important parts for the build workflow are already present: `engine: microvm`, `image: nixos`, `dependencies: [devenv]`, and `virtualisation.docker: true`.\n"}], "grading": {"expectations": [{"text": "The output recognizes Tangled CI as Spindle and discusses .tangled/workflows.", "passed": true, "evidence": "Manual grade: supported by the generated output for this run."}, {"text": "The output references both build.yml and check.yml or their roles.", "passed": true, "evidence": "Manual grade: supported by the generated output for this run."}, {"text": "The output identifies engine: microvm, image: nixos, and Docker-in-microVM requirements for the build workflow.", "passed": true, "evidence": "Manual grade: supported by the generated output for this run."}, {"text": "The output preserves the repo conventions such as devenv shell and proposes minimal/focused fixes rather than a broad rewrite.", "passed": true, "evidence": "Manual grade: supported by the generated output for this run."}], "summary": {"passed": 4, "failed": 0, "total": 4, "pass_rate": 1.0}, "execution_metrics": {"tool_calls": {"Read": 0, "Write": 0, "Bash": 0, "Edit": 0}, "total_tool_calls": 0, "total_steps": 1, "errors_encountered": 0, "output_chars": 2564, "transcript_chars": 0}, "timing": {"total_tokens": 0, "duration_ms": 0, "total_duration_seconds": 0.0}, "user_notes_summary": {"uncertainties": ["Manual sanity run: Claude CLI was present but not authenticated, so these are not independent subagent outputs."], "needs_review": [], "workarounds": ["Generated with-skill outputs inline and graded assertions manually."]}}}, {"id": "eval-1-debug-push-log-command-with_skill-run-1", "prompt": "(No prompt found)", "eval_id": null, "outputs": [{"name": "debugging.md", "type": "text", "content": "# Debugging the failed Spindle pipeline\n\nThat `remote:` hint is Tangled's terminal log viewer for Spindle, the Tangled CI system.\n\nRun the exact command it printed:\n\n```bash\nssh -t -p 3333 tangled.org did:plc:limfqbuucqmoafpeuc7diz6c 8f22c8f46d9b1b3b17ee4bf8138c38631c4cccf8\n```\n\nIf you are running it from a script or another non-interactive tool, wrap it with a timeout so it does not follow logs forever:\n\n```bash\ntimeout 180 ssh -t -p 3333 tangled.org did:plc:limfqbuucqmoafpeuc7diz6c 8f22c8f46d9b1b3b17ee4bf8138c38631c4cccf8\n```\n\n## What to look for in the logs\n\n1. **Which workflow ran**\n - Match the failing run to `.tangled/workflows/check.yml` or `.tangled/workflows/build.yml`.\n - Confirm whether the trigger was `push`, `pull_request`, or `manual`.\n\n2. **Where it failed**\n - Clone/setup failure\n - NixOS microVM activation / dependency realization\n - `devenv shell true`\n - Dagger check\n - Docker buildx production image build\n - Docker Compose smoke test\n\n3. **If Docker fails**\n - Confirm the workflow has:\n ```yaml\n engine: microvm\n image: nixos\n virtualisation:\n docker: true\n ```\n - Check whether the image was built with `--load` before Compose uses `--no-build`.\n\n4. **If a tool is missing**\n - In this repo, tools should normally come from `devenv shell ...`.\n - If the missing tool is needed before `devenv` is available, add it to Spindle `dependencies`.\n\n5. **If setup is just slow**\n - Look for Nix/devenv cache misses.\n - Prefer fixing Cachix/Nix caching rather than rewriting the Spindle workflow.\n\nAfter you capture the failing step and error text, paste that log excerpt back and we can make the smallest workflow or project fix.\n"}], "grading": {"expectations": [{"text": "The output recognizes the SSH command as the Spindle/Tangled CI terminal log viewer.", "passed": true, "evidence": "Manual grade: supported by the generated output for this run."}, {"text": "The output tells the user to run the exact ssh -t -p 3333 tangled.org command.", "passed": true, "evidence": "Manual grade: supported by the generated output for this run."}, {"text": "The output recommends using timeout or caution for non-interactive/log-following contexts.", "passed": true, "evidence": "Manual grade: supported by the generated output for this run."}, {"text": "The output gives a practical pipeline triage checklist.", "passed": true, "evidence": "Manual grade: supported by the generated output for this run."}], "summary": {"passed": 4, "failed": 0, "total": 4, "pass_rate": 1.0}, "execution_metrics": {"tool_calls": {"Read": 0, "Write": 0, "Bash": 0, "Edit": 0}, "total_tool_calls": 0, "total_steps": 1, "errors_encountered": 0, "output_chars": 1712, "transcript_chars": 0}, "timing": {"total_tokens": 0, "duration_ms": 0, "total_duration_seconds": 0.0}, "user_notes_summary": {"uncertainties": ["Manual sanity run: Claude CLI was present but not authenticated, so these are not independent subagent outputs."], "needs_review": [], "workarounds": ["Generated with-skill outputs inline and graded assertions manually."]}}}, {"id": "eval-2-create-rust-postgres-workflow-with_skill-run-1", "prompt": "(No prompt found)", "eval_id": null, "outputs": [{"name": "notes.md", "type": "text", "content": "# Notes\n\nSave the YAML as `.tangled/workflows/rust.yml` if this repository has a Rust service to test.\n\nSecrets are intentionally not included in the workflow file. Add secrets through Tangled repository Settings \u2192 Secrets, then reference those environment variables from step commands if needed.\n\nThe PostgreSQL setup uses the `spindle-workflow` user/database pattern because Spindle steps run as the `spindle-workflow` Unix user. With Postgres over `/run/postgresql`, peer auth works without putting a database password in CI YAML.\n"}, {"name": "rust-postgres.yml", "type": "text", "content": "# .tangled/workflows/rust.yml\nwhen:\n - event: [\"push\"]\n branch: [\"main\"]\n - event: [\"pull_request\"]\n branch: [\"main\"]\n\nengine: microvm\nimage: nixos\n\nclone:\n skip: false\n depth: 1\n\n# Public, non-secret configuration only. Put secrets in Tangled repository\n# Settings \u2192 Secrets and read them from commands when needed.\nenvironment:\n DATABASE_URL: \"postgresql:///spindle-workflow?host=/run/postgresql\"\n\n# Flat dependency list for the NixOS microVM image.\ndependencies:\n - gcc\n - cargo\n - rustc\n - clippy\n - rustfmt\n - pkg-config\n - openssl\n\nservices:\n postgresql:\n enable: true\n ensureDatabases: [\"spindle-workflow\"]\n ensureUsers:\n - name: spindle-workflow\n ensureDBOwnership: true\n\nsteps:\n - name: \"Check formatting\"\n command: |\n set -euo pipefail\n cargo fmt --check\n\n - name: \"Clippy\"\n command: |\n set -euo pipefail\n cargo clippy --all-targets -- -D warnings\n\n - name: \"Test\"\n command: |\n set -euo pipefail\n cargo test --all\n"}], "grading": {"expectations": [{"text": "The output provides a Tangled workflow YAML under .tangled/workflows for a Rust service.", "passed": true, "evidence": "Manual grade: supported by the generated output for this run."}, {"text": "The YAML uses engine: microvm and image: nixos.", "passed": true, "evidence": "Manual grade: supported by the generated output for this run."}, {"text": "The YAML triggers on pushes to main and pull requests targeting main.", "passed": true, "evidence": "Manual grade: supported by the generated output for this run."}, {"text": "The YAML includes Rust/OpenSSL-related dependencies such as cargo, rustc, pkg-config, and openssl.", "passed": true, "evidence": "Manual grade: supported by the generated output for this run."}, {"text": "The YAML configures services.postgresql with the spindle-workflow database/user peer-auth pattern.", "passed": true, "evidence": "Manual grade: supported by the generated output for this run."}, {"text": "The output avoids putting secrets in the public workflow YAML.", "passed": true, "evidence": "Manual grade: supported by the generated output for this run."}], "summary": {"passed": 6, "failed": 0, "total": 6, "pass_rate": 1.0}, "execution_metrics": {"tool_calls": {"Read": 0, "Write": 0, "Bash": 0, "Edit": 0}, "total_tool_calls": 0, "total_steps": 1, "errors_encountered": 0, "output_chars": 1542, "transcript_chars": 0}, "timing": {"total_tokens": 0, "duration_ms": 0, "total_duration_seconds": 0.0}, "user_notes_summary": {"uncertainties": ["Manual sanity run: Claude CLI was present but not authenticated, so these are not independent subagent outputs."], "needs_review": [], "workarounds": ["Generated with-skill outputs inline and graded assertions manually."]}}}], "previous_feedback": {}, "previous_outputs": {}, "benchmark": {"metadata": {"skill_name": "spindle", "skill_path": "<path/to/skill>", "executor_model": "<model-name>", "analyzer_model": "<model-name>", "timestamp": "2026-07-02T22:32:38Z", "evals_run": [0, 1, 2], "runs_per_configuration": 3}, "runs": [{"eval_id": 0, "configuration": "with_skill", "run_number": 1, "result": {"pass_rate": 1.0, "passed": 4, "failed": 0, "total": 4, "time_seconds": 0.0, "tokens": 2564, "tool_calls": 0, "errors": 0}, "expectations": [{"text": "The output recognizes Tangled CI as Spindle and discusses .tangled/workflows.", "passed": true, "evidence": "Manual grade: supported by the generated output for this run."}, {"text": "The output references both build.yml and check.yml or their roles.", "passed": true, "evidence": "Manual grade: supported by the generated output for this run."}, {"text": "The output identifies engine: microvm, image: nixos, and Docker-in-microVM requirements for the build workflow.", "passed": true, "evidence": "Manual grade: supported by the generated output for this run."}, {"text": "The output preserves the repo conventions such as devenv shell and proposes minimal/focused fixes rather than a broad rewrite.", "passed": true, "evidence": "Manual grade: supported by the generated output for this run."}], "notes": ["Manual sanity run: Claude CLI was present but not authenticated, so these are not independent subagent outputs.", "Generated with-skill outputs inline and graded assertions manually."]}, {"eval_id": 1, "configuration": "with_skill", "run_number": 1, "result": {"pass_rate": 1.0, "passed": 4, "failed": 0, "total": 4, "time_seconds": 0.0, "tokens": 1712, "tool_calls": 0, "errors": 0}, "expectations": [{"text": "The output recognizes the SSH command as the Spindle/Tangled CI terminal log viewer.", "passed": true, "evidence": "Manual grade: supported by the generated output for this run."}, {"text": "The output tells the user to run the exact ssh -t -p 3333 tangled.org command.", "passed": true, "evidence": "Manual grade: supported by the generated output for this run."}, {"text": "The output recommends using timeout or caution for non-interactive/log-following contexts.", "passed": true, "evidence": "Manual grade: supported by the generated output for this run."}, {"text": "The output gives a practical pipeline triage checklist.", "passed": true, "evidence": "Manual grade: supported by the generated output for this run."}], "notes": ["Manual sanity run: Claude CLI was present but not authenticated, so these are not independent subagent outputs.", "Generated with-skill outputs inline and graded assertions manually."]}, {"eval_id": 2, "configuration": "with_skill", "run_number": 1, "result": {"pass_rate": 1.0, "passed": 6, "failed": 0, "total": 6, "time_seconds": 0.0, "tokens": 1542, "tool_calls": 0, "errors": 0}, "expectations": [{"text": "The output provides a Tangled workflow YAML under .tangled/workflows for a Rust service.", "passed": true, "evidence": "Manual grade: supported by the generated output for this run."}, {"text": "The YAML uses engine: microvm and image: nixos.", "passed": true, "evidence": "Manual grade: supported by the generated output for this run."}, {"text": "The YAML triggers on pushes to main and pull requests targeting main.", "passed": true, "evidence": "Manual grade: supported by the generated output for this run."}, {"text": "The YAML includes Rust/OpenSSL-related dependencies such as cargo, rustc, pkg-config, and openssl.", "passed": true, "evidence": "Manual grade: supported by the generated output for this run."}, {"text": "The YAML configures services.postgresql with the spindle-workflow database/user peer-auth pattern.", "passed": true, "evidence": "Manual grade: supported by the generated output for this run."}, {"text": "The output avoids putting secrets in the public workflow YAML.", "passed": true, "evidence": "Manual grade: supported by the generated output for this run."}], "notes": ["Manual sanity run: Claude CLI was present but not authenticated, so these are not independent subagent outputs.", "Generated with-skill outputs inline and graded assertions manually."]}], "run_summary": {"with_skill": {"pass_rate": {"mean": 1.0, "stddev": 0.0, "min": 1.0, "max": 1.0}, "time_seconds": {"mean": 0.0, "stddev": 0.0, "min": 0.0, "max": 0.0}, "tokens": {"mean": 1939.3333, "stddev": 547.6142, "min": 1542, "max": 2564}}, "delta": {"pass_rate": "+1.00", "time_seconds": "+0.0", "tokens": "+1939"}}, "notes": []}};
650650+651651+ // ---- State ----
652652+ let feedbackMap = {}; // run_id -> feedback text
653653+ let currentIndex = 0;
654654+ let visitedRuns = new Set();
655655+656656+ // ---- Init ----
657657+ async function init() {
658658+ // Load saved feedback from server — but only if this isn't a fresh
659659+ // iteration (indicated by previous_feedback being present). When
660660+ // previous feedback exists, the feedback.json on disk is stale from
661661+ // the prior iteration and should not pre-fill the textareas.
662662+ const hasPrevious = Object.keys(EMBEDDED_DATA.previous_feedback || {}).length > 0
663663+ || Object.keys(EMBEDDED_DATA.previous_outputs || {}).length > 0;
664664+ if (!hasPrevious) {
665665+ try {
666666+ const resp = await fetch("/api/feedback");
667667+ const data = await resp.json();
668668+ if (data.reviews) {
669669+ for (const r of data.reviews) feedbackMap[r.run_id] = r.feedback;
670670+ }
671671+ } catch { /* first run, no feedback yet */ }
672672+ }
673673+674674+ document.getElementById("skill-name").textContent = EMBEDDED_DATA.skill_name;
675675+ showRun(0);
676676+677677+ // Wire up feedback auto-save
678678+ const textarea = document.getElementById("feedback");
679679+ let saveTimeout = null;
680680+ textarea.addEventListener("input", () => {
681681+ clearTimeout(saveTimeout);
682682+ document.getElementById("feedback-status").textContent = "";
683683+ saveTimeout = setTimeout(() => saveCurrentFeedback(), 800);
684684+ });
685685+ }
686686+687687+ // ---- Navigation ----
688688+ function navigate(delta) {
689689+ const newIndex = currentIndex + delta;
690690+ if (newIndex >= 0 && newIndex < EMBEDDED_DATA.runs.length) {
691691+ saveCurrentFeedback();
692692+ showRun(newIndex);
693693+ }
694694+ }
695695+696696+ function updateNavButtons() {
697697+ document.getElementById("prev-btn").disabled = currentIndex === 0;
698698+ document.getElementById("next-btn").disabled =
699699+ currentIndex === EMBEDDED_DATA.runs.length - 1;
700700+ }
701701+702702+ // ---- Show a run ----
703703+ function showRun(index) {
704704+ currentIndex = index;
705705+ const run = EMBEDDED_DATA.runs[index];
706706+707707+ // Progress
708708+ document.getElementById("progress").textContent =
709709+ `${index + 1} of ${EMBEDDED_DATA.runs.length}`;
710710+711711+ // Prompt
712712+ document.getElementById("prompt-text").textContent = run.prompt;
713713+714714+ // Config badge
715715+ const badge = document.getElementById("config-badge");
716716+ const configMatch = run.id.match(/(with_skill|without_skill|new_skill|old_skill)/);
717717+ if (configMatch) {
718718+ const config = configMatch[1];
719719+ const isBaseline = config === "without_skill" || config === "old_skill";
720720+ badge.textContent = config.replace(/_/g, " ");
721721+ badge.className = "config-badge " + (isBaseline ? "config-baseline" : "config-primary");
722722+ badge.style.display = "inline-block";
723723+ } else {
724724+ badge.style.display = "none";
725725+ }
726726+727727+ // Outputs
728728+ renderOutputs(run);
729729+730730+ // Previous outputs
731731+ renderPrevOutputs(run);
732732+733733+ // Grades
734734+ renderGrades(run);
735735+736736+ // Previous feedback
737737+ const prevFb = (EMBEDDED_DATA.previous_feedback || {})[run.id];
738738+ const prevEl = document.getElementById("prev-feedback");
739739+ if (prevFb) {
740740+ document.getElementById("prev-feedback-text").textContent = prevFb;
741741+ prevEl.style.display = "block";
742742+ } else {
743743+ prevEl.style.display = "none";
744744+ }
745745+746746+ // Feedback
747747+ document.getElementById("feedback").value = feedbackMap[run.id] || "";
748748+ document.getElementById("feedback-status").textContent = "";
749749+750750+ updateNavButtons();
751751+752752+ // Track visited runs and promote done button when all visited
753753+ visitedRuns.add(index);
754754+ const doneBtn = document.getElementById("done-btn");
755755+ if (visitedRuns.size >= EMBEDDED_DATA.runs.length) {
756756+ doneBtn.classList.add("ready");
757757+ }
758758+759759+ // Scroll main content to top
760760+ document.querySelector(".main").scrollTop = 0;
761761+ }
762762+763763+ // ---- Render outputs ----
764764+ function renderOutputs(run) {
765765+ const container = document.getElementById("outputs-body");
766766+ container.innerHTML = "";
767767+768768+ const outputs = run.outputs || [];
769769+ if (outputs.length === 0) {
770770+ container.innerHTML = '<div class="empty-state">No output files</div>';
771771+ return;
772772+ }
773773+774774+ for (const file of outputs) {
775775+ const fileDiv = document.createElement("div");
776776+ fileDiv.className = "output-file";
777777+778778+ // Always show file header with download link
779779+ const header = document.createElement("div");
780780+ header.className = "output-file-header";
781781+ const nameSpan = document.createElement("span");
782782+ nameSpan.textContent = file.name;
783783+ header.appendChild(nameSpan);
784784+ const dlBtn = document.createElement("a");
785785+ dlBtn.className = "dl-btn";
786786+ dlBtn.textContent = "Download";
787787+ dlBtn.download = file.name;
788788+ dlBtn.href = getDownloadUri(file);
789789+ header.appendChild(dlBtn);
790790+ fileDiv.appendChild(header);
791791+792792+ const content = document.createElement("div");
793793+ content.className = "output-file-content";
794794+795795+ if (file.type === "text") {
796796+ const pre = document.createElement("pre");
797797+ pre.textContent = file.content;
798798+ content.appendChild(pre);
799799+ } else if (file.type === "image") {
800800+ const img = document.createElement("img");
801801+ img.src = file.data_uri;
802802+ img.alt = file.name;
803803+ content.appendChild(img);
804804+ } else if (file.type === "pdf") {
805805+ const iframe = document.createElement("iframe");
806806+ iframe.src = file.data_uri;
807807+ content.appendChild(iframe);
808808+ } else if (file.type === "xlsx") {
809809+ renderXlsx(content, file.data_b64);
810810+ } else if (file.type === "binary") {
811811+ const a = document.createElement("a");
812812+ a.className = "download-link";
813813+ a.href = file.data_uri;
814814+ a.download = file.name;
815815+ a.textContent = "Download " + file.name;
816816+ content.appendChild(a);
817817+ } else if (file.type === "error") {
818818+ const pre = document.createElement("pre");
819819+ pre.textContent = file.content;
820820+ pre.style.color = "var(--red)";
821821+ content.appendChild(pre);
822822+ }
823823+824824+ fileDiv.appendChild(content);
825825+ container.appendChild(fileDiv);
826826+ }
827827+ }
828828+829829+ // ---- XLSX rendering via SheetJS ----
830830+ function renderXlsx(container, b64Data) {
831831+ try {
832832+ const raw = Uint8Array.from(atob(b64Data), c => c.charCodeAt(0));
833833+ const wb = XLSX.read(raw, { type: "array" });
834834+835835+ for (let i = 0; i < wb.SheetNames.length; i++) {
836836+ const sheetName = wb.SheetNames[i];
837837+ const ws = wb.Sheets[sheetName];
838838+839839+ if (wb.SheetNames.length > 1) {
840840+ const sheetLabel = document.createElement("div");
841841+ sheetLabel.style.cssText =
842842+ "font-weight:600; font-size:0.8rem; color:#b0aea5; margin-top:0.5rem; margin-bottom:0.25rem;";
843843+ sheetLabel.textContent = "Sheet: " + sheetName;
844844+ container.appendChild(sheetLabel);
845845+ }
846846+847847+ const htmlStr = XLSX.utils.sheet_to_html(ws, { editable: false });
848848+ const wrapper = document.createElement("div");
849849+ wrapper.innerHTML = htmlStr;
850850+ container.appendChild(wrapper);
851851+ }
852852+ } catch (err) {
853853+ container.textContent = "Error rendering spreadsheet: " + err.message;
854854+ }
855855+ }
856856+857857+ // ---- Grades ----
858858+ function renderGrades(run) {
859859+ const section = document.getElementById("grades-section");
860860+ const content = document.getElementById("grades-content");
861861+862862+ if (!run.grading) {
863863+ section.style.display = "none";
864864+ return;
865865+ }
866866+867867+ const grading = run.grading;
868868+ section.style.display = "block";
869869+ // Reset to collapsed
870870+ content.classList.remove("open");
871871+ document.getElementById("grades-arrow").classList.remove("open");
872872+873873+ const summary = grading.summary || {};
874874+ const expectations = grading.expectations || [];
875875+876876+ let html = '<div style="padding: 1rem;">';
877877+878878+ // Summary line
879879+ const passRate = summary.pass_rate != null
880880+ ? Math.round(summary.pass_rate * 100) + "%"
881881+ : "?";
882882+ const badgeClass = summary.pass_rate >= 0.8 ? "grade-pass" : summary.pass_rate >= 0.5 ? "" : "grade-fail";
883883+ html += '<div class="grades-summary">';
884884+ html += '<span class="grade-badge ' + badgeClass + '">' + passRate + '</span>';
885885+ html += '<span>' + (summary.passed || 0) + ' passed, ' + (summary.failed || 0) + ' failed of ' + (summary.total || 0) + '</span>';
886886+ html += '</div>';
887887+888888+ // Assertions list
889889+ html += '<ul class="assertion-list">';
890890+ for (const exp of expectations) {
891891+ const statusClass = exp.passed ? "pass" : "fail";
892892+ const statusIcon = exp.passed ? "\u2713" : "\u2717";
893893+ html += '<li class="assertion-item">';
894894+ html += '<span class="assertion-status ' + statusClass + '">' + statusIcon + '</span>';
895895+ html += '<span>' + escapeHtml(exp.text) + '</span>';
896896+ if (exp.evidence) {
897897+ html += '<div class="assertion-evidence">' + escapeHtml(exp.evidence) + '</div>';
898898+ }
899899+ html += '</li>';
900900+ }
901901+ html += '</ul>';
902902+903903+ html += '</div>';
904904+ content.innerHTML = html;
905905+ }
906906+907907+ function toggleGrades() {
908908+ const content = document.getElementById("grades-content");
909909+ const arrow = document.getElementById("grades-arrow");
910910+ content.classList.toggle("open");
911911+ arrow.classList.toggle("open");
912912+ }
913913+914914+ // ---- Previous outputs (collapsible) ----
915915+ function renderPrevOutputs(run) {
916916+ const section = document.getElementById("prev-outputs-section");
917917+ const content = document.getElementById("prev-outputs-content");
918918+ const prevOutputs = (EMBEDDED_DATA.previous_outputs || {})[run.id];
919919+920920+ if (!prevOutputs || prevOutputs.length === 0) {
921921+ section.style.display = "none";
922922+ return;
923923+ }
924924+925925+ section.style.display = "block";
926926+ // Reset to collapsed
927927+ content.classList.remove("open");
928928+ document.getElementById("prev-outputs-arrow").classList.remove("open");
929929+930930+ // Render the files into the content area
931931+ content.innerHTML = "";
932932+ const wrapper = document.createElement("div");
933933+ wrapper.style.padding = "1rem";
934934+935935+ for (const file of prevOutputs) {
936936+ const fileDiv = document.createElement("div");
937937+ fileDiv.className = "output-file";
938938+939939+ const header = document.createElement("div");
940940+ header.className = "output-file-header";
941941+ const nameSpan = document.createElement("span");
942942+ nameSpan.textContent = file.name;
943943+ header.appendChild(nameSpan);
944944+ const dlBtn = document.createElement("a");
945945+ dlBtn.className = "dl-btn";
946946+ dlBtn.textContent = "Download";
947947+ dlBtn.download = file.name;
948948+ dlBtn.href = getDownloadUri(file);
949949+ header.appendChild(dlBtn);
950950+ fileDiv.appendChild(header);
951951+952952+ const fc = document.createElement("div");
953953+ fc.className = "output-file-content";
954954+955955+ if (file.type === "text") {
956956+ const pre = document.createElement("pre");
957957+ pre.textContent = file.content;
958958+ fc.appendChild(pre);
959959+ } else if (file.type === "image") {
960960+ const img = document.createElement("img");
961961+ img.src = file.data_uri;
962962+ img.alt = file.name;
963963+ fc.appendChild(img);
964964+ } else if (file.type === "pdf") {
965965+ const iframe = document.createElement("iframe");
966966+ iframe.src = file.data_uri;
967967+ fc.appendChild(iframe);
968968+ } else if (file.type === "xlsx") {
969969+ renderXlsx(fc, file.data_b64);
970970+ } else if (file.type === "binary") {
971971+ const a = document.createElement("a");
972972+ a.className = "download-link";
973973+ a.href = file.data_uri;
974974+ a.download = file.name;
975975+ a.textContent = "Download " + file.name;
976976+ fc.appendChild(a);
977977+ }
978978+979979+ fileDiv.appendChild(fc);
980980+ wrapper.appendChild(fileDiv);
981981+ }
982982+983983+ content.appendChild(wrapper);
984984+ }
985985+986986+ function togglePrevOutputs() {
987987+ const content = document.getElementById("prev-outputs-content");
988988+ const arrow = document.getElementById("prev-outputs-arrow");
989989+ content.classList.toggle("open");
990990+ arrow.classList.toggle("open");
991991+ }
992992+993993+ // ---- Feedback (saved to server -> feedback.json) ----
994994+ function saveCurrentFeedback() {
995995+ const run = EMBEDDED_DATA.runs[currentIndex];
996996+ const text = document.getElementById("feedback").value;
997997+998998+ if (text.trim() === "") {
999999+ delete feedbackMap[run.id];
10001000+ } else {
10011001+ feedbackMap[run.id] = text;
10021002+ }
10031003+10041004+ // Build reviews array from map
10051005+ const reviews = [];
10061006+ for (const [run_id, feedback] of Object.entries(feedbackMap)) {
10071007+ if (feedback.trim()) {
10081008+ reviews.push({ run_id, feedback, timestamp: new Date().toISOString() });
10091009+ }
10101010+ }
10111011+10121012+ fetch("/api/feedback", {
10131013+ method: "POST",
10141014+ headers: { "Content-Type": "application/json" },
10151015+ body: JSON.stringify({ reviews, status: "in_progress" }),
10161016+ }).then(() => {
10171017+ document.getElementById("feedback-status").textContent = "Saved";
10181018+ }).catch(() => {
10191019+ // Static mode or server unavailable — no-op on auto-save,
10201020+ // feedback will be downloaded on final submit
10211021+ document.getElementById("feedback-status").textContent = "Will download on submit";
10221022+ });
10231023+ }
10241024+10251025+ // ---- Done ----
10261026+ function showDoneDialog() {
10271027+ // Save current textarea to feedbackMap (but don't POST yet)
10281028+ const run = EMBEDDED_DATA.runs[currentIndex];
10291029+ const text = document.getElementById("feedback").value;
10301030+ if (text.trim() === "") {
10311031+ delete feedbackMap[run.id];
10321032+ } else {
10331033+ feedbackMap[run.id] = text;
10341034+ }
10351035+10361036+ // POST once with status: complete — include ALL runs so the model
10371037+ // can distinguish "no feedback" (looks good) from "not reviewed"
10381038+ const reviews = [];
10391039+ const ts = new Date().toISOString();
10401040+ for (const r of EMBEDDED_DATA.runs) {
10411041+ reviews.push({ run_id: r.id, feedback: feedbackMap[r.id] || "", timestamp: ts });
10421042+ }
10431043+ const payload = JSON.stringify({ reviews, status: "complete" }, null, 2);
10441044+ fetch("/api/feedback", {
10451045+ method: "POST",
10461046+ headers: { "Content-Type": "application/json" },
10471047+ body: payload,
10481048+ }).then(() => {
10491049+ document.getElementById("done-overlay").classList.add("visible");
10501050+ }).catch(() => {
10511051+ // Server not available (static mode) — download as file
10521052+ const blob = new Blob([payload], { type: "application/json" });
10531053+ const url = URL.createObjectURL(blob);
10541054+ const a = document.createElement("a");
10551055+ a.href = url;
10561056+ a.download = "feedback.json";
10571057+ a.click();
10581058+ URL.revokeObjectURL(url);
10591059+ document.getElementById("done-overlay").classList.add("visible");
10601060+ });
10611061+ }
10621062+10631063+ function closeDoneDialog() {
10641064+ // Reset status back to in_progress
10651065+ saveCurrentFeedback();
10661066+ document.getElementById("done-overlay").classList.remove("visible");
10671067+ }
10681068+10691069+ // ---- Toast ----
10701070+ function showToast(message) {
10711071+ const toast = document.getElementById("toast");
10721072+ toast.textContent = message;
10731073+ toast.classList.add("visible");
10741074+ setTimeout(() => toast.classList.remove("visible"), 2000);
10751075+ }
10761076+10771077+ // ---- Keyboard nav ----
10781078+ document.addEventListener("keydown", (e) => {
10791079+ // Don't capture when typing in textarea
10801080+ if (e.target.tagName === "TEXTAREA") return;
10811081+10821082+ if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
10831083+ e.preventDefault();
10841084+ navigate(-1);
10851085+ } else if (e.key === "ArrowRight" || e.key === "ArrowDown") {
10861086+ e.preventDefault();
10871087+ navigate(1);
10881088+ }
10891089+ });
10901090+10911091+ // ---- Util ----
10921092+ function getDownloadUri(file) {
10931093+ if (file.data_uri) return file.data_uri;
10941094+ if (file.data_b64) return "data:application/octet-stream;base64," + file.data_b64;
10951095+ if (file.type === "text") return "data:text/plain;charset=utf-8," + encodeURIComponent(file.content);
10961096+ return "#";
10971097+ }
10981098+10991099+ function escapeHtml(text) {
11001100+ const div = document.createElement("div");
11011101+ div.textContent = text;
11021102+ return div.innerHTML;
11031103+ }
11041104+11051105+ // ---- View switching ----
11061106+ function switchView(view) {
11071107+ document.querySelectorAll(".view-tab").forEach(t => t.classList.remove("active"));
11081108+ document.querySelectorAll(".view-panel").forEach(p => p.classList.remove("active"));
11091109+ document.querySelector(`[onclick="switchView('${view}')"]`).classList.add("active");
11101110+ document.getElementById("panel-" + view).classList.add("active");
11111111+ }
11121112+11131113+ // ---- Benchmark rendering ----
11141114+ function renderBenchmark() {
11151115+ const data = EMBEDDED_DATA.benchmark;
11161116+ if (!data) return;
11171117+11181118+ // Show the tabs
11191119+ document.getElementById("view-tabs").style.display = "flex";
11201120+11211121+ const container = document.getElementById("benchmark-content");
11221122+ const summary = data.run_summary || {};
11231123+ const metadata = data.metadata || {};
11241124+ const notes = data.notes || [];
11251125+11261126+ let html = "";
11271127+11281128+ // Header
11291129+ html += "<h2 style='font-family: Poppins, sans-serif; margin-bottom: 0.5rem;'>Benchmark Results</h2>";
11301130+ html += "<p style='color: var(--text-muted); font-size: 0.875rem; margin-bottom: 1.25rem;'>";
11311131+ if (metadata.skill_name) html += "<strong>" + escapeHtml(metadata.skill_name) + "</strong> — ";
11321132+ if (metadata.timestamp) html += metadata.timestamp + " — ";
11331133+ if (metadata.evals_run) html += "Evals: " + metadata.evals_run.join(", ") + " — ";
11341134+ html += (metadata.runs_per_configuration || "?") + " runs per configuration";
11351135+ html += "</p>";
11361136+11371137+ // Summary table
11381138+ html += '<table class="benchmark-table">';
11391139+11401140+ function fmtStat(stat, pct) {
11411141+ if (!stat) return "—";
11421142+ const suffix = pct ? "%" : "";
11431143+ const m = pct ? (stat.mean * 100).toFixed(0) : stat.mean.toFixed(1);
11441144+ const s = pct ? (stat.stddev * 100).toFixed(0) : stat.stddev.toFixed(1);
11451145+ return m + suffix + " ± " + s + suffix;
11461146+ }
11471147+11481148+ function deltaClass(val) {
11491149+ if (!val) return "";
11501150+ const n = parseFloat(val);
11511151+ if (n > 0) return "benchmark-delta-positive";
11521152+ if (n < 0) return "benchmark-delta-negative";
11531153+ return "";
11541154+ }
11551155+11561156+ // Discover config names dynamically (everything except "delta")
11571157+ const configs = Object.keys(summary).filter(k => k !== "delta");
11581158+ const configA = configs[0] || "config_a";
11591159+ const configB = configs[1] || "config_b";
11601160+ const labelA = configA.replace(/_/g, " ").replace(/\b\w/g, c => c.toUpperCase());
11611161+ const labelB = configB.replace(/_/g, " ").replace(/\b\w/g, c => c.toUpperCase());
11621162+ const a = summary[configA] || {};
11631163+ const b = summary[configB] || {};
11641164+ const delta = summary.delta || {};
11651165+11661166+ html += "<thead><tr><th>Metric</th><th>" + escapeHtml(labelA) + "</th><th>" + escapeHtml(labelB) + "</th><th>Delta</th></tr></thead>";
11671167+ html += "<tbody>";
11681168+11691169+ html += "<tr><td><strong>Pass Rate</strong></td>";
11701170+ html += "<td>" + fmtStat(a.pass_rate, true) + "</td>";
11711171+ html += "<td>" + fmtStat(b.pass_rate, true) + "</td>";
11721172+ html += '<td class="' + deltaClass(delta.pass_rate) + '">' + (delta.pass_rate || "—") + "</td></tr>";
11731173+11741174+ // Time (only show row if data exists)
11751175+ if (a.time_seconds || b.time_seconds) {
11761176+ html += "<tr><td><strong>Time (s)</strong></td>";
11771177+ html += "<td>" + fmtStat(a.time_seconds, false) + "</td>";
11781178+ html += "<td>" + fmtStat(b.time_seconds, false) + "</td>";
11791179+ html += '<td class="' + deltaClass(delta.time_seconds) + '">' + (delta.time_seconds ? delta.time_seconds + "s" : "—") + "</td></tr>";
11801180+ }
11811181+11821182+ // Tokens (only show row if data exists)
11831183+ if (a.tokens || b.tokens) {
11841184+ html += "<tr><td><strong>Tokens</strong></td>";
11851185+ html += "<td>" + fmtStat(a.tokens, false) + "</td>";
11861186+ html += "<td>" + fmtStat(b.tokens, false) + "</td>";
11871187+ html += '<td class="' + deltaClass(delta.tokens) + '">' + (delta.tokens || "—") + "</td></tr>";
11881188+ }
11891189+11901190+ html += "</tbody></table>";
11911191+11921192+ // Per-eval breakdown (if runs data available)
11931193+ const runs = data.runs || [];
11941194+ if (runs.length > 0) {
11951195+ const evalIds = [...new Set(runs.map(r => r.eval_id))].sort((a, b) => a - b);
11961196+11971197+ html += "<h3 style='font-family: Poppins, sans-serif; margin-bottom: 0.75rem;'>Per-Eval Breakdown</h3>";
11981198+11991199+ const hasTime = runs.some(r => r.result && r.result.time_seconds != null);
12001200+ const hasErrors = runs.some(r => r.result && r.result.errors > 0);
12011201+12021202+ for (const evalId of evalIds) {
12031203+ const evalRuns = runs.filter(r => r.eval_id === evalId);
12041204+ const evalName = evalRuns[0] && evalRuns[0].eval_name ? evalRuns[0].eval_name : "Eval " + evalId;
12051205+12061206+ html += "<h4 style='font-family: Poppins, sans-serif; margin: 1rem 0 0.5rem; color: var(--text);'>" + escapeHtml(evalName) + "</h4>";
12071207+ html += '<table class="benchmark-table">';
12081208+ html += "<thead><tr><th>Config</th><th>Run</th><th>Pass Rate</th>";
12091209+ if (hasTime) html += "<th>Time (s)</th>";
12101210+ if (hasErrors) html += "<th>Crashes During Execution</th>";
12111211+ html += "</tr></thead>";
12121212+ html += "<tbody>";
12131213+12141214+ // Group by config and render with average rows
12151215+ const configGroups = [...new Set(evalRuns.map(r => r.configuration))];
12161216+ for (let ci = 0; ci < configGroups.length; ci++) {
12171217+ const config = configGroups[ci];
12181218+ const configRuns = evalRuns.filter(r => r.configuration === config);
12191219+ if (configRuns.length === 0) continue;
12201220+12211221+ const rowClass = ci === 0 ? "benchmark-row-with" : "benchmark-row-without";
12221222+ const configLabel = config.replace(/_/g, " ").replace(/\b\w/g, c => c.toUpperCase());
12231223+12241224+ for (const run of configRuns) {
12251225+ const r = run.result || {};
12261226+ const prClass = r.pass_rate >= 0.8 ? "benchmark-delta-positive" : r.pass_rate < 0.5 ? "benchmark-delta-negative" : "";
12271227+ html += '<tr class="' + rowClass + '">';
12281228+ html += "<td>" + configLabel + "</td>";
12291229+ html += "<td>" + run.run_number + "</td>";
12301230+ html += '<td class="' + prClass + '">' + ((r.pass_rate || 0) * 100).toFixed(0) + "% (" + (r.passed || 0) + "/" + (r.total || 0) + ")</td>";
12311231+ if (hasTime) html += "<td>" + (r.time_seconds != null ? r.time_seconds.toFixed(1) : "—") + "</td>";
12321232+ if (hasErrors) html += "<td>" + (r.errors || 0) + "</td>";
12331233+ html += "</tr>";
12341234+ }
12351235+12361236+ // Average row
12371237+ const rates = configRuns.map(r => (r.result || {}).pass_rate || 0);
12381238+ const avgRate = rates.reduce((a, b) => a + b, 0) / rates.length;
12391239+ const avgPrClass = avgRate >= 0.8 ? "benchmark-delta-positive" : avgRate < 0.5 ? "benchmark-delta-negative" : "";
12401240+ html += '<tr class="benchmark-row-avg ' + rowClass + '">';
12411241+ html += "<td>" + configLabel + "</td>";
12421242+ html += "<td>Avg</td>";
12431243+ html += '<td class="' + avgPrClass + '">' + (avgRate * 100).toFixed(0) + "%</td>";
12441244+ if (hasTime) {
12451245+ const times = configRuns.map(r => (r.result || {}).time_seconds).filter(t => t != null);
12461246+ html += "<td>" + (times.length ? (times.reduce((a, b) => a + b, 0) / times.length).toFixed(1) : "—") + "</td>";
12471247+ }
12481248+ if (hasErrors) html += "<td></td>";
12491249+ html += "</tr>";
12501250+ }
12511251+ html += "</tbody></table>";
12521252+12531253+ // Per-assertion detail for this eval
12541254+ const runsWithExpectations = {};
12551255+ for (const config of configGroups) {
12561256+ runsWithExpectations[config] = evalRuns.filter(r => r.configuration === config && r.expectations && r.expectations.length > 0);
12571257+ }
12581258+ const hasAnyExpectations = Object.values(runsWithExpectations).some(runs => runs.length > 0);
12591259+ if (hasAnyExpectations) {
12601260+ // Collect all unique assertion texts across all configs
12611261+ const allAssertions = [];
12621262+ const seen = new Set();
12631263+ for (const config of configGroups) {
12641264+ for (const run of runsWithExpectations[config]) {
12651265+ for (const exp of (run.expectations || [])) {
12661266+ if (!seen.has(exp.text)) {
12671267+ seen.add(exp.text);
12681268+ allAssertions.push(exp.text);
12691269+ }
12701270+ }
12711271+ }
12721272+ }
12731273+12741274+ html += '<table class="benchmark-table" style="margin-top: 0.5rem;">';
12751275+ html += "<thead><tr><th>Assertion</th>";
12761276+ for (const config of configGroups) {
12771277+ const label = config.replace(/_/g, " ").replace(/\b\w/g, c => c.toUpperCase());
12781278+ html += "<th>" + escapeHtml(label) + "</th>";
12791279+ }
12801280+ html += "</tr></thead><tbody>";
12811281+12821282+ for (const assertionText of allAssertions) {
12831283+ html += "<tr><td>" + escapeHtml(assertionText) + "</td>";
12841284+12851285+ for (const config of configGroups) {
12861286+ html += "<td>";
12871287+ for (const run of runsWithExpectations[config]) {
12881288+ const exp = (run.expectations || []).find(e => e.text === assertionText);
12891289+ if (exp) {
12901290+ const cls = exp.passed ? "benchmark-delta-positive" : "benchmark-delta-negative";
12911291+ const icon = exp.passed ? "\u2713" : "\u2717";
12921292+ html += '<span class="' + cls + '" title="Run ' + run.run_number + ': ' + escapeHtml(exp.evidence || "") + '">' + icon + "</span> ";
12931293+ } else {
12941294+ html += "— ";
12951295+ }
12961296+ }
12971297+ html += "</td>";
12981298+ }
12991299+ html += "</tr>";
13001300+ }
13011301+ html += "</tbody></table>";
13021302+ }
13031303+ }
13041304+ }
13051305+13061306+ // Notes
13071307+ if (notes.length > 0) {
13081308+ html += '<div class="benchmark-notes">';
13091309+ html += "<h3>Analysis Notes</h3>";
13101310+ html += "<ul>";
13111311+ for (const note of notes) {
13121312+ html += "<li>" + escapeHtml(note) + "</li>";
13131313+ }
13141314+ html += "</ul></div>";
13151315+ }
13161316+13171317+ container.innerHTML = html;
13181318+ }
13191319+13201320+ // ---- Start ----
13211321+ init();
13221322+ renderBenchmark();
13231323+ </script>
13241324+</body>
13251325+</html>
+192
.agents/skills/spindle/SKILL.md
···11+---
22+name: spindle
33+description: |
44+ REQUIRED when the user asks about Tangled CI, Spindle, pipelines, workflow runs,
55+ CI logs from git push, `ssh -t -p 3333 tangled.org ...`, or files under
66+ `.tangled/workflows/`. Use this skill for creating, editing, debugging, or
77+ reviewing Tangled Spindle workflow YAML, especially `engine: microvm`, NixOS
88+ images, `dependencies`, `services`, `virtualisation.docker`, secrets, and CI
99+ trigger behavior. Also use it when the user says "Tangled CI" because the CI
1010+ runner/pipeline system is named Spindle.
1111+---
1212+1313+# Spindle — Tangled CI workflow guidance
1414+1515+Spindle is Tangled's CI/pipeline runner. In this repo, Spindle workflows live in
1616+`.tangled/workflows/*.yml` or `.yaml`.
1717+1818+## First moves
1919+2020+1. Inspect existing workflow files before proposing changes:
2121+ ```bash
2222+ find .tangled/workflows -maxdepth 1 -type f -print | sort
2323+ ```
2424+2. Read the specific workflow(s) involved. Preserve local conventions: file
2525+ names, branch filters, `devenv shell`, Docker Compose usage, Dagger usage, and
2626+ step naming style.
2727+3. If the task involves Tangled repo operations (issues, PRs, repo view), also
2828+ use the `tang` skill. If it involves Cachix/Nix binary cache speed in this
2929+ repo, also use the `cachix` skill.
3030+4. Treat CI changes as potentially expensive: do not run `git push` or manually
3131+ trigger remote workflows unless the user asks or approves.
3232+3333+## Workflow YAML essentials
3434+3535+Common top-level fields:
3636+3737+```yaml
3838+when:
3939+ - event: ["push", "manual"]
4040+ branch: ["main"]
4141+ - event: ["pull_request"]
4242+ branch: ["main"]
4343+4444+engine: microvm
4545+clone:
4646+ skip: false
4747+ depth: 1
4848+4949+environment:
5050+ NODE_ENV: production
5151+5252+steps:
5353+ - name: "Step name"
5454+ command: |
5555+ set -euo pipefail
5656+ command here
5757+```
5858+5959+Trigger notes:
6060+- `push` requires `branch` and/or `tag`.
6161+- `pull_request` `branch` means target branch.
6262+- `manual` ignores `branch`, but it can share a condition with `push`.
6363+- Branch/tag patterns support `*` and `**`.
6464+6565+Environment notes:
6666+- Do not put secrets in `environment`; workflow YAML is public. Use Tangled
6767+ repository Settings → Secrets and reference those variables from commands.
6868+- Prefer `set -euo pipefail` inside multi-line shell commands for reliable CI
6969+ failure behavior.
7070+7171+## Engine selection
7272+7373+Prefer `engine: microvm` for new work unless the repository already uses and
7474+wants to keep `nixery`.
7575+7676+### microVM engine
7777+7878+Typical NixOS microVM workflow:
7979+8080+```yaml
8181+engine: microvm
8282+image: nixos
8383+8484+dependencies:
8585+ - devenv
8686+ - docker-client
8787+8888+virtualisation:
8989+ docker: true
9090+9191+steps:
9292+ - name: "Initialize devenv"
9393+ command: devenv shell true
9494+```
9595+9696+MicroVM details:
9797+- `image: nixos` enables NixOS-level workflow config: `dependencies`,
9898+ `registry`, `caches`, `services`, and `virtualisation`.
9999+- `dependencies` is a flat list. Bare names come from nixpkgs. Flake attrs can
100100+ use `flakeref#attr`.
101101+- `registry` can pin/alias flakes, e.g. `nixpkgs: github:nixos/nixpkgs/nixos-unstable`.
102102+- `caches` maps binary cache URL to trusted public key.
103103+- `services` and `virtualisation` are passed through to NixOS. `true` is shorthand
104104+ for `.enable = true` when an enable option exists.
105105+- Use `virtualisation: { docker: true }` when workflow steps need a real Docker
106106+ daemon, Docker Compose, or `docker buildx`.
107107+- `image: alpine` is useful for small checks, but NixOS-specific fields do not
108108+ apply there; install packages in steps with Alpine's package manager instead.
109109+110110+PostgreSQL service pattern:
111111+112112+```yaml
113113+environment:
114114+ DATABASE_URL: "postgresql:///spindle-workflow?host=/run/postgresql"
115115+services:
116116+ postgresql:
117117+ enable: true
118118+ ensureDatabases: ["spindle-workflow"]
119119+ ensureUsers:
120120+ - name: spindle-workflow
121121+ ensureDBOwnership: true
122122+```
123123+124124+The workflow user is `spindle-workflow`; naming the database/user this way makes
125125+Postgres peer auth work over the local socket.
126126+127127+### nixery engine
128128+129129+Legacy Nixery workflows use a dependency map rather than the microVM flat list:
130130+131131+```yaml
132132+engine: nixery
133133+dependencies:
134134+ nixpkgs:
135135+ - nodejs
136136+ - go
137137+```
138138+139139+When converting Nixery to microVM, change `engine: microvm`, choose `image`, and
140140+convert dependencies to the microVM form.
141141+142142+## Debugging Spindle runs
143143+144144+When `git push` prints a remote hint like:
145145+146146+```text
147147+remote: → Browse CI logs in your terminal:
148148+remote: ssh -t -p 3333 tangled.org DID PIPELINE_OR_SHA
149149+```
150150+151151+use the exact SSH command shown by the remote when the user wants terminal logs.
152152+It may attach to a live run, so use a timeout in non-interactive tool contexts,
153153+e.g.:
154154+155155+```bash
156156+timeout 180 ssh -t -p 3333 tangled.org <did> <pipeline-or-sha>
157157+```
158158+159159+Debug checklist:
160160+1. Identify which workflow file ran and which trigger matched.
161161+2. Check whether the failure happened during clone, microVM/NixOS activation,
162162+ dependency realization, service startup, or a named step.
163163+3. For command failures, reproduce locally with the same working directory and
164164+ environment shape when possible (`devenv shell ...` in this repo).
165165+4. For Docker failures in microVM, verify `virtualisation.docker: true` and that
166166+ the step waits for/builds the right image (`--load` if later Compose steps use
167167+ the local Docker daemon).
168168+5. For missing tools, add them to microVM `dependencies` or install them in the
169169+ step for non-NixOS images.
170170+6. For slow Nix setup, prefer cache configuration and consult the repo's Cachix
171171+ workflow guidance before changing Nix/devenv files.
172172+173173+## RSSBase conventions observed
174174+175175+Current repo workflows use:
176176+- `.tangled/workflows/check.yml` for Dagger checks via `devenv shell dagger check`.
177177+- `.tangled/workflows/build.yml` for production Docker/Compose smoke tests.
178178+- `engine: microvm`, `image: nixos`, `dependencies: [devenv]`, shallow clone,
179179+ and `virtualisation.docker: true` for production image builds.
180180+181181+Keep commands wrapped in `devenv shell -- bash -c '...'` when the step depends on
182182+project-provided tools or environment. Prefer fixing the smallest workflow file
183183+necessary rather than reshaping the whole CI setup.
184184+185185+## Reference docs
186186+187187+Primary docs:
188188+- https://docs.tangled.org/spindles
189189+- https://blog.tangled.org/spindle-microvm/
190190+191191+Read `references/spindles-quick-reference.md` for a condensed local reference if
192192+network access is unavailable or you need examples without loading the full docs.
+26
.agents/skills/spindle/evals/evals.json
···11+{
22+ "skill_name": "spindle",
33+ "evals": [
44+ {
55+ "id": 0,
66+ "prompt": "Please review our Tangled CI setup in .tangled/workflows. The build workflow is supposed to build a production Docker image and then run docker compose smoke tests on main and PRs. Tell me if the Spindle YAML looks right and suggest minimal fixes if not.",
77+ "expected_output": "Inspects .tangled/workflows, recognizes Spindle/Tangled CI, explains microvm+nixos+docker requirements, preserves repo conventions, and proposes focused workflow fixes rather than broad rewrites.",
88+ "files": [
99+ ".tangled/workflows/build.yml",
1010+ ".tangled/workflows/check.yml"
1111+ ]
1212+ },
1313+ {
1414+ "id": 1,
1515+ "prompt": "git push printed this: remote: → Browse CI logs in your terminal: remote: ssh -t -p 3333 tangled.org did:plc:limfqbuucqmoafpeuc7diz6c 8f22c8f46d9b1b3b17ee4bf8138c38631c4cccf8. What should I do to debug the failed pipeline?",
1616+ "expected_output": "Recognizes the command as Spindle terminal logs, advises using the exact ssh command (with timeout if non-interactive), then gives a practical CI failure triage checklist.",
1717+ "files": []
1818+ },
1919+ {
2020+ "id": 2,
2121+ "prompt": "Add a new Tangled workflow for a Rust service that needs Postgres and OpenSSL. It should run on PRs to main and pushes to main, use the microvm engine, and keep secrets out of the YAML.",
2222+ "expected_output": "Creates or proposes a .tangled/workflows YAML using engine: microvm, image: nixos, flat dependencies, services.postgresql with spindle-workflow peer auth, DATABASE_URL over /run/postgresql, and no secrets in public environment.",
2323+ "files": []
2424+ }
2525+ ]
2626+}
···11+# Reference
22+33+# Controller Cleanup
44+55+## The Problem: Fat Controllers
66+77+```php
88+// BAD: Fat controller with too much logic
99+#[Route('/orders', methods: ['POST'])]
1010+public function create(Request $request): Response
1111+{
1212+ $data = json_decode($request->getContent(), true);
1313+1414+ // Validation logic in controller
1515+ if (empty($data['items'])) {
1616+ return new JsonResponse(['error' => 'Items required'], 400);
1717+ }
1818+1919+ // Business logic in controller
2020+ $order = new Order();
2121+ $order->setCustomer($this->getUser());
2222+ $order->setStatus('pending');
2323+2424+ $total = 0;
2525+ foreach ($data['items'] as $itemData) {
2626+ $product = $this->em->find(Product::class, $itemData['productId']);
2727+ if (!$product) {
2828+ return new JsonResponse(['error' => 'Product not found'], 400);
2929+ }
3030+3131+ if ($product->getStock() < $itemData['quantity']) {
3232+ return new JsonResponse(['error' => 'Insufficient stock'], 400);
3333+ }
3434+3535+ $item = new OrderItem();
3636+ $item->setProduct($product);
3737+ $item->setQuantity($itemData['quantity']);
3838+ $item->setPrice($product->getPrice());
3939+ $order->addItem($item);
4040+4141+ $total += $product->getPrice() * $itemData['quantity'];
4242+ $product->setStock($product->getStock() - $itemData['quantity']);
4343+ }
4444+4545+ $order->setTotal($total);
4646+4747+ // Coupon logic
4848+ if (!empty($data['coupon'])) {
4949+ $coupon = $this->em->getRepository(Coupon::class)
5050+ ->findOneBy(['code' => $data['coupon']]);
5151+ if ($coupon && $coupon->isValid()) {
5252+ $discount = $total * ($coupon->getDiscount() / 100);
5353+ $order->setDiscount($discount);
5454+ $order->setTotal($total - $discount);
5555+ }
5656+ }
5757+5858+ $this->em->persist($order);
5959+ $this->em->flush();
6060+6161+ // Send email
6262+ $email = (new Email())
6363+ ->to($this->getUser()->getEmail())
6464+ ->subject('Order Confirmation')
6565+ ->text('Your order has been placed.');
6666+ $this->mailer->send($email);
6767+6868+ return new JsonResponse(['id' => $order->getId()], 201);
6969+}
7070+```
7171+7272+## The Solution: Lean Controller
7373+7474+### Step 1: Extract to Service
7575+7676+```php
7777+<?php
7878+// src/Service/OrderService.php
7979+8080+namespace App\Service;
8181+8282+use App\Dto\CreateOrderRequest;
8383+use App\Entity\Order;
8484+use App\Entity\User;
8585+8686+class OrderService
8787+{
8888+ public function __construct(
8989+ private ProductService $products,
9090+ private CouponService $coupons,
9191+ private EntityManagerInterface $em,
9292+ private OrderNotificationService $notifications,
9393+ ) {}
9494+9595+ public function createOrder(User $user, CreateOrderRequest $request): Order
9696+ {
9797+ // Validate and reserve products
9898+ $items = $this->products->reserveItems($request->items);
9999+100100+ // Create order
101101+ $order = Order::create($user, $items);
102102+103103+ // Apply coupon if provided
104104+ if ($request->couponCode) {
105105+ $discount = $this->coupons->apply($request->couponCode, $order);
106106+ $order->applyDiscount($discount);
107107+ }
108108+109109+ $this->em->persist($order);
110110+ $this->em->flush();
111111+112112+ // Async notification
113113+ $this->notifications->orderCreated($order);
114114+115115+ return $order;
116116+ }
117117+}
118118+```
119119+120120+### Step 2: Use DTOs for Input
121121+122122+```php
123123+<?php
124124+// src/Dto/CreateOrderRequest.php
125125+126126+namespace App\Dto;
127127+128128+use Symfony\Component\Validator\Constraints as Assert;
129129+130130+final readonly class CreateOrderRequest
131131+{
132132+ public function __construct(
133133+ #[Assert\NotBlank]
134134+ #[Assert\Count(min: 1)]
135135+ #[Assert\Valid]
136136+ public array $items,
137137+138138+ public ?string $couponCode = null,
139139+ ) {}
140140+}
141141+```
142142+143143+### Step 3: Lean Controller
144144+145145+```php
146146+<?php
147147+// src/Controller/Api/OrderController.php
148148+149149+namespace App\Controller\Api;
150150+151151+use App\Dto\CreateOrderRequest;
152152+use App\Service\OrderService;
153153+use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
154154+use Symfony\Component\HttpFoundation\JsonResponse;
155155+use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;
156156+use Symfony\Component\Routing\Attribute\Route;
157157+158158+#[Route('/api/orders')]
159159+class OrderController extends AbstractController
160160+{
161161+ public function __construct(
162162+ private OrderService $orderService,
163163+ ) {}
164164+165165+ #[Route('', methods: ['POST'])]
166166+ public function create(
167167+ #[MapRequestPayload] CreateOrderRequest $request
168168+ ): JsonResponse {
169169+ $order = $this->orderService->createOrder(
170170+ $this->getUser(),
171171+ $request
172172+ );
173173+174174+ return new JsonResponse(['id' => $order->getId()], 201);
175175+ }
176176+}
177177+```
178178+179179+## Controller Patterns
180180+181181+### Maximum 5-10 Lines Per Action
182182+183183+```php
184184+#[Route('/posts/{id}', methods: ['PUT'])]
185185+public function update(
186186+ Post $post,
187187+ #[MapRequestPayload] UpdatePostRequest $request
188188+): JsonResponse {
189189+ $this->denyAccessUnlessGranted('EDIT', $post);
190190+191191+ $post = $this->postService->update($post, $request);
192192+193193+ return new JsonResponse(PostOutput::fromEntity($post));
194194+}
195195+```
196196+197197+### Use Attributes for Common Tasks
198198+199199+```php
200200+use Symfony\Component\Security\Http\Attribute\IsGranted;
201201+202202+#[Route('/admin/users')]
203203+#[IsGranted('ROLE_ADMIN')]
204204+class AdminUserController extends AbstractController
205205+{
206206+ #[Route('', methods: ['GET'])]
207207+ public function list(): Response
208208+ {
209209+ // Already authorized by class attribute
210210+ }
211211+}
212212+```
213213+214214+### MapRequestPayload for Input
215215+216216+```php
217217+#[Route('/contact', methods: ['POST'])]
218218+public function contact(
219219+ #[MapRequestPayload] ContactRequest $request
220220+): JsonResponse {
221221+ // $request is already validated
222222+ $this->contactService->send($request);
223223+224224+ return new JsonResponse(['status' => 'sent']);
225225+}
226226+```
227227+228228+### EntityValueResolver for Entities
229229+230230+The legacy SensioFrameworkExtraBundle `ParamConverter` is gone; Doctrine's
231231+built-in `EntityValueResolver` maps route parameters to entities automatically.
232232+233233+```php
234234+// {id} is resolved to the Post entity by the EntityValueResolver
235235+#[Route('/posts/{id}', methods: ['GET'])]
236236+public function show(Post $post): Response
237237+{
238238+ // 404 handled automatically if not found
239239+ return $this->render('post/show.html.twig', ['post' => $post]);
240240+}
241241+```
242242+243243+Use `#[MapEntity]` to control the lookup (non-id field, custom expression, etc.):
244244+245245+```php
246246+use Symfony\Bridge\Doctrine\Attribute\MapEntity;
247247+248248+#[Route('/posts/{slug}', methods: ['GET'])]
249249+public function showBySlug(
250250+ #[MapEntity(mapping: ['slug' => 'slug'])] Post $post,
251251+): Response {
252252+ return $this->render('post/show.html.twig', ['post' => $post]);
253253+}
254254+```
255255+256256+### Inject per-argument, never pull from the container
257257+258258+Type-hint the services you need as action arguments (or constructor args). Don't
259259+reach into the container with `$this->container->get(...)` / `$this->get(...)` —
260260+that hides dependencies and breaks autowiring/testing.
261261+262262+```php
263263+// GOOD — explicit, autowired, testable
264264+#[Route('/reports', methods: ['GET'])]
265265+public function reports(ReportBuilder $reports): Response
266266+{
267267+ return $this->json($reports->forCurrentUser($this->getUser()));
268268+}
269269+270270+// BAD
271271+// $reports = $this->container->get(ReportBuilder::class);
272272+```
273273+274274+### Console: invokable commands (no base class)
275275+276276+The same "thin entry point + service" discipline applies to commands. Since
277277+Symfony 7.3 an `#[AsCommand]` class with `__invoke()` no longer needs to extend
278278+`Command`:
279279+280280+```php
281281+use Symfony\Component\Console\Attribute\Argument;
282282+use Symfony\Component\Console\Attribute\AsCommand;
283283+use Symfony\Component\Console\Command\Command;
284284+use Symfony\Component\Console\Style\SymfonyStyle;
285285+286286+#[AsCommand(name: 'app:create-user', description: 'Creates a user.')]
287287+final class CreateUserCommand
288288+{
289289+ public function __construct(private UserService $users) {}
290290+291291+ public function __invoke(
292292+ SymfonyStyle $io,
293293+ #[Argument('The username.')] string $username,
294294+ ): int {
295295+ $this->users->create($username);
296296+ $io->success("Created {$username}");
297297+298298+ return Command::SUCCESS;
299299+ }
300300+}
301301+```
302302+303303+## Extract Responsibilities
304304+305305+### Validation → DTO + Validator
306306+307307+```php
308308+// DTO handles validation rules
309309+final readonly class CreateUserRequest
310310+{
311311+ #[Assert\NotBlank]
312312+ #[Assert\Email]
313313+ public string $email;
314314+315315+ #[Assert\NotBlank]
316316+ #[Assert\Length(min: 8)]
317317+ public string $password;
318318+}
319319+```
320320+321321+### Business Logic → Service
322322+323323+```php
324324+// Service handles business rules
325325+class UserService
326326+{
327327+ public function register(CreateUserRequest $request): User
328328+ {
329329+ $this->ensureEmailUnique($request->email);
330330+ $user = User::register($request->email, $request->password);
331331+ $this->em->persist($user);
332332+ $this->em->flush();
333333+ return $user;
334334+ }
335335+}
336336+```
337337+338338+### Authorization → Voter
339339+340340+```php
341341+// Voter handles access control
342342+class PostVoter extends Voter
343343+{
344344+ protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
345345+ {
346346+ return match ($attribute) {
347347+ 'EDIT' => $subject->getAuthor() === $token->getUser(),
348348+ default => false,
349349+ };
350350+ }
351351+}
352352+```
353353+354354+### Notifications → Events/Messages
355355+356356+```php
357357+// Async via Messenger
358358+class OrderService
359359+{
360360+ public function create(CreateOrderRequest $request): Order
361361+ {
362362+ // ... create order
363363+ $this->bus->dispatch(new SendOrderConfirmation($order->getId()));
364364+ return $order;
365365+ }
366366+}
367367+```
368368+369369+## Testing Lean Controllers
370370+371371+```php
372372+class OrderControllerTest extends WebTestCase
373373+{
374374+ public function testCreateOrder(): void
375375+ {
376376+ $user = UserFactory::createOne();
377377+ ProductFactory::createMany(3);
378378+379379+ $this->client->loginUser($user->object());
380380+ $this->client->request('POST', '/api/orders', [], [], [
381381+ 'CONTENT_TYPE' => 'application/json',
382382+ ], json_encode([
383383+ 'items' => [
384384+ ['productId' => 1, 'quantity' => 2],
385385+ ],
386386+ ]));
387387+388388+ $this->assertResponseStatusCodeSame(201);
389389+ }
390390+}
391391+```
392392+393393+## Checklist
394394+395395+- [ ] Controller actions ≤ 10 lines
396396+- [ ] No `new Entity()` in controller
397397+- [ ] No direct EntityManager usage
398398+- [ ] Use DTOs for input
399399+- [ ] Use services for business logic
400400+- [ ] Use voters for authorization
401401+- [ ] Use events/messages for side effects
402402+403403+404404+## Skill Operating Checklist
405405+406406+### Design checklist
407407+- Confirm operation boundaries and invariants first.
408408+- Minimize scope while preserving contract correctness.
409409+- Test both happy path and negative path behavior.
410410+411411+### Validation commands
412412+- rg --files
413413+- composer validate
414414+- ./vendor/bin/phpstan analyse
415415+416416+### Failure modes to test
417417+- Invalid payload or forbidden actor.
418418+- Boundary values / not-found cases.
419419+- Retry or partial-failure behavior for async flows.
420420+
+39
.agents/skills/symfony-cqrs-and-handlers/SKILL.md
···11+---
22+33+name: symfony-cqrs-and-handlers
44+allowed-tools:
55+ - Read
66+ - Glob
77+ - Grep
88+description: Implement CQRS in Symfony with separate Command and Query buses/handlers using the Messenger component
99+---
1010+1111+# Cqrs And Handlers (Symfony)
1212+1313+## Use when
1414+- Refining architecture/workflows/context handling in Symfony projects.
1515+- Planning and executing medium/complex changes safely.
1616+1717+## Default workflow
1818+1. Establish current boundaries, constraints, and coupling points.
1919+2. Propose smallest coherent architectural adjustment.
2020+3. Execute in checkpoints with validation at each stage.
2121+4. Summarize tradeoffs and follow-up backlog.
2222+2323+## Guardrails
2424+- Use existing project patterns by default.
2525+- Avoid broad refactors without explicit need.
2626+- Keep decision log clear and auditable.
2727+2828+## Progressive disclosure
2929+- Use this file for execution posture and risk controls.
3030+- Open references when deep implementation details are needed.
3131+3232+## Output contract
3333+- Architecture/workflow changes.
3434+- Checkpoint validation outcomes.
3535+- Residual risks and next steps.
3636+3737+## References
3838+- `reference.md`
3939+- `docs/complexity-tiers.md`
···11+# Reference
22+33+# CQRS with Symfony Messenger
44+55+## Overview
66+77+CQRS (Command Query Responsibility Segregation) separates read and write operations:
88+- **Commands**: Change state (Create, Update, Delete)
99+- **Queries**: Read state (no side effects)
1010+1111+## Project Structure
1212+1313+```
1414+src/
1515+├── Application/
1616+│ ├── Command/
1717+│ │ ├── CreateOrder.php
1818+│ │ └── CreateOrderHandler.php
1919+│ └── Query/
2020+│ ├── GetOrder.php
2121+│ └── GetOrderHandler.php
2222+├── Domain/
2323+│ └── Order/
2424+│ └── Entity/Order.php
2525+└── Infrastructure/
2626+ └── Controller/
2727+ └── OrderController.php
2828+```
2929+3030+## Commands
3131+3232+### Command Class
3333+3434+```php
3535+<?php
3636+// src/Application/Command/CreateOrder.php
3737+3838+namespace App\Application\Command;
3939+4040+final readonly class CreateOrder
4141+{
4242+ public function __construct(
4343+ public int $customerId,
4444+ public array $items,
4545+ public ?string $couponCode = null,
4646+ ) {}
4747+}
4848+```
4949+5050+### Command Handler
5151+5252+```php
5353+<?php
5454+// src/Application/Command/CreateOrderHandler.php
5555+5656+namespace App\Application\Command;
5757+5858+use App\Domain\Order\Entity\Order;
5959+use App\Domain\Order\Repository\OrderRepositoryInterface;
6060+use Symfony\Component\Messenger\Attribute\AsMessageHandler;
6161+6262+#[AsMessageHandler]
6363+final readonly class CreateOrderHandler
6464+{
6565+ public function __construct(
6666+ private OrderRepositoryInterface $orders,
6767+ private ProductService $products,
6868+ private CouponService $coupons,
6969+ ) {}
7070+7171+ public function __invoke(CreateOrder $command): Order
7272+ {
7373+ // Validate products exist
7474+ $items = $this->products->resolveItems($command->items);
7575+7676+ // Create order
7777+ $order = Order::create(
7878+ $this->orders->nextId(),
7979+ $command->customerId,
8080+ );
8181+8282+ foreach ($items as $item) {
8383+ $order->addItem($item);
8484+ }
8585+8686+ // Apply coupon if provided
8787+ if ($command->couponCode) {
8888+ $discount = $this->coupons->apply($command->couponCode, $order);
8989+ $order->applyDiscount($discount);
9090+ }
9191+9292+ $this->orders->save($order);
9393+9494+ return $order;
9595+ }
9696+}
9797+```
9898+9999+## Queries
100100+101101+### Query Class
102102+103103+```php
104104+<?php
105105+// src/Application/Query/GetOrder.php
106106+107107+namespace App\Application\Query;
108108+109109+final readonly class GetOrder
110110+{
111111+ public function __construct(
112112+ public string $orderId,
113113+ ) {}
114114+}
115115+116116+// src/Application/Query/GetOrdersByCustomer.php
117117+118118+final readonly class GetOrdersByCustomer
119119+{
120120+ public function __construct(
121121+ public int $customerId,
122122+ public int $page = 1,
123123+ public int $limit = 20,
124124+ ) {}
125125+}
126126+```
127127+128128+### Query Handler
129129+130130+```php
131131+<?php
132132+// src/Application/Query/GetOrderHandler.php
133133+134134+namespace App\Application\Query;
135135+136136+use App\Domain\Order\Repository\OrderRepositoryInterface;
137137+use App\Dto\OrderView;
138138+use Symfony\Component\Messenger\Attribute\AsMessageHandler;
139139+140140+#[AsMessageHandler]
141141+final readonly class GetOrderHandler
142142+{
143143+ public function __construct(
144144+ private OrderRepositoryInterface $orders,
145145+ ) {}
146146+147147+ public function __invoke(GetOrder $query): ?OrderView
148148+ {
149149+ $order = $this->orders->findById($query->orderId);
150150+151151+ if (!$order) {
152152+ return null;
153153+ }
154154+155155+ return OrderView::fromEntity($order);
156156+ }
157157+}
158158+159159+// src/Application/Query/GetOrdersByCustomerHandler.php
160160+161161+#[AsMessageHandler]
162162+final readonly class GetOrdersByCustomerHandler
163163+{
164164+ public function __construct(
165165+ private OrderReadRepository $readRepository,
166166+ ) {}
167167+168168+ public function __invoke(GetOrdersByCustomer $query): PaginatedResult
169169+ {
170170+ return $this->readRepository->findByCustomer(
171171+ $query->customerId,
172172+ $query->page,
173173+ $query->limit,
174174+ );
175175+ }
176176+}
177177+```
178178+179179+## Separate Buses
180180+181181+### Configuration
182182+183183+```yaml
184184+# config/packages/messenger.yaml
185185+framework:
186186+ messenger:
187187+ default_bus: command_bus
188188+189189+ buses:
190190+ command_bus:
191191+ middleware:
192192+ - validation
193193+ - doctrine_transaction
194194+195195+ query_bus:
196196+ middleware:
197197+ - validation
198198+199199+ # Route by namespace so commands/queries land on the right bus.
200200+ routing:
201201+ 'App\Application\Command\*': command_bus
202202+ 'App\Application\Query\*': query_bus
203203+```
204204+205205+### Injecting the native buses directly
206206+207207+You don't need the wrapper interfaces below — Symfony registers each bus as
208208+`messenger.bus.<name>`. Inject them with `#[Autowire]` and type
209209+`MessageBusInterface`:
210210+211211+```php
212212+use Symfony\Component\DependencyInjection\Attribute\Autowire;
213213+use Symfony\Component\Messenger\HandleTrait;
214214+use Symfony\Component\Messenger\MessageBusInterface;
215215+216216+class OrderController extends AbstractController
217217+{
218218+ public function __construct(
219219+ #[Autowire('@messenger.bus.command_bus')]
220220+ private MessageBusInterface $commandBus,
221221+ #[Autowire('@messenger.bus.query_bus')]
222222+ private MessageBusInterface $queryBus,
223223+ ) {}
224224+}
225225+```
226226+227227+To get the handler's return value synchronously (queries), use `HandleTrait`
228228+in a thin service as shown in the next section.
229229+230230+### Bus Interfaces
231231+232232+```php
233233+<?php
234234+// src/Application/Bus/CommandBusInterface.php
235235+236236+namespace App\Application\Bus;
237237+238238+interface CommandBusInterface
239239+{
240240+ public function dispatch(object $command): mixed;
241241+}
242242+243243+// src/Application/Bus/QueryBusInterface.php
244244+245245+interface QueryBusInterface
246246+{
247247+ public function ask(object $query): mixed;
248248+}
249249+```
250250+251251+### Implementations
252252+253253+```php
254254+<?php
255255+// src/Infrastructure/Bus/MessengerCommandBus.php
256256+257257+namespace App\Infrastructure\Bus;
258258+259259+use App\Application\Bus\CommandBusInterface;
260260+use Symfony\Component\Messenger\HandleTrait;
261261+use Symfony\Component\Messenger\MessageBusInterface;
262262+263263+final class MessengerCommandBus implements CommandBusInterface
264264+{
265265+ use HandleTrait;
266266+267267+ public function __construct(MessageBusInterface $commandBus)
268268+ {
269269+ $this->messageBus = $commandBus;
270270+ }
271271+272272+ public function dispatch(object $command): mixed
273273+ {
274274+ return $this->handle($command);
275275+ }
276276+}
277277+278278+// src/Infrastructure/Bus/MessengerQueryBus.php
279279+280280+final class MessengerQueryBus implements QueryBusInterface
281281+{
282282+ use HandleTrait;
283283+284284+ public function __construct(MessageBusInterface $queryBus)
285285+ {
286286+ $this->messageBus = $queryBus;
287287+ }
288288+289289+ public function ask(object $query): mixed
290290+ {
291291+ return $this->handle($query);
292292+ }
293293+}
294294+```
295295+296296+### Service Configuration
297297+298298+```yaml
299299+# config/services.yaml
300300+services:
301301+ App\Application\Bus\CommandBusInterface:
302302+ class: App\Infrastructure\Bus\MessengerCommandBus
303303+ arguments: ['@command.bus']
304304+305305+ App\Application\Bus\QueryBusInterface:
306306+ class: App\Infrastructure\Bus\MessengerQueryBus
307307+ arguments: ['@query.bus']
308308+```
309309+310310+## Controller Usage
311311+312312+```php
313313+<?php
314314+// src/Infrastructure/Controller/OrderController.php
315315+316316+namespace App\Infrastructure\Controller;
317317+318318+use App\Application\Bus\CommandBusInterface;
319319+use App\Application\Bus\QueryBusInterface;
320320+use App\Application\Command\CreateOrder;
321321+use App\Application\Query\GetOrder;
322322+use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
323323+use Symfony\Component\HttpFoundation\JsonResponse;
324324+use Symfony\Component\HttpFoundation\Request;
325325+use Symfony\Component\Routing\Attribute\Route;
326326+327327+#[Route('/api/orders')]
328328+class OrderController extends AbstractController
329329+{
330330+ public function __construct(
331331+ private CommandBusInterface $commandBus,
332332+ private QueryBusInterface $queryBus,
333333+ ) {}
334334+335335+ #[Route('', methods: ['POST'])]
336336+ public function create(Request $request): JsonResponse
337337+ {
338338+ $data = json_decode($request->getContent(), true);
339339+340340+ $order = $this->commandBus->dispatch(new CreateOrder(
341341+ customerId: $data['customerId'],
342342+ items: $data['items'],
343343+ couponCode: $data['couponCode'] ?? null,
344344+ ));
345345+346346+ return new JsonResponse(['id' => $order->getId()], 201);
347347+ }
348348+349349+ #[Route('/{id}', methods: ['GET'])]
350350+ public function show(string $id): JsonResponse
351351+ {
352352+ $order = $this->queryBus->ask(new GetOrder($id));
353353+354354+ if (!$order) {
355355+ throw $this->createNotFoundException();
356356+ }
357357+358358+ return new JsonResponse($order);
359359+ }
360360+}
361361+```
362362+363363+## Read Models (Optional)
364364+365365+For complex reads, use dedicated read models:
366366+367367+```php
368368+<?php
369369+// src/Infrastructure/ReadModel/OrderReadRepository.php
370370+371371+namespace App\Infrastructure\ReadModel;
372372+373373+use Doctrine\DBAL\Connection;
374374+375375+class OrderReadRepository
376376+{
377377+ public function __construct(
378378+ private Connection $connection,
379379+ ) {}
380380+381381+ public function findByCustomer(int $customerId, int $page, int $limit): PaginatedResult
382382+ {
383383+ // Direct SQL for optimized reads
384384+ $sql = <<<SQL
385385+ SELECT o.id, o.total, o.status, o.created_at,
386386+ COUNT(i.id) as item_count
387387+ FROM orders o
388388+ LEFT JOIN order_items i ON i.order_id = o.id
389389+ WHERE o.customer_id = :customerId
390390+ GROUP BY o.id
391391+ ORDER BY o.created_at DESC
392392+ LIMIT :limit OFFSET :offset
393393+ SQL;
394394+395395+ $results = $this->connection->fetchAllAssociative($sql, [
396396+ 'customerId' => $customerId,
397397+ 'limit' => $limit,
398398+ 'offset' => ($page - 1) * $limit,
399399+ ]);
400400+401401+ return new PaginatedResult($results, $this->countByCustomer($customerId));
402402+ }
403403+}
404404+```
405405+406406+## Best Practices
407407+408408+1. **Commands change state**: Never return data from commands (except ID)
409409+2. **Queries are side-effect free**: Can be cached, retried
410410+3. **Separate handlers**: One handler per command/query
411411+4. **Validation in commands**: Use Symfony Validator
412412+5. **Read models for complex queries**: Optimize separately
413413+6. **Transaction on commands**: Wrap in database transaction
414414+415415+416416+## Skill Operating Checklist
417417+418418+### Design checklist
419419+- Confirm operation boundaries and invariants first.
420420+- Minimize scope while preserving contract correctness.
421421+- Test both happy path and negative path behavior.
422422+423423+### Validation commands
424424+- rg --files
425425+- composer validate
426426+- ./vendor/bin/phpstan analyse
427427+428428+### Failure modes to test
429429+- Invalid payload or forbidden actor.
430430+- Boundary values / not-found cases.
431431+- Retry or partial-failure behavior for async flows.
432432+
+39
.agents/skills/symfony-daily-workflow/SKILL.md
···11+---
22+33+name: symfony-daily-workflow
44+allowed-tools:
55+ - Read
66+ - Glob
77+ - Grep
88+description: Daily development workflow for Symfony projects including common tasks, debugging, and productivity tips
99+---
1010+1111+# Daily Workflow (Symfony)
1212+1313+## Use when
1414+- Refining architecture/workflows/context handling in Symfony projects.
1515+- Planning and executing medium/complex changes safely.
1616+1717+## Default workflow
1818+1. Establish current boundaries, constraints, and coupling points.
1919+2. Propose smallest coherent architectural adjustment.
2020+3. Execute in checkpoints with validation at each stage.
2121+4. Summarize tradeoffs and follow-up backlog.
2222+2323+## Guardrails
2424+- Use existing project patterns by default.
2525+- Avoid broad refactors without explicit need.
2626+- Keep decision log clear and auditable.
2727+2828+## Progressive disclosure
2929+- Use this file for execution posture and risk controls.
3030+- Open references when deep implementation details are needed.
3131+3232+## Output contract
3333+- Architecture/workflow changes.
3434+- Checkpoint validation outcomes.
3535+- Residual risks and next steps.
3636+3737+## References
3838+- `reference.md`
3939+- `docs/complexity-tiers.md`
···11+# Reference
22+33+# Doctrine Batch Processing
44+55+## The Problem
66+77+```php
88+// BAD: Loads all entities into memory
99+$products = $repository->findAll();
1010+foreach ($products as $product) {
1111+ $this->process($product);
1212+}
1313+// Out of Memory with large datasets!
1414+```
1515+1616+## Solution 1: Iterate with toIterable()
1717+1818+```php
1919+<?php
2020+2121+// GOOD: Process one at a time
2222+$query = $em->createQuery('SELECT p FROM Product p');
2323+2424+foreach ($query->toIterable() as $product) {
2525+ $this->process($product);
2626+2727+ // Clear managed entities periodically
2828+ $em->clear();
2929+}
3030+```
3131+3232+> **ORM 3 breaking change**: `Query#iterate()` was deprecated in ORM 2.7 and
3333+> **removed in ORM 3.0**. Use `toIterable()` (available since 2.7, the only
3434+> option in 3.x/4.x). You cannot iterate a query that fetch-joins a
3535+> collection-valued association.
3636+3737+## Solution 2: Batch with Clear
3838+3939+```php
4040+<?php
4141+4242+const BATCH_SIZE = 100;
4343+4444+$query = $em->createQuery('SELECT p FROM Product p');
4545+$i = 0;
4646+4747+foreach ($query->toIterable() as $product) {
4848+ $product->setProcessedAt(new \DateTimeImmutable());
4949+ $i++;
5050+5151+ if ($i % self::BATCH_SIZE === 0) {
5252+ $em->flush();
5353+ $em->clear();
5454+ gc_collect_cycles();
5555+ }
5656+}
5757+5858+// Flush remaining
5959+$em->flush();
6060+$em->clear();
6161+```
6262+6363+## Solution 3: ID-Based Pagination
6464+6565+```php
6666+<?php
6767+6868+class BatchProcessor
6969+{
7070+ private const BATCH_SIZE = 1000;
7171+7272+ public function processAll(): void
7373+ {
7474+ $lastId = 0;
7575+7676+ while (true) {
7777+ $products = $this->em->createQueryBuilder()
7878+ ->select('p')
7979+ ->from(Product::class, 'p')
8080+ ->where('p.id > :lastId')
8181+ ->setParameter('lastId', $lastId)
8282+ ->orderBy('p.id', 'ASC')
8383+ ->setMaxResults(self::BATCH_SIZE)
8484+ ->getQuery()
8585+ ->getResult();
8686+8787+ if (empty($products)) {
8888+ break;
8989+ }
9090+9191+ foreach ($products as $product) {
9292+ $this->process($product);
9393+ $lastId = $product->getId();
9494+ }
9595+9696+ $this->em->flush();
9797+ $this->em->clear();
9898+ }
9999+ }
100100+}
101101+```
102102+103103+## Solution 3b: DQL Bulk UPDATE / DELETE
104104+105105+For mass updates/deletes, a single DQL `UPDATE`/`DELETE` statement is the most
106106+efficient — it bypasses hydration and the unit of work entirely:
107107+108108+```php
109109+<?php
110110+111111+// Bulk update — runs one SQL UPDATE, no entities loaded
112112+$updated = $em->createQuery(
113113+ 'UPDATE App\Entity\Product p SET p.price = p.price * 0.9 WHERE p.discontinued = true'
114114+)->execute();
115115+116116+// Bulk delete
117117+$deleted = $em->createQuery(
118118+ 'DELETE App\Entity\Product p WHERE p.createdAt < :cutoff'
119119+)->setParameter('cutoff', new \DateTimeImmutable('-1 year'))
120120+ ->execute();
121121+```
122122+123123+DQL UPDATE/DELETE do **not** trigger lifecycle events or update already-managed
124124+objects in memory — clear or re-fetch afterwards if you keep working with them.
125125+126126+## Solution 4: DBAL for Bulk Updates
127127+128128+```php
129129+<?php
130130+131131+use Doctrine\DBAL\Connection;
132132+133133+class BulkUpdater
134134+{
135135+ public function __construct(
136136+ private Connection $connection,
137137+ ) {}
138138+139139+ public function markAllProcessed(): int
140140+ {
141141+ return $this->connection->executeStatement(
142142+ 'UPDATE product SET processed_at = NOW() WHERE processed_at IS NULL'
143143+ );
144144+ }
145145+146146+ public function updatePrices(array $updates): void
147147+ {
148148+ $this->connection->beginTransaction();
149149+150150+ try {
151151+ $stmt = $this->connection->prepare(
152152+ 'UPDATE product SET price = :price WHERE id = :id'
153153+ );
154154+155155+ foreach ($updates as $id => $price) {
156156+ $stmt->executeStatement(['id' => $id, 'price' => $price]);
157157+ }
158158+159159+ $this->connection->commit();
160160+ } catch (\Exception $e) {
161161+ $this->connection->rollBack();
162162+ throw $e;
163163+ }
164164+ }
165165+}
166166+```
167167+168168+## Solution 5: Bulk Insert
169169+170170+```php
171171+<?php
172172+173173+class BulkInserter
174174+{
175175+ private const BATCH_SIZE = 500;
176176+177177+ public function importProducts(array $data): void
178178+ {
179179+ // NOTE: in ORM 2.x you would call
180180+ // $this->em->getConnection()->getConfiguration()->setSQLLogger(null);
181181+ // to avoid memory growth. That method is DEPRECATED in DBAL 3+ and gone
182182+ // in DBAL 4 (logging moved to PSR-3 middleware). On ORM 3 / DBAL 4 you
183183+ // disable logging via configuration instead of this call.
184184+185185+ $batches = array_chunk($data, self::BATCH_SIZE);
186186+187187+ foreach ($batches as $batch) {
188188+ foreach ($batch as $item) {
189189+ $product = new Product();
190190+ $product->setName($item['name']);
191191+ $product->setPrice($item['price']);
192192+ $this->em->persist($product);
193193+ }
194194+195195+ $this->em->flush();
196196+ $this->em->clear();
197197+ }
198198+ }
199199+}
200200+```
201201+202202+## Memory Monitoring
203203+204204+```php
205205+<?php
206206+207207+class BatchProcessor
208208+{
209209+ public function process(): void
210210+ {
211211+ $startMemory = memory_get_usage();
212212+213213+ foreach ($query->toIterable() as $i => $entity) {
214214+ $this->processEntity($entity);
215215+216216+ if ($i % 100 === 0) {
217217+ $this->em->clear();
218218+219219+ $currentMemory = memory_get_usage();
220220+ $this->logger->info('Batch progress', [
221221+ 'processed' => $i,
222222+ 'memory_mb' => round($currentMemory / 1024 / 1024, 2),
223223+ 'memory_delta_mb' => round(($currentMemory - $startMemory) / 1024 / 1024, 2),
224224+ ]);
225225+ }
226226+ }
227227+ }
228228+}
229229+```
230230+231231+## Symfony Command for Batch Processing
232232+233233+```php
234234+<?php
235235+// src/Command/ProcessProductsCommand.php
236236+237237+#[AsCommand(name: 'app:process-products')]
238238+class ProcessProductsCommand extends Command
239239+{
240240+ private const BATCH_SIZE = 100;
241241+242242+ protected function execute(InputInterface $input, OutputInterface $output): int
243243+ {
244244+ $io = new SymfonyStyle($input, $output);
245245+246246+ $query = $this->em->createQuery('SELECT p FROM Product p WHERE p.processedAt IS NULL');
247247+ $total = $this->countUnprocessed();
248248+249249+ $io->progressStart($total);
250250+251251+ $processed = 0;
252252+ foreach ($query->toIterable() as $product) {
253253+ $this->processor->process($product);
254254+ $processed++;
255255+256256+ if ($processed % self::BATCH_SIZE === 0) {
257257+ $this->em->flush();
258258+ $this->em->clear();
259259+ $io->progressAdvance(self::BATCH_SIZE);
260260+ }
261261+ }
262262+263263+ $this->em->flush();
264264+ $io->progressFinish();
265265+266266+ $io->success("Processed {$processed} products");
267267+268268+ return Command::SUCCESS;
269269+ }
270270+}
271271+```
272272+273273+## Best Practices
274274+275275+1. **Clear regularly**: `$em->clear()` releases memory
276276+2. **Use toIterable()**: Don't load all results at once
277277+3. **DQL UPDATE/DELETE or DBAL for bulk writes**: Skip hydration for mass updates
278278+4. **Monitor memory**: Log memory usage in long processes
279279+5. **Disable SQL logging**: Via config/middleware on DBAL 4 (`setSQLLogger()` is gone)
280280+6. **Progress feedback**: Use SymfonyStyle progress bars
281281+282282+## Applicability
283283+284284+- **ORM 3.x / DBAL 4.x** (target): `toIterable()` only; `Query#iterate()` removed;
285285+ `setSQLLogger()` removed (PSR-3 middleware instead).
286286+- **ORM 2.7+ (legacy)**: `toIterable()` available alongside the deprecated
287287+ `iterate()`; prefer `toIterable()`.
288288+289289+290290+## Skill Operating Checklist
291291+292292+### Design checklist
293293+- Confirm operation boundaries and invariants first.
294294+- Minimize scope while preserving contract correctness.
295295+- Test both happy path and negative path behavior.
296296+297297+### Validation commands
298298+- php bin/console doctrine:migrations:diff
299299+- php bin/console doctrine:migrations:migrate
300300+- ./vendor/bin/phpunit --filter=Doctrine
301301+302302+### Failure modes to test
303303+- Invalid payload or forbidden actor.
304304+- Boundary values / not-found cases.
305305+- Retry or partial-failure behavior for async flows.
306306+
+44
.agents/skills/symfony-doctrine-events/SKILL.md
···11+---
22+33+name: symfony-doctrine-events
44+allowed-tools:
55+ - Read
66+ - Write
77+ - Edit
88+ - Bash
99+ - Glob
1010+ - Grep
1111+description: React to Doctrine entity lifecycle in Symfony with attribute listeners (#[AsDoctrineListener]/#[AsEntityListener], ORM 3) and lifecycle callbacks
1212+---
1313+1414+# Doctrine Events (Symfony)
1515+1616+## Use when
1717+- You need side effects on entity persistence (timestamps, slugs, search indexing, notifications).
1818+- Migrating an `EventSubscriberInterface` Doctrine subscriber to ORM 3.
1919+- Choosing between lifecycle callbacks, entity listeners, and global lifecycle listeners.
2020+2121+## Default workflow
2222+1. Pick the narrowest mechanism: callback (one entity, no deps) → entity listener (one entity, needs services) → lifecycle listener (cross-cutting, all entities).
2323+2. Wire it with attributes (`#[ORM\HasLifecycleCallbacks]`, `#[AsEntityListener]`, `#[AsDoctrineListener]`).
2424+3. Type the event argument by its per-event class (`PostPersistEventArgs`, `PreUpdateEventArgs`, …).
2525+4. Verify the side effect fires with a targeted functional test against a real DB.
2626+2727+## Guardrails
2828+- ORM 3.0 removed `EventSubscriberInterface` Doctrine subscribers — never reintroduce them.
2929+- Type-check the entity early in a global lifecycle listener (it fires for every entity).
3030+- Never call `flush()` inside a Doctrine event handler — it corrupts the in-flight unit of work.
3131+- `preUpdate` is restricted: change fields only via `$args->setNewValue()`, never touch associations.
3232+3333+## Progressive disclosure
3434+- Use this file for execution posture and risk controls.
3535+- Open `reference.md` for the three mechanisms, the event-args matrix, and migration from subscribers.
3636+3737+## Output contract
3838+- Listener/callback class with the correct attribute.
3939+- Justification for the chosen mechanism.
4040+- Validation outcomes (test that the side effect fired).
4141+4242+## References
4343+- `reference.md`
4444+- `docs/complexity-tiers.md`
···11+# Reference
22+33+# Doctrine Events (Symfony)
44+55+> **ORM 3 breaking change**: Doctrine `EventSubscriberInterface` subscribers were
66+> **removed in ORM 3.0**. The replacement is attribute-based listeners
77+> (`#[AsDoctrineListener]`, `#[AsEntityListener]`), available since
88+> **DoctrineBundle 2.8+**. Each event also gets its own typed argument class
99+> (`PostPersistEventArgs`, `PreUpdateEventArgs`, …) instead of the single
1010+> `LifecycleEventArgs` from ORM 2.x.
1111+1212+## Three mechanisms — which to pick
1313+1414+| Mechanism | Scope | Container services? | Cost | Use when |
1515+|-----------|-------|---------------------|------|----------|
1616+| **Lifecycle callbacks** | one entity, its own method | no | fastest | self-contained logic (timestamps, slug from own fields) |
1717+| **Entity listeners** | one entity class | yes (autowired) | medium | one entity needs a service (mailer, indexer) |
1818+| **Lifecycle listeners** | every entity | yes | slowest (called for all) | cross-cutting concerns (audit log, global timestamps) |
1919+2020+Rule of thumb: start with a **callback**; reach for an **entity listener** when
2121+you need a service; use a **global lifecycle listener** only for truly
2222+cross-cutting behavior, and type-check the entity first.
2323+2424+## 1. Lifecycle callbacks
2525+2626+A method on the entity itself. Requires `#[ORM\HasLifecycleCallbacks]` on the
2727+class.
2828+2929+```php
3030+<?php
3131+// src/Entity/Product.php
3232+3333+use Doctrine\ORM\Mapping as ORM;
3434+3535+#[ORM\Entity]
3636+#[ORM\HasLifecycleCallbacks]
3737+class Product
3838+{
3939+ #[ORM\Column]
4040+ private \DateTimeImmutable $createdAt;
4141+4242+ #[ORM\Column(nullable: true)]
4343+ private ?\DateTimeImmutable $updatedAt = null;
4444+4545+ #[ORM\PrePersist]
4646+ public function setCreatedAtValue(): void
4747+ {
4848+ $this->createdAt = new \DateTimeImmutable();
4949+ }
5050+5151+ #[ORM\PreUpdate]
5252+ public function setUpdatedAtValue(): void
5353+ {
5454+ $this->updatedAt = new \DateTimeImmutable();
5555+ }
5656+}
5757+```
5858+5959+Available callback attributes: `#[ORM\PrePersist]`, `#[ORM\PostPersist]`,
6060+`#[ORM\PreUpdate]`, `#[ORM\PostUpdate]`, `#[ORM\PreRemove]`,
6161+`#[ORM\PostRemove]`, `#[ORM\PostLoad]`.
6262+6363+Callbacks cannot receive constructor-injected services — keep them to logic that
6464+only touches the entity's own state.
6565+6666+## 2. Entity listeners (one entity, with services)
6767+6868+A separate class bound to one entity. It is a regular autowired service.
6969+7070+```php
7171+<?php
7272+// src/EntityListener/UserChangedNotifier.php
7373+7474+use App\Entity\User;
7575+use Doctrine\Bundle\DoctrineBundle\Attribute\AsEntityListener;
7676+use Doctrine\ORM\Event\PostUpdateEventArgs;
7777+use Doctrine\ORM\Events;
7878+7979+#[AsEntityListener(event: Events::postUpdate, method: 'postUpdate', entity: User::class)]
8080+final class UserChangedNotifier
8181+{
8282+ public function __construct(
8383+ private NotifierInterface $notifier,
8484+ ) {}
8585+8686+ public function postUpdate(User $user, PostUpdateEventArgs $event): void
8787+ {
8888+ // Fires only for User entities — no type-check needed.
8989+ $this->notifier->notifyAccountChange($user);
9090+ }
9191+}
9292+```
9393+9494+The handler signature is `(EntityType $entity, <Event>EventArgs $event)`. You
9595+can attach several `#[AsEntityListener]` attributes to one class for different
9696+events.
9797+9898+YAML equivalent (tag) if not using the attribute:
9999+100100+```yaml
101101+services:
102102+ App\EntityListener\UserChangedNotifier:
103103+ tags:
104104+ - { name: doctrine.orm.entity_listener, event: postUpdate, entity: App\Entity\User }
105105+```
106106+107107+## 3. Lifecycle listeners (all entities, with services)
108108+109109+A global listener invoked for **every** entity on the given event. Always
110110+type-check.
111111+112112+```php
113113+<?php
114114+// src/EventListener/SearchIndexer.php
115115+116116+use App\Entity\Product;
117117+use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
118118+use Doctrine\ORM\Event\PostPersistEventArgs;
119119+use Doctrine\ORM\Events;
120120+121121+#[AsDoctrineListener(event: Events::postPersist, priority: 500, connection: 'default')]
122122+final class SearchIndexer
123123+{
124124+ public function __construct(
125125+ private SearchClient $search,
126126+ ) {}
127127+128128+ public function postPersist(PostPersistEventArgs $args): void
129129+ {
130130+ $entity = $args->getObject();
131131+132132+ if (!$entity instanceof Product) {
133133+ return; // ignore everything that is not a Product
134134+ }
135135+136136+ $em = $args->getObjectManager();
137137+ $this->search->index($entity);
138138+ }
139139+}
140140+```
141141+142142+`priority` defaults to `0`; higher runs earlier. `connection` scopes the
143143+listener to one connection.
144144+145145+YAML equivalent:
146146+147147+```yaml
148148+services:
149149+ App\EventListener\SearchIndexer:
150150+ tags:
151151+ - { name: doctrine.event_listener, event: postPersist, priority: 500, connection: default }
152152+```
153153+154154+## Per-event argument classes (ORM 3)
155155+156156+The single `LifecycleEventArgs` of ORM 2 is replaced by one class per event,
157157+all in `Doctrine\ORM\Event`:
158158+159159+| Event (`Events::*`) | Argument class | Notes |
160160+|---------------------|----------------|-------|
161161+| `prePersist` | `PrePersistEventArgs` | before INSERT |
162162+| `postPersist` | `PostPersistEventArgs` | ID is available now |
163163+| `preUpdate` | `PreUpdateEventArgs` | change set only; see below |
164164+| `postUpdate` | `PostUpdateEventArgs` | after UPDATE |
165165+| `preRemove` | `PreRemoveEventArgs` | before DELETE |
166166+| `postRemove` | `PostRemoveEventArgs` | after DELETE |
167167+| `postLoad` | `PostLoadEventArgs` | after hydration |
168168+| `onFlush` | `OnFlushEventArgs` | whole unit of work |
169169+170170+Common accessors: `$args->getObject()` (the entity),
171171+`$args->getObjectManager()` (the `EntityManagerInterface`).
172172+173173+### preUpdate is special
174174+175175+`preUpdate` runs on a computed change set. You may only change already-changed
176176+fields, through the event API — never modify associations or call other
177177+entities' setters here, and never `flush()`:
178178+179179+```php
180180+use Doctrine\ORM\Event\PreUpdateEventArgs;
181181+182182+public function preUpdate(PreUpdateEventArgs $args): void
183183+{
184184+ if ($args->hasChangedField('price')) {
185185+ $old = $args->getOldValue('price');
186186+ $new = $args->getNewValue('price');
187187+ // adjust the value being written:
188188+ $args->setNewValue('price', max(0, $new));
189189+ }
190190+}
191191+```
192192+193193+## Migrating from an ORM 2 EventSubscriber
194194+195195+```php
196196+// BEFORE (ORM 2 — removed in ORM 3):
197197+class MySubscriber implements \Doctrine\Common\EventSubscriber
198198+{
199199+ public function getSubscribedEvents(): array
200200+ {
201201+ return [Events::postPersist];
202202+ }
203203+204204+ public function postPersist(LifecycleEventArgs $args): void { /* ... */ }
205205+}
206206+```
207207+208208+```php
209209+// AFTER (ORM 3):
210210+#[AsDoctrineListener(event: Events::postPersist)]
211211+final class MyListener
212212+{
213213+ public function postPersist(PostPersistEventArgs $args): void { /* ... */ }
214214+}
215215+```
216216+217217+Steps: drop `implements EventSubscriber` and `getSubscribedEvents()`; add
218218+`#[AsDoctrineListener(event: Events::...)]` (one attribute per event); swap
219219+`LifecycleEventArgs` for the per-event typed class.
220220+221221+## Guardrails
222222+223223+1. **No subscribers on ORM 3** — they no longer exist; use the attributes.
224224+2. **Don't `flush()` in a handler** — it re-enters the unit of work mid-flight.
225225+3. **Type-check in global listeners** — they fire for every entity.
226226+4. **`preUpdate` = change set only** — use `setNewValue()`, leave associations alone.
227227+5. **Prefer the narrowest scope** — callback < entity listener < lifecycle listener.
228228+229229+## Applicability
230230+231231+- **ORM 3.x + DoctrineBundle 2.8+** (target): attributes + per-event args, no
232232+ subscribers.
233233+- **ORM 2.x (legacy)**: subscribers and the single `LifecycleEventArgs` still
234234+ work but are deprecated — migrate before upgrading to 3.0.
235235+236236+237237+## Skill Operating Checklist
238238+239239+### Design checklist
240240+- Confirm operation boundaries and invariants first.
241241+- Minimize scope while preserving contract correctness.
242242+- Test both happy path and negative path behavior.
243243+244244+### Validation commands
245245+- php bin/console doctrine:migrations:diff
246246+- php bin/console doctrine:migrations:migrate
247247+- ./vendor/bin/phpunit --filter=Doctrine
248248+249249+### Failure modes to test
250250+- Invalid payload or forbidden actor.
251251+- Boundary values / not-found cases.
252252+- Retry or partial-failure behavior for async flows.
···11+# Doctrine Migrations Reference (Symfony)
22+33+Use this reference for implementation details and review criteria specific to `doctrine-migrations`.
44+55+Versions (target): **DoctrineMigrationsBundle `^3.0`**, which wraps the
66+**`doctrine/migrations` library 4.0.x** (5.0 in development). Migration classes
77+use the typed, `void`-returning signatures `up(Schema $schema): void` /
88+`down(Schema $schema): void`.
99+1010+## Install
1111+1212+```bash
1313+composer require doctrine/doctrine-migrations-bundle "^3.0"
1414+```
1515+1616+## Typical workflow
1717+1818+```bash
1919+# 1. Change your entity mapping (#[ORM\...]), then generate the diff
2020+php bin/console make:migration # MakerBundle wrapper around diff
2121+# or:
2222+php bin/console doctrine:migrations:diff
2323+2424+# 2. Review the generated up()/down() SQL (NEVER trust the diff blindly)
2525+2626+# 3. Apply
2727+php bin/console doctrine:migrations:migrate
2828+2929+# Useful neighbours
3030+php bin/console doctrine:migrations:status
3131+php bin/console doctrine:migrations:migrate prev # roll back one version
3232+php bin/console doctrine:migrations:execute 'DoctrineMigrations\Version20260617120000' --down
3333+```
3434+3535+## Migration class
3636+3737+```php
3838+<?php
3939+4040+declare(strict_types=1);
4141+4242+namespace DoctrineMigrations;
4343+4444+use Doctrine\DBAL\Schema\Schema;
4545+use Doctrine\Migrations\AbstractMigration;
4646+4747+final class Version20260617120000 extends AbstractMigration
4848+{
4949+ public function getDescription(): string
5050+ {
5151+ return 'Add status column to product';
5252+ }
5353+5454+ public function up(Schema $schema): void
5555+ {
5656+ $this->addSql('ALTER TABLE product ADD status VARCHAR(20) NOT NULL DEFAULT \'draft\'');
5757+ }
5858+5959+ public function down(Schema $schema): void
6060+ {
6161+ $this->addSql('ALTER TABLE product DROP status');
6262+ }
6363+}
6464+```
6565+6666+`down()` must reverse `up()`. A migration without a real `down()` cannot be
6767+rolled back safely — write it even when it feels redundant.
6868+6969+## Configuration (`config/packages/doctrine_migrations.yaml`)
7070+7171+```yaml
7272+doctrine_migrations:
7373+ migrations_paths:
7474+ 'DoctrineMigrations': '%kernel.project_dir%/migrations'
7575+ enable_profiler: false
7676+7777+ # Wrap EACH migration in its own transaction (default true).
7878+ transactional: true
7979+ # Run ALL pending migrations inside ONE transaction (default false).
8080+ # On MySQL most DDL is non-transactional (implicit commit), so this is
8181+ # mainly effective on PostgreSQL.
8282+ all_or_nothing: false
8383+8484+ check_database_platform: true
8585+ organize_migrations: false # or BY_YEAR / BY_YEAR_AND_MONTH
8686+```
8787+8888+Key options:
8989+9090+| Option | Default | Purpose |
9191+|--------|---------|---------|
9292+| `migrations_paths` | — | namespace → path pairs (required) |
9393+| `transactional` | `true` | wrap each migration in a transaction |
9494+| `all_or_nothing` | `false` | run all pending migrations in one transaction |
9595+| `em` | `default` | entity manager (overrides `connection`) |
9696+| `check_database_platform` | `true` | verify the DB type matches |
9797+| `enable_service_migrations` | `false` | allow migrations to be fetched from the container (DI) |
9898+9999+## Multiple entity managers
100100+101101+Each EM has its own migration history. Pass `--em` to every command:
102102+103103+```bash
104104+php bin/console doctrine:migrations:diff --em=customer
105105+php bin/console doctrine:migrations:migrate --em=customer
106106+```
107107+108108+When you have a non-default EM, also set `em:` (or a dedicated path) in
109109+`doctrine_migrations.yaml`, and in prod redefine the default EM in
110110+`config/packages/prod/doctrine.yaml`.
111111+112112+## Best practices
113113+114114+1. **Review the diff before applying.** `diff` reflects mapping vs current DB;
115115+ it can emit destructive or surprising DDL (renames seen as drop+add, index
116116+ churn). Read every `addSql()` line.
117117+2. **Schema only — no data migrations in schema migrations.** Mixing data
118118+ changes into a `diff`-generated migration breaks reversibility and gets
119119+ clobbered on the next `diff`. For data, use a dedicated, hand-written
120120+ migration or a one-off command, and keep it idempotent.
121121+3. **Always write `down()`** so the change is reversible.
122122+4. **One concern per migration.** Easier to review, revert, and reason about.
123123+5. **Never edit an already-applied migration.** Add a new one instead.
124124+6. **Service migrations** (`enable_service_migrations: true`) when a migration
125125+ genuinely needs a service — tag it `doctrine_migrations.migration` and call
126126+ `parent::__construct($connection, $logger)`.
127127+128128+## Applicability
129129+130130+- **Target**: bundle `^3.0` + library 4.0.x, typed `up/down(Schema): void`.
131131+- The signatures and `transactional`/`all_or_nothing` options shown apply to
132132+ library 3.x and 4.x alike.
133133+134134+135135+## Skill Operating Checklist
136136+137137+### Design checklist
138138+- Confirm operation boundaries and invariants first.
139139+- Minimize scope while preserving contract correctness.
140140+- Test both happy path and negative path behavior.
141141+142142+### Validation commands
143143+- php bin/console doctrine:migrations:diff
144144+- php bin/console doctrine:migrations:migrate
145145+- ./vendor/bin/phpunit --filter=Doctrine
146146+147147+### Failure modes to test
148148+- Invalid payload or forbidden actor.
149149+- Boundary values / not-found cases.
150150+- Retry or partial-failure behavior for async flows.
···11+# Doctrine Relations Reference (Symfony)
22+33+Use this reference for implementation details and review criteria specific to `doctrine-relations`.
44+55+Mapping is done with PHP attributes (`#[ORM\...]`). The mapping API is stable
66+across ORM 2.x/3.x; on ORM 3 the `targetEntity` can also be inferred from the
77+property type-hint, but passing it explicitly stays valid and is clearer.
88+99+## Owning vs Inverse side
1010+1111+| | Owning side | Inverse side |
1212+|--|-------------|--------------|
1313+| Mapping | `ManyToOne` (or owning `ManyToMany`/`OneToOne`) | `OneToMany` (or inverse side) |
1414+| Database | holds the foreign key | no column |
1515+| Updates DB | yes | only if the owning side is updated |
1616+1717+The single most common Doctrine bug: setting the relation on the **inverse**
1818+side only. Doctrine persists what the **owning** side holds. Always set the
1919+owning side (the `ManyToOne` reference).
2020+2121+## ManyToOne / OneToMany (the common pair)
2222+2323+### Owning side — `Product` holds the FK
2424+2525+```php
2626+<?php
2727+// src/Entity/Product.php
2828+2929+use Doctrine\ORM\Mapping as ORM;
3030+3131+#[ORM\Entity]
3232+class Product
3333+{
3434+ #[ORM\ManyToOne(targetEntity: Category::class, inversedBy: 'products')]
3535+ #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
3636+ private ?Category $category = null;
3737+3838+ public function getCategory(): ?Category
3939+ {
4040+ return $this->category;
4141+ }
4242+4343+ public function setCategory(?Category $category): self
4444+ {
4545+ $this->category = $category;
4646+4747+ return $this;
4848+ }
4949+}
5050+```
5151+5252+### Inverse side — `Category` owns a collection
5353+5454+```php
5555+<?php
5656+// src/Entity/Category.php
5757+5858+use Doctrine\Common\Collections\ArrayCollection;
5959+use Doctrine\Common\Collections\Collection;
6060+use Doctrine\ORM\Mapping as ORM;
6161+6262+#[ORM\Entity]
6363+class Category
6464+{
6565+ /** @var Collection<int, Product> */
6666+ #[ORM\OneToMany(targetEntity: Product::class, mappedBy: 'category', orphanRemoval: true)]
6767+ private Collection $products;
6868+6969+ public function __construct()
7070+ {
7171+ $this->products = new ArrayCollection();
7272+ }
7373+7474+ /** @return Collection<int, Product> */
7575+ public function getProducts(): Collection
7676+ {
7777+ return $this->products;
7878+ }
7979+8080+ public function addProduct(Product $product): self
8181+ {
8282+ if (!$this->products->contains($product)) {
8383+ $this->products[] = $product;
8484+ $product->setCategory($this); // keep both sides in sync
8585+ }
8686+8787+ return $this;
8888+ }
8989+9090+ public function removeProduct(Product $product): self
9191+ {
9292+ if ($this->products->removeElement($product)) {
9393+ // unset the owning side only if it points here
9494+ if ($product->getCategory() === $this) {
9595+ $product->setCategory(null);
9696+ }
9797+ }
9898+9999+ return $this;
100100+ }
101101+}
102102+```
103103+104104+### Persisting — set the owning side, then flush
105105+106106+```php
107107+$product->setCategory($category); // owning side carries the FK
108108+$em->persist($category);
109109+$em->persist($product);
110110+$em->flush();
111111+```
112112+113113+## orphanRemoval vs cascade
114114+115115+- **`cascade: ['persist', 'remove']`** — operations on the parent propagate to
116116+ the associated entities. Useful for aggregate roots you always save together.
117117+ Avoid `cascade: ['remove']` on large collections (issues a DELETE per row).
118118+- **`orphanRemoval: true`** — when a child is *removed from the collection* (no
119119+ longer referenced by the parent), Doctrine deletes it. Use it for true
120120+ composition (a `Product` cannot exist without its `Category`), not for
121121+ shared entities.
122122+123123+```php
124124+#[ORM\OneToMany(targetEntity: OrderItem::class, mappedBy: 'order',
125125+ cascade: ['persist'], orphanRemoval: true)]
126126+private Collection $items;
127127+```
128128+129129+## Fetching — avoid N+1
130130+131131+```php
132132+// LAZY (default): triggers one extra query per access
133133+$post = $em->find(Post::class, 1);
134134+$post->getAuthor()->getName(); // 2nd query here
135135+136136+// Fetch join: one query, relation hydrated
137137+$post = $repository->createQueryBuilder('p')
138138+ ->addSelect('a')
139139+ ->leftJoin('p.author', 'a')
140140+ ->where('p.id = :id')
141141+ ->setParameter('id', $id)
142142+ ->getQuery()
143143+ ->getOneOrNullResult();
144144+```
145145+146146+## Pitfall: `contains()` on large inverse collections
147147+148148+On a plain `OneToMany`, calling `$category->getProducts()->contains($product)`
149149+forces Doctrine to **hydrate the entire collection** into memory before
150150+checking — disastrous for collections with thousands of rows.
151151+152152+```php
153153+// BAD on a huge collection — loads everything
154154+if ($category->getProducts()->contains($product)) { /* ... */ }
155155+```
156156+157157+Mitigations:
158158+159159+- Mark the collection `fetch: 'EXTRA_LAZY'` so `contains()`, `count()` and
160160+ `slice()` issue targeted SQL (`EXISTS`, `COUNT`, `LIMIT`) instead of loading
161161+ the whole collection:
162162+163163+ ```php
164164+ #[ORM\OneToMany(targetEntity: Product::class, mappedBy: 'category', fetch: 'EXTRA_LAZY')]
165165+ private Collection $products;
166166+ ```
167167+168168+- Or check membership from the owning side: `$product->getCategory() === $category`.
169169+170170+Always review the generated `add*/remove*` helpers (from `make:entity`) — they
171171+call `contains()` and become a hot spot on large inverse collections.
172172+173173+## Multiple Entity Managers
174174+175175+A relation may **not** span two entity managers. When the schema is split, each
176176+EM owns its own set of entities.
177177+178178+```yaml
179179+# config/packages/doctrine.yaml
180180+doctrine:
181181+ dbal:
182182+ connections:
183183+ default: { url: '%env(resolve:DATABASE_URL)%' }
184184+ customer: { url: '%env(resolve:CUSTOMER_DATABASE_URL)%' }
185185+ default_connection: default
186186+ orm:
187187+ default_entity_manager: default
188188+ entity_managers:
189189+ default:
190190+ connection: default
191191+ mappings:
192192+ Main:
193193+ is_bundle: false
194194+ dir: '%kernel.project_dir%/src/Entity/Main'
195195+ prefix: 'App\Entity\Main'
196196+ customer:
197197+ connection: customer
198198+ mappings:
199199+ Customer:
200200+ is_bundle: false
201201+ dir: '%kernel.project_dir%/src/Entity/Customer'
202202+ prefix: 'App\Entity\Customer'
203203+```
204204+205205+### Autowiring a named EM (by variable name)
206206+207207+The FrameworkBundle registers an alias per EM. Type-hint
208208+`EntityManagerInterface` and **name the variable after the EM** to inject the
209209+right one:
210210+211211+```php
212212+use Doctrine\ORM\EntityManagerInterface;
213213+214214+public function __construct(
215215+ private EntityManagerInterface $entityManager, // default EM
216216+ private EntityManagerInterface $customerEntityManager, // "customer" EM
217217+) {}
218218+```
219219+220220+> Relying on the variable name for autowiring resolution is convenient, but be
221221+> aware Symfony 8.1+ deprecates name-based alias resolution in the general case;
222222+> for Doctrine EMs the named alias is still the documented mechanism. Use
223223+> `ManagerRegistry::getManager('customer')` when you need it explicitly.
224224+225225+### Repositories with multiple EMs
226226+227227+`getRepository()` takes the EM name as a second argument:
228228+229229+```php
230230+use Doctrine\Persistence\ManagerRegistry;
231231+232232+$default = $registry->getRepository(Product::class); // default EM
233233+$customer = $registry->getRepository(Customer::class, 'customer'); // named EM
234234+```
235235+236236+For a custom repository on an entity managed by a non-default EM, extend
237237+`Doctrine\ORM\EntityRepository` (**not** `ServiceEntityRepository`, whose
238238+constructor resolves the EM from the entity class via the default manager) and
239239+obtain it through `ManagerRegistry::getRepository()`.
240240+241241+### Console / migrations with `--em`
242242+243243+```bash
244244+php bin/console doctrine:database:create --connection=customer
245245+php bin/console doctrine:migrations:diff --em=customer
246246+php bin/console doctrine:migrations:migrate --em=customer
247247+```
248248+249249+250250+## Skill Operating Checklist
251251+252252+### Design checklist
253253+- Confirm operation boundaries and invariants first.
254254+- Minimize scope while preserving contract correctness.
255255+- Test both happy path and negative path behavior.
256256+257257+### Validation commands
258258+- php bin/console doctrine:migrations:diff
259259+- php bin/console doctrine:migrations:migrate
260260+- ./vendor/bin/phpunit --filter=Doctrine
261261+262262+### Failure modes to test
263263+- Invalid payload or forbidden actor.
264264+- Boundary values / not-found cases.
265265+- Retry or partial-failure behavior for async flows.
···11+---
22+33+name: symfony-e2e-panther-playwright
44+allowed-tools:
55+ - Read
66+ - Glob
77+ - Grep
88+description: Write end-to-end tests with Symfony Panther 2.4 for browser automation or Playwright for complex scenarios
99+---
1010+1111+# E2e Panther Playwright (Symfony)
1212+1313+## Use when
1414+- Building regression-safe behavior with TDD/functional/e2e tests.
1515+- Converting bug reports into executable failing tests.
1616+1717+## Default workflow
1818+1. Write failing test for target behavior and one boundary case.
1919+2. Implement minimal code to pass.
2020+3. Refactor while preserving green suite.
2121+4. Broaden coverage for invalid/unauthorized/not-found paths.
2222+2323+## Guardrails
2424+- Prefer deterministic fixtures/builders.
2525+- Assert observable behavior, not internal implementation.
2626+- Keep tests isolated and stable in CI.
2727+2828+## Progressive disclosure
2929+- Use this file for execution posture and risk controls.
3030+- Open references when deep implementation details are needed.
3131+3232+## Output contract
3333+- RED/GREEN/REFACTOR trace.
3434+- Test files changed and executed commands.
3535+- Coverage and confidence notes.
3636+3737+## References
3838+- `reference.md`
3939+- `docs/complexity-tiers.md`
···11+# Reference
22+33+# End-to-End Testing
44+55+> **Version applicability** — Panther section targets **symfony/panther v2.4.0** (2026-01,
66+> Symfony 7.x/8.x). Panther drives a **real browser** (W3C WebDriver: Chrome/Firefox) for
77+> E2E tests and scraping. The Playwright section is an alternative for richer JS scenarios.
88+99+## Symfony Panther
1010+1111+### Installation
1212+1313+```bash
1414+composer require --dev symfony/panther
1515+# bdi auto-downloads the matching ChromeDriver / geckodriver:
1616+composer require --dev dbrekelmans/bdi
1717+vendor/bin/bdi detect drivers
1818+```
1919+2020+Register the `ServerExtension` (manages the test web server; improves perf + enables
2121+`--debug`) in `phpunit.dist.xml`:
2222+2323+```xml
2424+<extensions>
2525+ <!-- PHPUnit 10+ -->
2626+ <bootstrap class="Symfony\Component\Panther\ServerExtension"/>
2727+ <!-- PHPUnit < 10 (legacy):
2828+ <extension class="Symfony\Component\Panther\ServerExtension"/> -->
2929+</extensions>
3030+```
3131+3232+### Basic test
3333+3434+```php
3535+<?php
3636+// tests/E2E/HomePageTest.php
3737+3838+namespace App\Tests\E2E;
3939+4040+use Symfony\Component\Panther\PantherTestCase;
4141+4242+class HomePageTest extends PantherTestCase
4343+{
4444+ public function testHomePageLoads(): void
4545+ {
4646+ $client = static::createPantherClient(); // real browser + JS
4747+ $client->request('GET', '/');
4848+4949+ $this->assertPageTitleContains('Home');
5050+ $this->assertSelectorTextContains('h1', 'Welcome');
5151+ }
5252+}
5353+```
5454+5555+### Client creation
5656+5757+```php
5858+static::createPantherClient(); // real browser, JS (Chrome default)
5959+static::createPantherClient(['browser' => static::FIREFOX]); // Firefox
6060+static::createClient(); // BrowserKit — fast, NO JS
6161+static::createHttpBrowserClient(); // real HTTP, no browser
6262+static::createAdditionalPantherClient(); // isolated 2nd browser (realtime/multi-user apps)
6363+// Standalone: Client::createChromeClient(), Client::createFirefoxClient(),
6464+// Client::createSeleniumClient('http://127.0.0.1:4444/wd/hub')
6565+```
6666+6767+### Form interaction
6868+6969+```php
7070+public function testContactForm(): void
7171+{
7272+ $client = static::createPantherClient();
7373+ $crawler = $client->request('GET', '/contact');
7474+7575+ $form = $crawler->selectButton('Send')->form([
7676+ 'contact[name]' => 'John Doe',
7777+ 'contact[email]' => 'john@example.com',
7878+ 'contact[message]' => 'Hello!',
7979+ ]);
8080+ $client->submit($form);
8181+8282+ $client->waitFor('.alert-success');
8383+ $this->assertSelectorTextContains('.alert-success', 'Message sent');
8484+}
8585+```
8686+8787+### Waiting for elements
8888+8989+Panther exposes explicit waits — never rely on implicit timing.
9090+9191+```php
9292+$client->waitFor('.product-list'); // exists in DOM
9393+$client->waitFor('.slow-content', 10); // custom timeout (s)
9494+$client->waitForVisibility('#x');
9595+$client->waitForInvisibility('.loading-spinner');
9696+$client->waitForStaleness('.popin');
9797+$client->waitForElementToContain('.total', '25 €');
9898+$client->waitForElementToNotContain('.total', '0 €');
9999+$client->waitForEnabled('[type=submit]');
100100+$client->waitForDisabled('[type=submit]');
101101+$client->waitForAttributeToContain('.price', 'data-old-price', '25 €');
102102+```
103103+104104+### Assertions
105105+106106+```php
107107+// Immediate state
108108+$this->assertSelectorIsVisible('.dropdown-menu');
109109+$this->assertSelectorIsNotVisible('.modal');
110110+$this->assertSelectorIsEnabled('[type=submit]');
111111+$this->assertSelectorIsDisabled('[type=submit]');
112112+$this->assertSelectorAttributeContains('.price', 'data-currency', 'EUR');
113113+114114+// Future state — wait then assert (Panther-specific)
115115+$this->assertSelectorWillExist('.product-card');
116116+$this->assertSelectorWillNotExist('.loading');
117117+$this->assertSelectorWillBeVisible('.toast');
118118+$this->assertSelectorWillBeEnabled('[type=submit]');
119119+$this->assertSelectorWillContain('.status', 'Complete');
120120+$this->assertSelectorAttributeWillContain('.price', 'data-old-price', '25 €');
121121+```
122122+123123+### Scripting & screenshots
124124+125125+```php
126126+$title = $client->executeScript('return document.title;');
127127+$client->takeScreenshot('var/screenshots/page.png');
128128+$logs = $client->getWebDriver()->manage()->getLog('browser'); // JS console
129129+```
130130+131131+> Screenshots are debris — delete any `*.png` produced by `takeScreenshot()` or by
132132+> `PANTHER_ERROR_SCREENSHOT_DIR` at the end of the run. Never commit them.
133133+134134+### Config & environment
135135+136136+Client options: `hostname` (127.0.0.1), `port` (9080), `external_base_uri`, `browser`.
137137+138138+Env vars: `PANTHER_NO_HEADLESS`, `PANTHER_WEB_SERVER_DIR` (`./public/`),
139139+`PANTHER_WEB_SERVER_PORT` (9080), `PANTHER_EXTERNAL_BASE_URI`, `PANTHER_APP_ENV`,
140140+`PANTHER_ERROR_SCREENSHOT_DIR`, `PANTHER_NO_SANDBOX`, `PANTHER_CHROME_ARGUMENTS`,
141141+`PANTHER_CHROME_BINARY`, `PANTHER_FIREFOX_ARGUMENTS`, `PANTHER_NO_REDUCED_MOTION`.
142142+143143+```php
144144+// Custom Chrome args:
145145+Client::createChromeClient(null, ['--window-size=1500,4000']);
146146+```
147147+148148+### Debug
149149+150150+```bash
151151+# Pauses the browser on failure for visual inspection (needs ServerExtension)
152152+PANTHER_NO_HEADLESS=1 bin/phpunit --debug
153153+```
154154+155155+### Limitations
156156+157157+HTML only (no XML crawling); no DOM updating from the crawler; no multidimensional array
158158+form values; returns `WebDriverElement`, not `\DOMElement`; Bootstrap 5 smooth-scroll can
159159+interfere (`$enable-smooth-scroll: false`).
160160+161161+## Playwright (alternative)
162162+163163+For richer cross-browser JS scenarios (WebKit, tracing, video), run Playwright separately
164164+against a running app. Use this when Panther's WebDriver model is too limiting.
165165+166166+### JavaScript Playwright tests
167167+168168+```javascript
169169+// tests/e2e/login.spec.js
170170+const { test, expect } = require('@playwright/test');
171171+172172+test('user can login', async ({ page }) => {
173173+ await page.goto('/login');
174174+175175+ await page.fill('input[name="email"]', 'test@example.com');
176176+ await page.fill('input[name="password"]', 'password');
177177+ await page.click('button[type="submit"]');
178178+179179+ await expect(page).toHaveURL('/dashboard');
180180+ await expect(page.locator('h1')).toContainText('Dashboard');
181181+});
182182+183183+test('login validation', async ({ page }) => {
184184+ await page.goto('/login');
185185+186186+ await page.fill('input[name="email"]', 'invalid');
187187+ await page.click('button[type="submit"]');
188188+189189+ await expect(page.locator('.error-message')).toBeVisible();
190190+});
191191+```
192192+193193+### Playwright configuration
194194+195195+```javascript
196196+// playwright.config.js
197197+module.exports = {
198198+ testDir: './tests/e2e',
199199+ timeout: 30000,
200200+ use: {
201201+ baseURL: 'http://localhost:8000',
202202+ screenshot: 'only-on-failure',
203203+ video: 'retain-on-failure',
204204+ },
205205+ projects: [
206206+ { name: 'chromium', use: { browserName: 'chromium' } },
207207+ { name: 'firefox', use: { browserName: 'firefox' } },
208208+ { name: 'webkit', use: { browserName: 'webkit' } },
209209+ ],
210210+};
211211+```
212212+213213+## Testing user flows
214214+215215+### Panther: complete checkout flow
216216+217217+```php
218218+public function testCheckoutFlow(): void
219219+{
220220+ $client = static::createPantherClient();
221221+222222+ // 1. Login
223223+ $client->request('GET', '/login');
224224+ $client->submitForm('Login', [
225225+ 'email' => 'test@example.com',
226226+ 'password' => 'password',
227227+ ]);
228228+ $client->waitFor('.dashboard');
229229+230230+ // 2. Browse products
231231+ $client->clickLink('Products');
232232+ $client->waitFor('.product-list');
233233+234234+ // 3. Add to cart
235235+ $client->click('.product-card:first-child .add-to-cart');
236236+ $client->waitForElementToContain('.cart-count', '1');
237237+238238+ // 4. Cart
239239+ $client->clickLink('Cart');
240240+ $client->waitFor('.cart-items');
241241+242242+ // 5. Checkout
243243+ $client->clickLink('Checkout');
244244+ $client->waitFor('.checkout-form');
245245+246246+ // 6. Shipping
247247+ $client->submitForm('Continue', [
248248+ 'shipping[address]' => '123 Main St',
249249+ 'shipping[city]' => 'Paris',
250250+ 'shipping[zip]' => '75001',
251251+ ]);
252252+253253+ // 7. Confirm
254254+ $client->waitFor('.order-summary');
255255+ $client->click('.confirm-order');
256256+257257+ // 8. Verify
258258+ $this->assertSelectorWillContain('.order-confirmation', 'Thank you');
259259+}
260260+```
261261+262262+## CI configuration
263263+264264+### GitHub Actions with Panther
265265+266266+```yaml
267267+# .github/workflows/e2e.yml
268268+name: E2E Tests
269269+270270+on: [push, pull_request]
271271+272272+jobs:
273273+ e2e:
274274+ runs-on: ubuntu-latest
275275+276276+ services:
277277+ database:
278278+ image: postgres:16
279279+ env:
280280+ POSTGRES_PASSWORD: test
281281+ options: >-
282282+ --health-cmd pg_isready
283283+ --health-interval 10s
284284+ --health-timeout 5s
285285+ --health-retries 5
286286+287287+ steps:
288288+ - uses: actions/checkout@v6
289289+290290+ - name: Setup PHP
291291+ uses: shivammathur/setup-php@v2
292292+ with:
293293+ php-version: '8.4'
294294+ extensions: pdo_pgsql
295295+296296+ - name: Install Chrome
297297+ uses: browser-actions/setup-chrome@latest
298298+299299+ - name: Install dependencies
300300+ run: composer install --no-interaction --prefer-dist --no-progress
301301+302302+ - name: Detect WebDriver
303303+ run: vendor/bin/bdi detect drivers
304304+305305+ - name: Setup database
306306+ run: |
307307+ bin/console doctrine:database:create --env=test
308308+ bin/console doctrine:migrations:migrate --no-interaction --env=test
309309+ bin/console doctrine:fixtures:load --no-interaction --env=test
310310+311311+ - name: Run E2E tests
312312+ run: ./vendor/bin/phpunit tests/E2E
313313+ env:
314314+ PANTHER_NO_SANDBOX: 1
315315+```
316316+317317+## Best practices
318318+319319+1. **Test critical paths**: login, checkout, signup.
320320+2. **Explicit waits**: prefer `waitFor*` / `assertSelectorWill*` over implicit timing.
321321+3. **Stable selectors**: target `data-testid` attributes, not styling classes.
322322+4. **Separate from unit tests**: E2E is slow — keep it in its own suite/dir.
323323+5. **Reset state**: clean the database between tests (DAMADoctrineTestBundle / Foundry reset).
324324+6. **Clean up screenshots**: delete any PNGs produced; never commit them.
325325+326326+327327+## Skill Operating Checklist
328328+329329+### Design checklist
330330+- Confirm operation boundaries and invariants first.
331331+- Minimize scope while preserving contract correctness.
332332+- Test both happy path and negative path behavior.
333333+334334+### Validation commands
335335+- vendor/bin/bdi detect drivers
336336+- ./vendor/bin/phpunit tests/E2E
337337+- PANTHER_NO_HEADLESS=1 bin/phpunit --debug
338338+339339+### Failure modes to test
340340+- Invalid payload or forbidden actor.
341341+- Boundary values / not-found cases.
342342+- Retry or partial-failure behavior for async flows.
+39
.agents/skills/symfony-effective-context/SKILL.md
···11+---
22+33+name: symfony-effective-context
44+allowed-tools:
55+ - Read
66+ - Glob
77+ - Grep
88+description: Provide effective context to Claude for Symfony development with relevant files, patterns, and constraints
99+---
1010+1111+# Effective Context (Symfony)
1212+1313+## Use when
1414+- Refining architecture/workflows/context handling in Symfony projects.
1515+- Planning and executing medium/complex changes safely.
1616+1717+## Default workflow
1818+1. Establish current boundaries, constraints, and coupling points.
1919+2. Propose smallest coherent architectural adjustment.
2020+3. Execute in checkpoints with validation at each stage.
2121+4. Summarize tradeoffs and follow-up backlog.
2222+2323+## Guardrails
2424+- Use existing project patterns by default.
2525+- Avoid broad refactors without explicit need.
2626+- Keep decision log clear and auditable.
2727+2828+## Progressive disclosure
2929+- Use this file for execution posture and risk controls.
3030+- Open references when deep implementation details are needed.
3131+3232+## Output contract
3333+- Architecture/workflow changes.
3434+- Checkpoint validation outcomes.
3535+- Residual risks and next steps.
3636+3737+## References
3838+- `reference.md`
3939+- `docs/complexity-tiers.md`
···11+# Reference
22+33+# Providing Effective Context
44+55+## What Context Helps
66+77+When asking Claude for help with Symfony, provide:
88+99+1. **Relevant code files** - Entity, Service, Controller involved
1010+2. **Error messages** - Full stack trace if available
1111+3. **Symfony version** - 8.x (stable), 7.4 LTS, or 6.4 legacy LTS
1212+4. **Installed bundles** - API Platform, Messenger, etc.
1313+5. **Constraints** - Performance requirements, backward compatibility
1414+1515+## Code Context Examples
1616+1717+### For Entity Questions
1818+1919+Provide:
2020+```
2121+- The entity file
2222+- Related entities (if relationships involved)
2323+- The repository if custom queries
2424+- Current migration state
2525+```
2626+2727+### For API Platform Questions
2828+2929+Provide:
3030+```
3131+- Entity with API Platform attributes
3232+- Custom providers/processors if any
3333+- Relevant filters
3434+- Expected request/response format
3535+```
3636+3737+### For Testing Questions
3838+3939+Provide:
4040+```
4141+- Test file
4242+- Code being tested
4343+- Factory files
4444+- Error output
4545+```
4646+4747+## Effective Prompts
4848+4949+### Good: Specific and Contextual
5050+5151+```
5252+I have a Symfony 7 app with API Platform 4. I need to add a filter
5353+that searches across multiple fields (title, description, author name).
5454+5555+Here's my entity:
5656+[paste entity code]
5757+5858+Current filters work for single fields. How do I create a custom
5959+filter that does OR search across these fields?
6060+```
6161+6262+### Bad: Vague and Missing Context
6363+6464+```
6565+How do I search in API Platform?
6666+```
6767+6868+## Including Error Context
6969+7070+### Full Error Message
7171+7272+```
7373+When I run bin/console doctrine:migrations:migrate, I get:
7474+7575+[Error message with full stack trace]
7676+7777+My entity is:
7878+[entity code]
7979+8080+My migration is:
8181+[migration code]
8282+```
8383+8484+### Relevant Log Output
8585+8686+```
8787+The Messenger worker crashes with this error in var/log/dev.log:
8888+8989+[2024-01-15 10:30:00] messenger.ERROR: Error thrown while handling message...
9090+9191+My message handler:
9292+[handler code]
9393+9494+My message:
9595+[message code]
9696+```
9797+9898+## Constraints to Mention
9999+100100+### Performance Requirements
101101+102102+```
103103+This endpoint needs to handle 1000 requests/second. Currently it's slow
104104+because of N+1 queries. Here's the code:
105105+[code]
106106+```
107107+108108+### Backward Compatibility
109109+110110+```
111111+I need to add a new field to the API response without breaking existing
112112+clients. Current response format:
113113+[JSON example]
114114+```
115115+116116+### Existing Patterns
117117+118118+```
119119+Our codebase uses CQRS pattern. Here's an example command handler:
120120+[example code]
121121+122122+I need to add a new feature following the same pattern.
123123+```
124124+125125+## Project Structure Context
126126+127127+### When Asking About Architecture
128128+129129+```
130130+Our project structure:
131131+src/
132132+├── Domain/ # Entities, Value Objects
133133+├── Application/ # Commands, Queries, Handlers
134134+└── Infrastructure/ # Controllers, Repositories
135135+136136+We follow hexagonal architecture. How should I structure a new
137137+feature for user notifications?
138138+```
139139+140140+### When Asking About Configuration
141141+142142+```
143143+config/packages/messenger.yaml:
144144+[current config]
145145+146146+I need to add a second transport for high-priority messages.
147147+```
148148+149149+## Version-Specific Context
150150+151151+### Symfony Version
152152+153153+```
154154+Symfony 6.4 LTS project. Can I use MapRequestPayload attribute?
155155+Or is that only in 7.x?
156156+```
157157+158158+### PHP Version
159159+160160+```
161161+PHP 8.2, Symfony 7.1. I want to use readonly classes for my DTOs.
162162+```
163163+164164+### Bundle Versions
165165+166166+```
167167+API Platform 3.2. I see examples using "operations" but my version
168168+uses "collectionOperations". Which syntax should I use?
169169+```
170170+171171+## Anti-Patterns to Avoid
172172+173173+### Don't Provide Too Much
174174+175175+```
176176+# Bad: dumping entire project
177177+Here's my entire src/ directory...
178178+[thousands of lines]
179179+```
180180+181181+### Don't Provide Too Little
182182+183183+```
184184+# Bad: no context
185185+Why doesn't my query work?
186186+```
187187+188188+### Don't Assume Knowledge
189189+190190+```
191191+# Bad: referring to unseen code
192192+The UserService I showed you earlier...
193193+[But it wasn't shown]
194194+```
195195+196196+## Template for Asking Questions
197197+198198+```markdown
199199+## Context
200200+- Symfony version: X.Y
201201+- PHP version: 8.X
202202+- Relevant bundles: [list]
203203+204204+## What I'm Trying to Do
205205+[Clear description of goal]
206206+207207+## Current Code
208208+[Relevant files only]
209209+210210+## What's Happening
211211+[Error message or unexpected behavior]
212212+213213+## What I've Tried
214214+[Previous attempts if any]
215215+216216+## Constraints
217217+[Performance, compatibility, patterns to follow]
218218+```
219219+220220+221221+## Skill Operating Checklist
222222+223223+### Design checklist
224224+- Confirm operation boundaries and invariants first.
225225+- Minimize scope while preserving contract correctness.
226226+- Test both happy path and negative path behavior.
227227+228228+### Validation commands
229229+- rg --files
230230+- composer validate
231231+- ./vendor/bin/phpstan analyse
232232+233233+### Failure modes to test
234234+- Invalid payload or forbidden actor.
235235+- Boundary values / not-found cases.
236236+- Retry or partial-failure behavior for async flows.
237237+
+42
.agents/skills/symfony-executing-plans/SKILL.md
···11+---
22+33+name: symfony-executing-plans
44+allowed-tools:
55+ - Read
66+ - Write
77+ - Edit
88+ - Bash
99+ - Glob
1010+ - Grep
1111+description: Methodically execute implementation plans with a TDD approach, incremental commits, and continuous validation
1212+---
1313+1414+# Executing Plans (Symfony)
1515+1616+## Use when
1717+- Refining architecture/workflows/context handling in Symfony projects.
1818+- Planning and executing medium/complex changes safely.
1919+2020+## Default workflow
2121+1. Establish current boundaries, constraints, and coupling points.
2222+2. Propose smallest coherent architectural adjustment.
2323+3. Execute in checkpoints with validation at each stage.
2424+4. Summarize tradeoffs and follow-up backlog.
2525+2626+## Guardrails
2727+- Use existing project patterns by default.
2828+- Avoid broad refactors without explicit need.
2929+- Keep decision log clear and auditable.
3030+3131+## Progressive disclosure
3232+- Use this file for execution posture and risk controls.
3333+- Open references when deep implementation details are needed.
3434+3535+## Output contract
3636+- Architecture/workflow changes.
3737+- Checkpoint validation outcomes.
3838+- Residual risks and next steps.
3939+4040+## References
4141+- `reference.md`
4242+- `docs/complexity-tiers.md`
···11+---
22+33+name: symfony-ports-and-adapters
44+allowed-tools:
55+ - Read
66+ - Glob
77+ - Grep
88+description: Implement Hexagonal Architecture (Ports and Adapters) in Symfony; separate domain logic from infrastructure with clear boundaries
99+---
1010+1111+# Ports And Adapters (Symfony)
1212+1313+## Use when
1414+- Refining architecture/workflows/context handling in Symfony projects.
1515+- Planning and executing medium/complex changes safely.
1616+1717+## Default workflow
1818+1. Establish current boundaries, constraints, and coupling points.
1919+2. Propose smallest coherent architectural adjustment.
2020+3. Execute in checkpoints with validation at each stage.
2121+4. Summarize tradeoffs and follow-up backlog.
2222+2323+## Guardrails
2424+- Use existing project patterns by default.
2525+- Avoid broad refactors without explicit need.
2626+- Keep decision log clear and auditable.
2727+2828+## Progressive disclosure
2929+- Use this file for execution posture and risk controls.
3030+- Open references when deep implementation details are needed.
3131+3232+## Output contract
3333+- Architecture/workflow changes.
3434+- Checkpoint validation outcomes.
3535+- Residual risks and next steps.
3636+3737+## References
3838+- `reference.md`
3939+- `docs/complexity-tiers.md`
···11+# Tdd With Phpunit Reference (Symfony)
22+33+Use this reference for implementation details and review criteria specific to `tdd-with-phpunit`.
44+55+> **Version applicability** — Targets **PHPUnit 10/11** with Symfony 7.4 LTS / 8.x.
66+> PHPUnit 10+ uses **PHP attributes** (`#[Test]`, `#[DataProvider]`, `#[CoversClass]`)
77+> — the old `/** @test @dataProvider */` annotations are deprecated/removed.
88+> `static::getContainer()` replaces legacy `self::$container`. Console test helpers and
99+> 422 assertions are Symfony 8.1+ where noted.
1010+1111+## The TDD cycle
1212+1313+```
1414+RED → write a failing test that expresses the desired behavior
1515+GREEN → write the minimum code to make it pass
1616+REFACTOR → improve structure with the test as a safety net (stay green)
1717+```
1818+1919+Repeat per behavior. Never write production code without a failing test first.
2020+2121+## Test base classes
2222+2323+| Base class | Use for |
2424+| --- | --- |
2525+| `TestCase` (PHPUnit) | Pure logic — no kernel, no container, fastest. |
2626+| `KernelTestCase` | Integration — boot kernel, real container/services. |
2727+| `WebTestCase` | Functional — simulate full HTTP requests. |
2828+2929+## Attributes (PHPUnit 10+)
3030+3131+Annotations are out; use attributes:
3232+3333+```php
3434+use PHPUnit\Framework\TestCase;
3535+use PHPUnit\Framework\Attributes\Test;
3636+use PHPUnit\Framework\Attributes\DataProvider;
3737+use PHPUnit\Framework\Attributes\CoversClass;
3838+3939+#[CoversClass(PriceCalculator::class)]
4040+final class PriceCalculatorTest extends TestCase
4141+{
4242+ #[Test]
4343+ public function it_applies_vat(): void
4444+ {
4545+ $calc = new PriceCalculator();
4646+ self::assertSame(120.0, $calc->withVat(100.0, 0.20));
4747+ }
4848+4949+ #[Test]
5050+ #[DataProvider('vatCases')]
5151+ public function it_applies_various_rates(float $net, float $rate, float $expected): void
5252+ {
5353+ self::assertSame($expected, (new PriceCalculator())->withVat($net, $rate));
5454+ }
5555+5656+ public static function vatCases(): \Generator
5757+ {
5858+ yield 'standard' => [100.0, 0.20, 120.0];
5959+ yield 'reduced' => [100.0, 0.055, 105.5];
6060+ yield 'zero rate' => [100.0, 0.0, 100.0];
6161+ }
6262+}
6363+```
6464+6565+## RED — write the failing test first
6666+6767+### Unit (no kernel)
6868+6969+```php
7070+#[Test]
7171+public function it_rejects_an_empty_order(): void
7272+{
7373+ $this->expectException(\InvalidArgumentException::class);
7474+ $this->expectExceptionMessage('Order must have at least one item');
7575+7676+ (new OrderService($this->createMock(EntityManagerInterface::class)))
7777+ ->createOrder($user, []);
7878+}
7979+```
8080+8181+### Integration (KernelTestCase + real container)
8282+8383+```php
8484+use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
8585+8686+final class OrderServiceTest extends KernelTestCase
8787+{
8888+ #[Test]
8989+ public function it_creates_an_order(): void
9090+ {
9191+ self::bootKernel();
9292+ $service = static::getContainer()->get(OrderService::class);
9393+9494+ $order = $service->createOrder($user, [['productId' => 1, 'quantity' => 2]]);
9595+9696+ self::assertCount(1, $order->getItems());
9797+ }
9898+}
9999+```
100100+101101+## GREEN — minimum code to pass
102102+103103+```php
104104+final class OrderService
105105+{
106106+ public function __construct(private EntityManagerInterface $em) {}
107107+108108+ public function createOrder(User $user, array $items): Order
109109+ {
110110+ if (empty($items)) {
111111+ throw new \InvalidArgumentException('Order must have at least one item');
112112+ }
113113+ // ... build + persist + flush, nothing speculative
114114+ return $order;
115115+ }
116116+}
117117+```
118118+119119+## REFACTOR — stay green
120120+121121+Extract collaborators, introduce value objects, move queries to repositories. Re-run the
122122+suite after each change.
123123+124124+## Mocking services in the test container
125125+126126+In `KernelTestCase`/`WebTestCase` you can swap a real service for a test double via the
127127+test container:
128128+129129+```php
130130+$mock = $this->createMock(NewsRepositoryInterface::class);
131131+$mock->method('findActive')->willReturn([]);
132132+133133+static::getContainer()->set(NewsRepositoryInterface::class, $mock);
134134+```
135135+136136+For **non-shared** services, pass a closure (Symfony 8.1+) so a fresh instance is returned
137137+each time:
138138+139139+```php
140140+static::getContainer()->set(Mailer::class, fn(): Mailer => $mock);
141141+```
142142+143143+> Prefer real services + real DB for repositories (see the `functional-tests` skill).
144144+> Reserve mocks for true external boundaries (HTTP clients, third-party SDKs).
145145+146146+## Testing console commands
147147+148148+Classic approach with `CommandTester`:
149149+150150+```php
151151+use Symfony\Component\Console\Tester\CommandTester;
152152+153153+$command = (new Application(self::$kernel))->find('app:import');
154154+$tester = new CommandTester($command);
155155+$tester->execute(['file' => 'data.csv']);
156156+157157+$tester->assertCommandIsSuccessful();
158158+self::assertStringContainsString('Imported', $tester->getDisplay());
159159+```
160160+161161+Symfony **8.1+** adds a `runCommand()` helper returning an `ExecutionResult`:
162162+163163+```php
164164+final class ImportCommandTest extends KernelTestCase
165165+{
166166+ #[Test]
167167+ public function it_imports(): void
168168+ {
169169+ $result = static::runCommand('app:import data.csv');
170170+171171+ $this->assertCommandIsSuccessful($result); // 8.1+ assertion
172172+ self::assertStringContainsString('Imported', $result->getDisplay());
173173+ }
174174+}
175175+```
176176+177177+Related console assertions (8.1+): `assertCommandIsSuccessful`, `assertCommandFailed`,
178178+`assertCommandResultEquals`.
179179+180180+## HTTP / form status assertions
181181+182182+When a controller renders an invalid form (Symfony 8.0+ passes the form, not the view),
183183+the response is **HTTP 422**:
184184+185185+```php
186186+#[Test]
187187+public function it_rejects_invalid_submission(): void
188188+{
189189+ $client = static::createClient();
190190+ $client->request('POST', '/register', ['email' => 'not-an-email']);
191191+192192+ $this->assertResponseIsUnprocessable(); // 422
193193+ $this->assertSelectorTextContains('.form-error', 'valid email');
194194+}
195195+```
196196+197197+## Review criteria
198198+199199+- A failing test exists before the production change (RED first).
200200+- Tests use PHPUnit 10+ **attributes** (`#[Test]`, `#[DataProvider]`, `#[CoversClass]`), not annotations.
201201+- Correct base class: `TestCase` (logic) / `KernelTestCase` (services) / `WebTestCase` (HTTP).
202202+- Services fetched via `static::getContainer()`; mocks only at external boundaries.
203203+- Console tests use `runCommand()` + `assertCommandIsSuccessful` (8.1+) or `CommandTester`.
204204+- Invalid form/HTTP cases assert 422 via `assertResponseIsUnprocessable()`.
205205+206206+207207+## Skill Operating Checklist
208208+209209+### Design checklist
210210+- Confirm operation boundaries and invariants first.
211211+- Minimize scope while preserving contract correctness.
212212+- Test both happy path and negative path behavior.
213213+214214+### Validation commands
215215+- ./vendor/bin/phpunit --filter=...
216216+- ./vendor/bin/phpunit
217217+- ./vendor/bin/pest --filter=...
218218+219219+### Failure modes to test
220220+- Invalid payload or forbidden actor.
221221+- Boundary values / not-found cases.
222222+- Retry or partial-failure behavior for async flows.
···11+---
22+33+name: symfony-test-doubles-mocking
44+allowed-tools:
55+ - Read
66+ - Glob
77+ - Grep
88+description: Create test doubles with PHPUnit mocks for isolated unit testing in Symfony
99+---
1010+1111+# Test Doubles Mocking (Symfony)
1212+1313+## Use when
1414+- Building regression-safe behavior with TDD/functional/e2e tests.
1515+- Converting bug reports into executable failing tests.
1616+1717+## Default workflow
1818+1. Write failing test for target behavior and one boundary case.
1919+2. Implement minimal code to pass.
2020+3. Refactor while preserving green suite.
2121+4. Broaden coverage for invalid/unauthorized/not-found paths.
2222+2323+## Guardrails
2424+- Prefer deterministic fixtures/builders.
2525+- Assert observable behavior, not internal implementation.
2626+- Keep tests isolated and stable in CI.
2727+2828+## Progressive disclosure
2929+- Use this file for execution posture and risk controls.
3030+- Open references when deep implementation details are needed.
3131+3232+## Output contract
3333+- RED/GREEN/REFACTOR trace.
3434+- Test files changed and executed commands.
3535+- Coverage and confidence notes.
3636+3737+## References
3838+- `reference.md`
3939+- `docs/complexity-tiers.md`
···11+---
22+33+name: symfony-value-objects-and-dtos
44+allowed-tools:
55+ - Read
66+ - Glob
77+ - Grep
88+description: Design Value Objects for domain concepts and DTOs for data transfer with proper immutability (readonly) and validation
99+---
1010+1111+# Value Objects And Dtos (Symfony)
1212+1313+## Use when
1414+- Refining architecture/workflows/context handling in Symfony projects.
1515+- Planning and executing medium/complex changes safely.
1616+1717+## Default workflow
1818+1. Establish current boundaries, constraints, and coupling points.
1919+2. Propose smallest coherent architectural adjustment.
2020+3. Execute in checkpoints with validation at each stage.
2121+4. Summarize tradeoffs and follow-up backlog.
2222+2323+## Guardrails
2424+- Use existing project patterns by default.
2525+- Avoid broad refactors without explicit need.
2626+- Keep decision log clear and auditable.
2727+2828+## Progressive disclosure
2929+- Use this file for execution posture and risk controls.
3030+- Open references when deep implementation details are needed.
3131+3232+## Output contract
3333+- Architecture/workflow changes.
3434+- Checkpoint validation outcomes.
3535+- Residual risks and next steps.
3636+3737+## References
3838+- `reference.md`
3939+- `docs/complexity-tiers.md`
···11+# Reference
22+33+# Value Objects and DTOs in Symfony
44+55+## Value Objects
66+77+Value Objects are immutable objects defined by their attributes, not identity.
88+99+### Money Value Object
1010+1111+```php
1212+<?php
1313+// src/Domain/ValueObject/Money.php
1414+1515+namespace App\Domain\ValueObject;
1616+1717+final readonly class Money
1818+{
1919+ private function __construct(
2020+ private int $amount, // In cents
2121+ private string $currency,
2222+ ) {
2323+ if ($amount < 0) {
2424+ throw new \InvalidArgumentException('Amount cannot be negative');
2525+ }
2626+ if (strlen($currency) !== 3) {
2727+ throw new \InvalidArgumentException('Currency must be ISO 4217 code');
2828+ }
2929+ }
3030+3131+ public static function of(int $amount, string $currency): self
3232+ {
3333+ return new self($amount, strtoupper($currency));
3434+ }
3535+3636+ public static function EUR(int $amount): self
3737+ {
3838+ return new self($amount, 'EUR');
3939+ }
4040+4141+ public static function zero(string $currency = 'EUR'): self
4242+ {
4343+ return new self(0, strtoupper($currency));
4444+ }
4545+4646+ public function add(self $other): self
4747+ {
4848+ $this->assertSameCurrency($other);
4949+ return new self($this->amount + $other->amount, $this->currency);
5050+ }
5151+5252+ public function subtract(self $other): self
5353+ {
5454+ $this->assertSameCurrency($other);
5555+ return new self($this->amount - $other->amount, $this->currency);
5656+ }
5757+5858+ public function multiply(float $multiplier): self
5959+ {
6060+ return new self((int) round($this->amount * $multiplier), $this->currency);
6161+ }
6262+6363+ public function getAmount(): int
6464+ {
6565+ return $this->amount;
6666+ }
6767+6868+ public function getCurrency(): string
6969+ {
7070+ return $this->currency;
7171+ }
7272+7373+ public function format(): string
7474+ {
7575+ return number_format($this->amount / 100, 2) . ' ' . $this->currency;
7676+ }
7777+7878+ public function equals(self $other): bool
7979+ {
8080+ return $this->amount === $other->amount
8181+ && $this->currency === $other->currency;
8282+ }
8383+8484+ public function isGreaterThan(self $other): bool
8585+ {
8686+ $this->assertSameCurrency($other);
8787+ return $this->amount > $other->amount;
8888+ }
8989+9090+ public function isZero(): bool
9191+ {
9292+ return $this->amount === 0;
9393+ }
9494+9595+ private function assertSameCurrency(self $other): void
9696+ {
9797+ if ($this->currency !== $other->currency) {
9898+ throw new \InvalidArgumentException(
9999+ "Cannot operate on different currencies: {$this->currency} vs {$other->currency}"
100100+ );
101101+ }
102102+ }
103103+}
104104+```
105105+106106+### Email Value Object
107107+108108+```php
109109+<?php
110110+// src/Domain/ValueObject/Email.php
111111+112112+namespace App\Domain\ValueObject;
113113+114114+final readonly class Email
115115+{
116116+ private function __construct(
117117+ private string $value,
118118+ ) {
119119+ if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
120120+ throw new \InvalidArgumentException("Invalid email: {$value}");
121121+ }
122122+ }
123123+124124+ public static function fromString(string $email): self
125125+ {
126126+ return new self(strtolower(trim($email)));
127127+ }
128128+129129+ public function getValue(): string
130130+ {
131131+ return $this->value;
132132+ }
133133+134134+ public function getDomain(): string
135135+ {
136136+ return substr($this->value, strpos($this->value, '@') + 1);
137137+ }
138138+139139+ public function equals(self $other): bool
140140+ {
141141+ return $this->value === $other->value;
142142+ }
143143+144144+ public function __toString(): string
145145+ {
146146+ return $this->value;
147147+ }
148148+}
149149+```
150150+151151+### Address Value Object
152152+153153+```php
154154+<?php
155155+// src/Domain/ValueObject/Address.php
156156+157157+namespace App\Domain\ValueObject;
158158+159159+final readonly class Address
160160+{
161161+ public function __construct(
162162+ public string $street,
163163+ public string $city,
164164+ public string $postalCode,
165165+ public string $country,
166166+ public ?string $state = null,
167167+ ) {
168168+ if (empty($street) || empty($city) || empty($postalCode) || empty($country)) {
169169+ throw new \InvalidArgumentException('Address fields cannot be empty');
170170+ }
171171+ }
172172+173173+ public function withStreet(string $street): self
174174+ {
175175+ return new self($street, $this->city, $this->postalCode, $this->country, $this->state);
176176+ }
177177+178178+ public function format(): string
179179+ {
180180+ $parts = [$this->street, $this->postalCode . ' ' . $this->city];
181181+182182+ if ($this->state) {
183183+ $parts[] = $this->state;
184184+ }
185185+186186+ $parts[] = $this->country;
187187+188188+ return implode("\n", $parts);
189189+ }
190190+191191+ public function equals(self $other): bool
192192+ {
193193+ return $this->street === $other->street
194194+ && $this->city === $other->city
195195+ && $this->postalCode === $other->postalCode
196196+ && $this->country === $other->country
197197+ && $this->state === $other->state;
198198+ }
199199+}
200200+```
201201+202202+## Doctrine Embeddables
203203+204204+Store Value Objects in database:
205205+206206+```php
207207+<?php
208208+// src/Domain/ValueObject/Money.php
209209+210210+use Doctrine\ORM\Mapping as ORM;
211211+212212+#[ORM\Embeddable]
213213+final readonly class Money
214214+{
215215+ #[ORM\Column(type: 'integer')]
216216+ private int $amount;
217217+218218+ #[ORM\Column(type: 'string', length: 3)]
219219+ private string $currency;
220220+221221+ // ... rest of class
222222+}
223223+224224+// src/Entity/Order.php
225225+226226+#[ORM\Entity]
227227+class Order
228228+{
229229+ #[ORM\Embedded(class: Money::class)]
230230+ private Money $total;
231231+232232+ public function getTotal(): Money
233233+ {
234234+ return $this->total;
235235+ }
236236+}
237237+```
238238+239239+## DTOs (Data Transfer Objects)
240240+241241+DTOs carry data between layers without behavior.
242242+243243+### Input DTO
244244+245245+```php
246246+<?php
247247+// src/Dto/CreateOrderInput.php
248248+249249+namespace App\Dto;
250250+251251+use Symfony\Component\Validator\Constraints as Assert;
252252+253253+final readonly class CreateOrderInput
254254+{
255255+ public function __construct(
256256+ #[Assert\NotBlank]
257257+ #[Assert\Positive]
258258+ public int $customerId,
259259+260260+ #[Assert\NotBlank]
261261+ #[Assert\Count(min: 1, minMessage: 'Order must have at least one item')]
262262+ #[Assert\Valid]
263263+ public array $items,
264264+265265+ #[Assert\Length(max: 20)]
266266+ public ?string $couponCode = null,
267267+ ) {}
268268+}
269269+270270+// src/Dto/OrderItemInput.php
271271+272272+final readonly class OrderItemInput
273273+{
274274+ public function __construct(
275275+ #[Assert\NotBlank]
276276+ #[Assert\Positive]
277277+ public int $productId,
278278+279279+ #[Assert\NotBlank]
280280+ #[Assert\Positive]
281281+ #[Assert\LessThanOrEqual(100)]
282282+ public int $quantity,
283283+ ) {}
284284+}
285285+```
286286+287287+### Output DTO
288288+289289+```php
290290+<?php
291291+// src/Dto/OrderOutput.php
292292+293293+namespace App\Dto;
294294+295295+use App\Entity\Order;
296296+297297+final readonly class OrderOutput
298298+{
299299+ public function __construct(
300300+ public string $id,
301301+ public int $customerId,
302302+ public array $items,
303303+ public MoneyOutput $total,
304304+ public string $status,
305305+ public string $createdAt,
306306+ ) {}
307307+308308+ public static function fromEntity(Order $order): self
309309+ {
310310+ return new self(
311311+ id: $order->getId(),
312312+ customerId: $order->getCustomer()->getId(),
313313+ items: array_map(
314314+ fn($item) => OrderItemOutput::fromEntity($item),
315315+ $order->getItems()->toArray()
316316+ ),
317317+ total: MoneyOutput::fromValueObject($order->getTotal()),
318318+ status: $order->getStatus()->value,
319319+ createdAt: $order->getCreatedAt()->format('c'),
320320+ );
321321+ }
322322+}
323323+324324+// src/Dto/MoneyOutput.php
325325+326326+final readonly class MoneyOutput
327327+{
328328+ public function __construct(
329329+ public int $amount,
330330+ public string $currency,
331331+ public string $formatted,
332332+ ) {}
333333+334334+ public static function fromValueObject(Money $money): self
335335+ {
336336+ return new self(
337337+ amount: $money->getAmount(),
338338+ currency: $money->getCurrency(),
339339+ formatted: $money->format(),
340340+ );
341341+ }
342342+}
343343+```
344344+345345+### API Platform Integration
346346+347347+```php
348348+<?php
349349+// src/Entity/Order.php
350350+351351+use ApiPlatform\Metadata\ApiResource;
352352+use ApiPlatform\Metadata\Post;
353353+use App\Dto\CreateOrderInput;
354354+use App\Dto\OrderOutput;
355355+356356+#[ApiResource(
357357+ operations: [
358358+ new Post(
359359+ input: CreateOrderInput::class,
360360+ output: OrderOutput::class,
361361+ processor: CreateOrderProcessor::class,
362362+ ),
363363+ ],
364364+)]
365365+class Order { /* ... */ }
366366+```
367367+368368+## Serializer Attributes
369369+370370+Prefer attributes on the DTO over external mapping files. The attributes live in
371371+the `Symfony\Component\Serializer\Attribute\*` namespace (renamed from the old
372372+`Annotation\*` namespace in Symfony 7.0):
373373+374374+```php
375375+<?php
376376+377377+namespace App\Dto;
378378+379379+use Symfony\Component\Serializer\Attribute\Groups;
380380+use Symfony\Component\Serializer\Attribute\SerializedName;
381381+use Symfony\Component\Serializer\Attribute\Ignore;
382382+383383+final readonly class OrderOutput
384384+{
385385+ public function __construct(
386386+ #[Groups(['order:read'])]
387387+ public string $id,
388388+389389+ #[Groups(['order:read'])]
390390+ #[SerializedName('customer_id')]
391391+ public int $customerId,
392392+393393+ #[Ignore]
394394+ public ?string $internalNote = null,
395395+ ) {}
396396+}
397397+```
398398+399399+## Serializer Configuration (mapping files)
400400+401401+```yaml
402402+# config/packages/serializer.yaml
403403+framework:
404404+ serializer:
405405+ mapping:
406406+ paths:
407407+ - '%kernel.project_dir%/config/serializer'
408408+```
409409+410410+```yaml
411411+# config/serializer/Money.yaml
412412+App\Domain\ValueObject\Money:
413413+ attributes:
414414+ amount:
415415+ groups: ['read']
416416+ currency:
417417+ groups: ['read']
418418+```
419419+420420+## Best Practices
421421+422422+1. **Value Objects are immutable**: Return new instances
423423+2. **Validate in constructor**: Fail fast
424424+3. **Use readonly**: `readonly` properties (PHP 8.1+) or whole `readonly class` (PHP 8.2+)
425425+4. **Equality by value**: Implement equals() method
426426+5. **DTOs are simple**: No behavior, just data
427427+6. **Separate Input/Output**: Different validation needs
428428+7. **Use Embeddables**: Store VOs in database naturally
429429+430430+431431+## Skill Operating Checklist
432432+433433+### Design checklist
434434+- Confirm operation boundaries and invariants first.
435435+- Minimize scope while preserving contract correctness.
436436+- Test both happy path and negative path behavior.
437437+438438+### Validation commands
439439+- rg --files
440440+- composer validate
441441+- ./vendor/bin/phpstan analyse
442442+443443+### Failure modes to test
444444+- Invalid payload or forbidden actor.
445445+- Boundary values / not-found cases.
446446+- Retry or partial-failure behavior for async flows.
447447+
+39
.agents/skills/symfony-writing-plans/SKILL.md
···11+---
22+33+name: symfony-writing-plans
44+allowed-tools:
55+ - Read
66+ - Glob
77+ - Grep
88+description: Create structured implementation plans for Symfony features with clear steps, dependencies, and acceptance criteria
99+---
1010+1111+# Writing Plans (Symfony)
1212+1313+## Use when
1414+- Refining architecture/workflows/context handling in Symfony projects.
1515+- Planning and executing medium/complex changes safely.
1616+1717+## Default workflow
1818+1. Establish current boundaries, constraints, and coupling points.
1919+2. Propose smallest coherent architectural adjustment.
2020+3. Execute in checkpoints with validation at each stage.
2121+4. Summarize tradeoffs and follow-up backlog.
2222+2323+## Guardrails
2424+- Use existing project patterns by default.
2525+- Avoid broad refactors without explicit need.
2626+- Keep decision log clear and auditable.
2727+2828+## Progressive disclosure
2929+- Use this file for execution posture and risk controls.
3030+- Open references when deep implementation details are needed.
3131+3232+## Output contract
3333+- Architecture/workflow changes.
3434+- Checkpoint validation outcomes.
3535+- Residual risks and next steps.
3636+3737+## References
3838+- `reference.md`
3939+- `docs/complexity-tiers.md`
+227
.agents/skills/symfony-writing-plans/reference.md
···11+# Reference
22+33+# Writing Implementation Plans
44+55+Transform brainstorming results into actionable implementation plans.
66+77+## Plan Structure
88+99+### 1. Overview
1010+1111+```markdown
1212+# Implementation Plan: [Feature Name]
1313+1414+## Summary
1515+[1-2 sentence description of what we're building]
1616+1717+## Scope
1818+- IN: [What's included]
1919+- OUT: [What's explicitly excluded]
2020+2121+## Dependencies
2222+- [Required packages]
2323+- [Existing services/entities to modify]
2424+- [External services]
2525+```
2626+2727+### 2. Technical Design
2828+2929+```markdown
3030+## Entities
3131+3232+### New Entity: Order
3333+```php
3434+#[ORM\Entity]
3535+class Order
3636+{
3737+ #[ORM\Id]
3838+ #[ORM\Column(type: 'uuid')]
3939+ private Uuid $id;
4040+4141+ #[ORM\ManyToOne(targetEntity: User::class)]
4242+ private User $customer;
4343+4444+ #[ORM\Column(type: 'string', enumType: OrderStatus::class)]
4545+ private OrderStatus $status;
4646+4747+ #[ORM\OneToMany(targetEntity: OrderItem::class, mappedBy: 'order')]
4848+ private Collection $items;
4949+}
5050+```
5151+5252+### Modified Entity: User
5353+- Add `orders` OneToMany relation
5454+```
5555+5656+### 3. Services & Handlers
5757+5858+```markdown
5959+## Services
6060+6161+### OrderService
6262+- `createOrder(User $user, array $items): Order`
6363+- `calculateTotal(Order $order): Money`
6464+- `validateStock(Order $order): bool`
6565+6666+### Message Handlers
6767+6868+### ProcessOrderHandler
6969+- Triggered by: `ProcessOrder` message
7070+- Actions:
7171+ 1. Validate stock
7272+ 2. Reserve items
7373+ 3. Dispatch `OrderProcessed` event
7474+```
7575+7676+### 4. API Endpoints
7777+7878+```markdown
7979+## API Endpoints
8080+8181+### POST /api/orders
8282+- Request: `{items: [{productId, quantity}]}`
8383+- Response: `201 Created` with Order resource
8484+- Security: `ROLE_USER`
8585+8686+### GET /api/orders/{id}
8787+- Response: Order resource
8888+- Security: Owner or `ROLE_ADMIN`
8989+```
9090+9191+### 5. Implementation Steps
9292+9393+```markdown
9494+## Implementation Steps
9595+9696+### Phase 1: Foundation
9797+1. [ ] Create `OrderStatus` enum
9898+2. [ ] Create `Order` entity with migrations
9999+3. [ ] Create `OrderItem` entity with migrations
100100+4. [ ] Add relation to `User` entity
101101+5. [ ] Run migrations
102102+103103+### Phase 2: Business Logic
104104+6. [ ] Create `OrderService`
105105+7. [ ] Create `ProcessOrder` message
106106+8. [ ] Create `ProcessOrderHandler`
107107+9. [ ] Write unit tests for service
108108+109109+### Phase 3: API
110110+10. [ ] Configure API Platform resource
111111+11. [ ] Add security voters
112112+12. [ ] Write functional tests
113113+114114+### Phase 4: Integration
115115+13. [ ] Connect to payment service
116116+14. [ ] Add email notifications
117117+15. [ ] End-to-end testing
118118+```
119119+120120+### 6. Acceptance Criteria
121121+122122+```markdown
123123+## Acceptance Criteria
124124+125125+- [ ] User can create order with multiple items
126126+- [ ] Order total is calculated correctly
127127+- [ ] Stock is validated before processing
128128+- [ ] Only owner can view their orders
129129+- [ ] Admin can view all orders
130130+- [ ] Order status transitions are validated
131131+- [ ] Email sent on order confirmation
132132+```
133133+134134+### 7. Risks & Mitigations
135135+136136+```markdown
137137+## Risks
138138+139139+| Risk | Probability | Impact | Mitigation |
140140+|------|-------------|--------|------------|
141141+| Stock race condition | Medium | High | Use database locking |
142142+| Payment failure | Low | High | Implement retry with Messenger |
143143+| Performance on large orders | Low | Medium | Batch processing |
144144+```
145145+146146+## Plan Templates
147147+148148+### CRUD Feature
149149+150150+```markdown
151151+# Plan: [Entity] CRUD
152152+153153+## Entities
154154+- [Entity] with fields: [list]
155155+156156+## Steps
157157+1. [ ] Create entity + migration
158158+2. [ ] Create Foundry factory
159159+3. [ ] Configure API Platform resource
160160+4. [ ] Add validation constraints
161161+5. [ ] Add security voter
162162+6. [ ] Write tests
163163+```
164164+165165+### Background Job Feature
166166+167167+```markdown
168168+# Plan: [Job Name]
169169+170170+## Messages
171171+- [MessageName]: [trigger description]
172172+173173+## Handlers
174174+- [HandlerName]: [processing steps]
175175+176176+## Steps
177177+1. [ ] Create message class
178178+2. [ ] Create handler
179179+3. [ ] Configure routing in messenger.yaml
180180+4. [ ] Add retry strategy
181181+5. [ ] Write tests with in-memory transport
182182+```
183183+184184+### Integration Feature
185185+186186+```markdown
187187+# Plan: [Service] Integration
188188+189189+## External Service
190190+- API: [URL/docs]
191191+- Auth: [method]
192192+- Rate limits: [limits]
193193+194194+## Steps
195195+1. [ ] Create HTTP client service
196196+2. [ ] Create DTOs for requests/responses
197197+3. [ ] Implement retry logic
198198+4. [ ] Add circuit breaker
199199+5. [ ] Write tests with mocked responses
200200+```
201201+202202+## Best Practices
203203+204204+1. **Atomic steps**: Each step should be completable independently
205205+2. **Test-first**: Include test steps before implementation
206206+3. **Dependencies clear**: Note which steps depend on others
207207+4. **Reviewable**: Plan should be reviewable by team
208208+5. **Time-boxed**: Add rough complexity indicators (S/M/L)
209209+210210+211211+## Skill Operating Checklist
212212+213213+### Design checklist
214214+- Confirm operation boundaries and invariants first.
215215+- Minimize scope while preserving contract correctness.
216216+- Test both happy path and negative path behavior.
217217+218218+### Validation commands
219219+- rg --files
220220+- composer validate
221221+- ./vendor/bin/phpstan analyse
222222+223223+### Failure modes to test
224224+- Invalid payload or forbidden actor.
225225+- Boundary values / not-found cases.
226226+- Retry or partial-failure behavior for async flows.
227227+
+56
.agents/skills/telemetry-capture/SKILL.md
···11+---
22+name: telemetry-capture
33+description: "Capture and inspect the raw OTel telemetry (spans, logs, metrics) a dagger CLI invocation emits, using the hack/otlpdump tool. Use when debugging telemetry emission, verifying spans/log records/attributes reach the exporter, checking streaming progress records (dagger.io/progress.*), or diagnosing why something doesn't render in the TUI or Dagger Cloud. Triggers on: capture telemetry, OTLP, otlpdump, debug spans, debug telemetry, progress records, telemetry not showing up."
44+---
55+66+# Telemetry Capture
77+88+`hack/otlpdump` is a tiny OTLP receiver that dumps every span, log record, and metric a dagger CLI invocation exports to a JSONL file for inspection with `jq`. Use it to see ground truth when the TUI (or Cloud) isn't showing what you expect: the question "was it emitted?" separates emitter bugs from rendering bugs.
99+1010+## Capture
1111+1212+**Step 1: Start the receiver** (run in background; it serves until killed):
1313+1414+```bash
1515+go run ./hack/otlpdump -addr 127.0.0.1:43180 -out /tmp/telemetry.jsonl
1616+```
1717+1818+**Step 2: Run dagger pointed at it.** The generic endpoint var alone only delivers traces — logs and metrics need their explicit vars too:
1919+2020+```bash
2121+env OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:43180 \
2222+ OTEL_EXPORTER_OTLP_LOGS_ENDPOINT=http://127.0.0.1:43180/v1/logs \
2323+ OTEL_EXPORTER_OTLP_METRICS_ENDPOINT=http://127.0.0.1:43180/v1/metrics \
2424+ OTEL_EXPORTER_OTLP_TRACES_LIVE=1 \
2525+ ./hack/with-dev ./bin/dagger ...
2626+```
2727+2828+`OTEL_EXPORTER_OTLP_TRACES_LIVE=1` makes spans export on start as well as end, so you see in-flight state, matching what live consumers receive.
2929+3030+## Inspect
3131+3232+Each JSONL line has a `.kind` of `span`, `log`, or `metric`. Useful queries:
3333+3434+```bash
3535+# all span names
3636+jq -r 'select(.kind=="span") | .name' /tmp/telemetry.jsonl | sort -u
3737+3838+# spans by name prefix, with ids (first 8 hex chars locate children/logs)
3939+jq -r 'select(.kind=="span") | select(.name|startswith("pulling")) | [.spanId[0:8], .name] | @tsv' /tmp/telemetry.jsonl
4040+4141+# streaming progress records (the dagger.io/progress.* convention,
4242+# engine/telemetryattrs/attrs.go), grouped per span
4343+jq -r 'select(.kind=="log" and (.attrs["dagger.io/progress.item"]//empty)!="") |
4444+ [.spanId[0:8], .attrs["dagger.io/progress.item"], .attrs["dagger.io/progress.current"], .attrs["dagger.io/progress.total"]] | @tsv' /tmp/telemetry.jsonl
4545+4646+# log records attached to a specific span
4747+jq -r 'select(.kind=="log" and .spanId[0:8]=="<prefix>") | .body' /tmp/telemetry.jsonl
4848+```
4949+5050+For progress records, the invariants worth checking: records are keyed by (span, item) with latest-wins; emitters throttle (~100ms) but MUST emit a final converged state (`current == total` for known totals); bodies are explicit empty strings (an unset body breaks the OTLP round-trip).
5151+5252+## Gotchas
5353+5454+- The output file is appended across runs — delete it (or use a fresh `-out` path) between captures, and don't delete it out from under a running receiver (it keeps the old fd).
5555+- Cached operations emit no transfer telemetry: cold-state setup matters (e.g. pull a not-yet-pulled image; `./hack/build` fully resets the dev engine cache).
5656+- A missing span in the capture usually means the engine running is stale, not that the code is wrong — verify the dev engine actually contains your change before debugging the emitter.
+159
.agents/skills/tui-qa/SKILL.md
···11+---
22+name: tui-qa
33+description: QA CLI and TUI applications with asciinema recordings, timestamped terminal snapshots, and hang/timing analysis. Use when validating terminal output, progress behavior, delays, or what a user would see at a given moment.
44+---
55+66+# TUI QA
77+88+Use this skill for operator-style QA of terminal applications.
99+1010+Prefer the bundled helper at `scripts/tui_qa.py` over ad hoc shell pipelines. It standardizes artifacts, snapshot capture, and timing analysis so follow-up sessions can inspect the same evidence.
1111+1212+## Backend
1313+1414+This skill requires `asciinema`.
1515+1616+Artifact meanings:
1717+1818+- `.cast`: source of truth for timing and terminal behavior
1919+- `final.txt`: final rendered screen as plain text
2020+- `snapshots/*.txt`: rendered screen at specific timestamps
2121+- `.raw`: optional low-level byte stream for terminal-control debugging
2222+- `report.json`: machine-readable timings, findings, and artifact paths
2323+- `report.md`: short human-readable QA summary
2424+2525+Use `.cast` for time-sensitive behavior. Use text snapshots for "what the user saw at time T". If terminal control behavior itself is suspect, inspect the `.cast` replay rather than relying on `.txt`.
2626+2727+## Workflow
2828+2929+1. State the operator expectation before recording.
3030+3131+ - expected first visible response
3232+ - expected milestones
3333+ - expected final screen or files written
3434+ - acceptable delays
3535+3636+1. Record and analyze in one step when possible:
3737+3838+```bash
3939+python3 skills/tui-qa/scripts/tui_qa.py run \
4040+ --name workspace-install \
4141+ --command 'dagger install github.com/dagger/dagger/modules/wolfi@main' \
4242+ --workdir /path/to/repo \
4343+ --snapshot-at 0.5 \
4444+ --snapshot-at 2 \
4545+ --milestone 'Initialized workspace' \
4646+ --milestone 'Installed module'
4747+```
4848+4949+1. Review:
5050+5151+ - `report.md` for the summary
5252+ - `snapshots/*.txt` for point-in-time screens
5353+ - `session.cast` with `asciinema play` when timing or redraw behavior matters
5454+5555+1. Classify issues:
5656+5757+ - content bug
5858+ - timing bug
5959+ - hard hang: no output for too long
6060+ - semantic hang: output continues but no meaningful milestone progress
6161+ - polish issue
6262+6363+## Commands
6464+6565+`record`
6666+6767+- Use for manual or interactive sessions.
6868+- Stores `session.cast` and `meta.json`.
6969+- If the current shell is not attached to a tty, the helper automatically records in headless mode.
7070+7171+```bash
7272+python3 skills/tui-qa/scripts/tui_qa.py record \
7373+ --name interactive-playground
7474+```
7575+7676+```bash
7777+python3 skills/tui-qa/scripts/tui_qa.py record \
7878+ --name module-init \
7979+ --command 'dagger sdk install go && dagger module init go demo' \
8080+ --workdir /tmp/playground
8181+```
8282+8383+`snapshot`
8484+8585+- Produces a plain-text screen at a chosen timestamp.
8686+- The helper truncates the cast at the last full event at or before `--at`, then converts that truncated cast to text.
8787+8888+```bash
8989+python3 skills/tui-qa/scripts/tui_qa.py snapshot \
9090+ .qa/tui/module-init-20260404-120000/session.cast \
9191+ --at 1.25
9292+```
9393+9494+`analyze`
9595+9696+- Parses the event stream.
9797+- Generates `final.txt`, snapshots, `report.json`, and `report.md`.
9898+- Detects startup delay and hard hangs from periods with no output.
9999+- If milestones are supplied, also reports semantic-hang candidates.
100100+101101+Default thresholds:
102102+103103+- startup warning: 2s
104104+- startup failure: 5s
105105+- idle warning: 10s
106106+- idle failure: 30s
107107+- semantic-hang warning: 120s
108108+109109+`run`
110110+111111+- Runs `record`, then `analyze`.
112112+- This is the default path for non-interactive QA.
113113+114114+## Guidance
115115+116116+- Treat `.cast` as the authority for timing.
117117+- Treat `final.txt` as the authority for final rendered text.
118118+- When low-level terminal control bytes matter, export `.raw` directly with `asciinema convert -f raw session.cast session.raw`.
119119+- Use milestones for higher-level progress checks. Without milestones, semantic-hang analysis is intentionally reported as not evaluated.
120120+- Input events do not count as progress.
121121+- Output events that only repaint the terminal still count for hard-hang timing. Use milestone timing and snapshots to decide whether that output felt meaningfully progressive.
122122+- For commands that write files, inspect the filesystem after the run in addition to the terminal artifacts.
123123+124124+## Examples
125125+126126+Batch CLI:
127127+128128+```bash
129129+python3 skills/tui-qa/scripts/tui_qa.py run \
130130+ --name help-output \
131131+ --command 'dagger --help' \
132132+ --snapshot-at 0.1
133133+```
134134+135135+Progress UI:
136136+137137+```bash
138138+python3 skills/tui-qa/scripts/tui_qa.py run \
139139+ --name generate \
140140+ --command 'dagger generate' \
141141+ --milestone 'Generated' \
142142+ --milestone 'done'
143143+```
144144+145145+Manual recording, then analysis:
146146+147147+```bash
148148+python3 skills/tui-qa/scripts/tui_qa.py record --name manual-flow
149149+python3 skills/tui-qa/scripts/tui_qa.py analyze .qa/tui/manual-flow-*/session.cast
150150+```
151151+152152+Specific screen sample:
153153+154154+```bash
155155+python3 skills/tui-qa/scripts/tui_qa.py snapshot \
156156+ .qa/tui/generate-20260404-120000/session.cast \
157157+ --at 12.3 \
158158+ --label before-finish
159159+```
+4
.agents/skills/tui-qa/agents/openai.yaml
···11+interface:
22+ display_name: "TUI QA"
33+ short_description: "QA terminal output, timing, and hangs"
44+ default_prompt: "Use $tui-qa to record a terminal session, capture timestamped screen snapshots, and report output, delay, and hang issues."
+954
.agents/skills/tui-qa/scripts/tui_qa.py
···11+#!/usr/bin/env python3
22+33+from __future__ import annotations
44+55+import argparse
66+import json
77+import os
88+import re
99+import shutil
1010+import subprocess
1111+import sys
1212+import tempfile
1313+from dataclasses import dataclass
1414+from datetime import datetime
1515+from pathlib import Path
1616+from typing import Any
1717+1818+1919+DEFAULT_STARTUP_WARN = 2.0
2020+DEFAULT_STARTUP_FAIL = 5.0
2121+DEFAULT_IDLE_WARN = 10.0
2222+DEFAULT_IDLE_FAIL = 30.0
2323+DEFAULT_SEMANTIC_WARN = 120.0
2424+SNAPSHOT_EPSILON = 0.000001
2525+2626+ANSI_ESCAPE_RE = re.compile(
2727+ r"""
2828+ \x1B
2929+ (?:
3030+ \[[0-?]*[ -/]*[@-~]
3131+ |\][^\x07]*(?:\x07|\x1B\\)
3232+ |[@-Z\\-_]
3333+ )
3434+ """,
3535+ re.VERBOSE,
3636+)
3737+3838+3939+@dataclass
4040+class CastEvent:
4141+ delta: float
4242+ kind: str
4343+ data: Any
4444+ raw: list[Any]
4545+ time: float
4646+4747+4848+def main() -> int:
4949+ parser = build_parser()
5050+ args = parser.parse_args()
5151+ try:
5252+ return args.func(args)
5353+ except QaError as exc:
5454+ print(f"error: {exc}", file=sys.stderr)
5555+ return 1
5656+5757+5858+class QaError(RuntimeError):
5959+ pass
6060+6161+6262+def build_parser() -> argparse.ArgumentParser:
6363+ parser = argparse.ArgumentParser(
6464+ description="Record and analyze terminal QA sessions with asciinema.",
6565+ )
6666+ subparsers = parser.add_subparsers(dest="command", required=True)
6767+6868+ record = subparsers.add_parser("record", help="Record a terminal session to a cast file.")
6969+ add_common_record_args(record)
7070+ record.set_defaults(func=cmd_record)
7171+7272+ snapshot = subparsers.add_parser(
7373+ "snapshot",
7474+ help="Render the visible terminal screen at a given timestamp.",
7575+ )
7676+ snapshot.add_argument("cast", type=Path, help="Path to an asciinema cast file.")
7777+ snapshot.add_argument("--at", type=float, required=True, help="Timestamp in seconds.")
7878+ snapshot.add_argument("--output", type=Path, help="Output path for the text snapshot.")
7979+ snapshot.add_argument("--label", help="Optional label for the snapshot file name.")
8080+ snapshot.set_defaults(func=cmd_snapshot)
8181+8282+ analyze = subparsers.add_parser("analyze", help="Analyze a recorded cast.")
8383+ analyze.add_argument("cast", type=Path, help="Path to an asciinema cast file.")
8484+ analyze.add_argument(
8585+ "--snapshot-at",
8686+ action="append",
8787+ default=[],
8888+ type=float,
8989+ help="Additional timestamp to snapshot. Repeat as needed.",
9090+ )
9191+ analyze.add_argument(
9292+ "--milestone",
9393+ action="append",
9494+ default=[],
9595+ help="Regex describing a meaningful progress milestone. Repeat as needed.",
9696+ )
9797+ analyze.add_argument(
9898+ "--startup-warn",
9999+ type=float,
100100+ default=DEFAULT_STARTUP_WARN,
101101+ help="Warn if first output takes at least this many seconds.",
102102+ )
103103+ analyze.add_argument(
104104+ "--startup-fail",
105105+ type=float,
106106+ default=DEFAULT_STARTUP_FAIL,
107107+ help="Fail if first output takes at least this many seconds.",
108108+ )
109109+ analyze.add_argument(
110110+ "--idle-warn",
111111+ type=float,
112112+ default=DEFAULT_IDLE_WARN,
113113+ help="Warn on no-output gaps of at least this many seconds.",
114114+ )
115115+ analyze.add_argument(
116116+ "--idle-fail",
117117+ type=float,
118118+ default=DEFAULT_IDLE_FAIL,
119119+ help="Fail on no-output gaps of at least this many seconds.",
120120+ )
121121+ analyze.add_argument(
122122+ "--semantic-warn",
123123+ type=float,
124124+ default=DEFAULT_SEMANTIC_WARN,
125125+ help="Warn when output continues without milestone progress for this long.",
126126+ )
127127+ analyze.add_argument(
128128+ "--output-dir",
129129+ type=Path,
130130+ help="Directory for generated analysis artifacts. Defaults to the cast directory.",
131131+ )
132132+ analyze.set_defaults(func=cmd_analyze)
133133+134134+ run = subparsers.add_parser(
135135+ "run",
136136+ help="Record a terminal session and analyze it in one step.",
137137+ )
138138+ add_common_record_args(run)
139139+ run.add_argument(
140140+ "--snapshot-at",
141141+ action="append",
142142+ default=[],
143143+ type=float,
144144+ help="Additional timestamp to snapshot. Repeat as needed.",
145145+ )
146146+ run.add_argument(
147147+ "--milestone",
148148+ action="append",
149149+ default=[],
150150+ help="Regex describing a meaningful progress milestone. Repeat as needed.",
151151+ )
152152+ run.add_argument("--startup-warn", type=float, default=DEFAULT_STARTUP_WARN)
153153+ run.add_argument("--startup-fail", type=float, default=DEFAULT_STARTUP_FAIL)
154154+ run.add_argument("--idle-warn", type=float, default=DEFAULT_IDLE_WARN)
155155+ run.add_argument("--idle-fail", type=float, default=DEFAULT_IDLE_FAIL)
156156+ run.add_argument("--semantic-warn", type=float, default=DEFAULT_SEMANTIC_WARN)
157157+ run.set_defaults(func=cmd_run)
158158+159159+ return parser
160160+161161+162162+def add_common_record_args(parser: argparse.ArgumentParser) -> None:
163163+ parser.add_argument("--name", required=True, help="Artifact name prefix.")
164164+ parser.add_argument(
165165+ "--command",
166166+ help="Command to run inside the recording. Omit for an interactive session.",
167167+ )
168168+ parser.add_argument(
169169+ "--artifact-dir",
170170+ type=Path,
171171+ help="Artifact directory. Defaults to .qa/tui/<name>-<timestamp>.",
172172+ )
173173+ parser.add_argument(
174174+ "--capture-input",
175175+ action="store_true",
176176+ help="Record keyboard input as well as output.",
177177+ )
178178+ parser.add_argument(
179179+ "--workdir",
180180+ type=Path,
181181+ help="Working directory for the recorded command.",
182182+ )
183183+ parser.add_argument(
184184+ "--window-size",
185185+ help="Override terminal size as COLSxROWS.",
186186+ )
187187+ parser.add_argument(
188188+ "--idle-time-limit",
189189+ type=float,
190190+ help="Optional playback idle-time cap stored in the cast metadata.",
191191+ )
192192+ parser.add_argument(
193193+ "--headless",
194194+ action="store_true",
195195+ help="Force headless recording mode.",
196196+ )
197197+198198+199199+def cmd_record(args: argparse.Namespace) -> int:
200200+ artifacts = ensure_artifact_dir(args.name, args.artifact_dir)
201201+ result = record_session(
202202+ artifact_dir=artifacts,
203203+ name=args.name,
204204+ command=args.command,
205205+ workdir=args.workdir,
206206+ capture_input=args.capture_input,
207207+ window_size=args.window_size,
208208+ idle_time_limit=args.idle_time_limit,
209209+ headless=args.headless,
210210+ )
211211+ print(result["cast"])
212212+ if result.get("command_exit_code") not in (None, 0):
213213+ print(
214214+ f"recorded command exit code: {result['command_exit_code']}",
215215+ file=sys.stderr,
216216+ )
217217+ return 0
218218+219219+220220+def cmd_snapshot(args: argparse.Namespace) -> int:
221221+ cast_path = args.cast.resolve()
222222+ if not cast_path.is_file():
223223+ raise QaError(f"cast not found: {cast_path}")
224224+ output_path = args.output
225225+ if output_path is None:
226226+ label = sanitize_label(args.label or f"at-{format_seconds(args.at)}")
227227+ output_path = cast_path.parent / "snapshots" / f"{label}.txt"
228228+ render_snapshot(cast_path, args.at, output_path)
229229+ print(output_path.resolve())
230230+ return 0
231231+232232+233233+def cmd_analyze(args: argparse.Namespace) -> int:
234234+ report = analyze_cast(
235235+ cast_path=args.cast.resolve(),
236236+ output_dir=(args.output_dir.resolve() if args.output_dir else None),
237237+ snapshot_times=list(args.snapshot_at),
238238+ milestone_patterns=list(args.milestone),
239239+ startup_warn=args.startup_warn,
240240+ startup_fail=args.startup_fail,
241241+ idle_warn=args.idle_warn,
242242+ idle_fail=args.idle_fail,
243243+ semantic_warn=args.semantic_warn,
244244+ )
245245+ print(report["report_md"])
246246+ return 0
247247+248248+249249+def cmd_run(args: argparse.Namespace) -> int:
250250+ artifacts = ensure_artifact_dir(args.name, args.artifact_dir)
251251+ record_session(
252252+ artifact_dir=artifacts,
253253+ name=args.name,
254254+ command=args.command,
255255+ workdir=args.workdir,
256256+ capture_input=args.capture_input,
257257+ window_size=args.window_size,
258258+ idle_time_limit=args.idle_time_limit,
259259+ headless=args.headless,
260260+ )
261261+ report = analyze_cast(
262262+ cast_path=artifacts / "session.cast",
263263+ output_dir=artifacts,
264264+ snapshot_times=list(args.snapshot_at),
265265+ milestone_patterns=list(args.milestone),
266266+ startup_warn=args.startup_warn,
267267+ startup_fail=args.startup_fail,
268268+ idle_warn=args.idle_warn,
269269+ idle_fail=args.idle_fail,
270270+ semantic_warn=args.semantic_warn,
271271+ )
272272+ summary = report["summary"]
273273+ print(f"artifacts: {artifacts}")
274274+ print(f"verdict: {summary['verdict']}")
275275+ print(f"report: {report['report_md']}")
276276+ return 0
277277+278278+279279+def ensure_artifact_dir(name: str, artifact_dir: Path | None) -> Path:
280280+ if artifact_dir is None:
281281+ stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
282282+ artifact_dir = Path(".qa") / "tui" / f"{sanitize_label(name)}-{stamp}"
283283+ artifact_dir = artifact_dir.resolve()
284284+ artifact_dir.mkdir(parents=True, exist_ok=True)
285285+ (artifact_dir / "snapshots").mkdir(parents=True, exist_ok=True)
286286+ return artifact_dir
287287+288288+289289+def record_session(
290290+ *,
291291+ artifact_dir: Path,
292292+ name: str,
293293+ command: str | None,
294294+ workdir: Path | None,
295295+ capture_input: bool,
296296+ window_size: str | None,
297297+ idle_time_limit: float | None,
298298+ headless: bool,
299299+) -> dict[str, Any]:
300300+ asciinema = shutil.which("asciinema")
301301+ if not asciinema:
302302+ raise QaError("asciinema is required but was not found in PATH")
303303+304304+ cast_path = artifact_dir / "session.cast"
305305+ meta_path = artifact_dir / "meta.json"
306306+ workdir_path = workdir.resolve() if workdir else None
307307+ should_headless = headless or not (sys.stdin.isatty() and sys.stdout.isatty())
308308+ effective_window_size = window_size or infer_window_size()
309309+310310+ cmd = [asciinema, "record", "--quiet", "--overwrite", "--return"]
311311+ if command:
312312+ cmd.extend(["--command", command])
313313+ if capture_input:
314314+ cmd.append("--capture-input")
315315+ if idle_time_limit is not None:
316316+ cmd.extend(["--idle-time-limit", str(idle_time_limit)])
317317+ if should_headless:
318318+ cmd.append("--headless")
319319+ if effective_window_size:
320320+ cmd.extend(["--window-size", effective_window_size])
321321+ cmd.extend(["--title", name, str(cast_path)])
322322+323323+ started_at = datetime.now().astimezone().isoformat()
324324+ result = subprocess.run(
325325+ cmd,
326326+ cwd=str(workdir_path) if workdir_path else None,
327327+ text=True,
328328+ capture_output=True,
329329+ check=False,
330330+ )
331331+ finished_at = datetime.now().astimezone().isoformat()
332332+333333+ if not cast_path.exists():
334334+ stderr = result.stderr.strip()
335335+ if stderr:
336336+ raise QaError(stderr)
337337+ raise QaError("recording failed before a cast file was produced")
338338+339339+ meta = {
340340+ "name": name,
341341+ "command": command,
342342+ "workdir": str(workdir_path) if workdir_path else None,
343343+ "artifact_dir": str(artifact_dir),
344344+ "cast": str(cast_path),
345345+ "capture_input": capture_input,
346346+ "headless": should_headless,
347347+ "window_size": effective_window_size,
348348+ "idle_time_limit": idle_time_limit,
349349+ "started_at": started_at,
350350+ "finished_at": finished_at,
351351+ "command_exit_code": result.returncode,
352352+ "stderr": result.stderr,
353353+ }
354354+ meta_path.write_text(json.dumps(meta, indent=2, sort_keys=True) + "\n")
355355+ return meta
356356+357357+358358+def analyze_cast(
359359+ *,
360360+ cast_path: Path,
361361+ output_dir: Path | None,
362362+ snapshot_times: list[float],
363363+ milestone_patterns: list[str],
364364+ startup_warn: float,
365365+ startup_fail: float,
366366+ idle_warn: float,
367367+ idle_fail: float,
368368+ semantic_warn: float,
369369+) -> dict[str, Any]:
370370+ if not cast_path.is_file():
371371+ raise QaError(f"cast not found: {cast_path}")
372372+ output_dir = (output_dir or cast_path.parent).resolve()
373373+ output_dir.mkdir(parents=True, exist_ok=True)
374374+ snapshots_dir = output_dir / "snapshots"
375375+ snapshots_dir.mkdir(parents=True, exist_ok=True)
376376+377377+ header, events = load_cast(cast_path)
378378+ total_duration = events[-1].time if events else 0.0
379379+ output_events = [event for event in events if event.kind == "o"]
380380+ output_times = [event.time for event in output_events]
381381+ first_output_time = output_times[0] if output_times else None
382382+ last_output_time = output_times[-1] if output_times else None
383383+ exit_event = next((event for event in events if event.kind == "x"), None)
384384+ exit_time = exit_event.time if exit_event else total_duration
385385+386386+ final_txt = output_dir / "final.txt"
387387+ convert_cast_to_text(cast_path, final_txt)
388388+389389+ thresholds = {
390390+ "startup_warn": startup_warn,
391391+ "startup_fail": startup_fail,
392392+ "idle_warn": idle_warn,
393393+ "idle_fail": idle_fail,
394394+ "semantic_warn": semantic_warn,
395395+ }
396396+397397+ findings: list[dict[str, Any]] = []
398398+ startup_duration = first_output_time if first_output_time is not None else total_duration
399399+ startup_severity = severity_for_duration(startup_duration, startup_warn, startup_fail)
400400+ if startup_severity != "pass":
401401+ findings.append(
402402+ finding(
403403+ startup_severity,
404404+ "startup_delay",
405405+ f"First output took {format_seconds(startup_duration)}.",
406406+ )
407407+ )
408408+409409+ gaps = build_no_output_gaps(output_times, total_duration)
410410+ idle_gaps = []
411411+ max_no_output_gap = 0.0
412412+ longest_gap = None
413413+ for gap in gaps:
414414+ max_no_output_gap = max(max_no_output_gap, gap["duration"])
415415+ if longest_gap is None or gap["duration"] > longest_gap["duration"]:
416416+ longest_gap = gap
417417+ if gap["kind"] == "startup":
418418+ continue
419419+ gap_severity = severity_for_duration(gap["duration"], idle_warn, idle_fail)
420420+ gap["severity"] = gap_severity
421421+ if gap_severity != "pass":
422422+ findings.append(
423423+ finding(
424424+ gap_severity,
425425+ "idle_gap",
426426+ (
427427+ f"No output for {format_seconds(gap['duration'])} "
428428+ f"between {format_seconds(gap['start'])} and {format_seconds(gap['end'])}."
429429+ ),
430430+ )
431431+ )
432432+ idle_gaps.append(gap)
433433+434434+ milestones = evaluate_milestones(output_events, milestone_patterns)
435435+ semantic = evaluate_semantic_hangs(
436436+ output_times=output_times,
437437+ total_duration=total_duration,
438438+ milestones=milestones,
439439+ semantic_warn=semantic_warn,
440440+ )
441441+ for candidate in semantic["candidates"]:
442442+ findings.append(
443443+ finding(
444444+ "warn",
445445+ "semantic_hang",
446446+ (
447447+ f"Output continued for {format_seconds(candidate['duration'])} "
448448+ f"without a milestone between {format_seconds(candidate['start'])} "
449449+ f"and {format_seconds(candidate['end'])}."
450450+ ),
451451+ )
452452+ )
453453+454454+ meta = load_optional_json(cast_path.parent / "meta.json")
455455+ if meta and meta.get("command_exit_code") not in (None, 0):
456456+ findings.append(
457457+ finding(
458458+ "fail",
459459+ "command_exit",
460460+ f"Recorded command exited with status {meta['command_exit_code']}.",
461461+ )
462462+ )
463463+464464+ requested_snapshots = [
465465+ snapshot_spec(at, f"at-{format_seconds(at)}") for at in snapshot_times
466466+ ]
467467+ auto_snapshots = []
468468+ if first_output_time is not None:
469469+ auto_snapshots.append(snapshot_spec(first_output_time, "first-output"))
470470+ if longest_gap is not None and longest_gap["duration"] > 0:
471471+ auto_snapshots.append(snapshot_spec(longest_gap["start"], "longest-idle-start"))
472472+ if longest_gap["end"] > longest_gap["start"]:
473473+ auto_snapshots.append(
474474+ snapshot_spec(
475475+ max(longest_gap["start"], longest_gap["end"] - SNAPSHOT_EPSILON),
476476+ "longest-idle-end",
477477+ )
478478+ )
479479+ auto_snapshots.append(snapshot_spec(total_duration, "final"))
480480+ for index, candidate in enumerate(semantic["candidates"], start=1):
481481+ auto_snapshots.append(snapshot_spec(candidate["start"], f"semantic-{index}-start"))
482482+ auto_snapshots.append(
483483+ snapshot_spec(
484484+ max(candidate["start"], candidate["end"] - SNAPSHOT_EPSILON),
485485+ f"semantic-{index}-end",
486486+ )
487487+ )
488488+489489+ snapshot_results = write_snapshots(
490490+ cast_path=cast_path,
491491+ snapshots_dir=snapshots_dir,
492492+ final_txt=final_txt,
493493+ specs=requested_snapshots + auto_snapshots,
494494+ total_duration=total_duration,
495495+ )
496496+497497+ if longest_gap is not None:
498498+ mark_visual_change(
499499+ gap=longest_gap,
500500+ snapshot_results=snapshot_results,
501501+ start_label="longest-idle-start",
502502+ end_label="longest-idle-end",
503503+ )
504504+ for index, candidate in enumerate(semantic["candidates"], start=1):
505505+ mark_visual_change(
506506+ gap=candidate,
507507+ snapshot_results=snapshot_results,
508508+ start_label=f"semantic-{index}-start",
509509+ end_label=f"semantic-{index}-end",
510510+ )
511511+512512+ summary = {
513513+ "verdict": summarize_verdict(findings),
514514+ "total_duration": total_duration,
515515+ "time_to_first_output": first_output_time,
516516+ "time_to_exit": exit_time,
517517+ "output_event_count": len(output_events),
518518+ "max_no_output_gap": max_no_output_gap,
519519+ }
520520+521521+ report = {
522522+ "cast": str(cast_path),
523523+ "header": header,
524524+ "meta": meta,
525525+ "thresholds": thresholds,
526526+ "summary": summary,
527527+ "startup": {
528528+ "duration": startup_duration,
529529+ "severity": startup_severity,
530530+ },
531531+ "idle_gaps": idle_gaps,
532532+ "milestones": milestones,
533533+ "semantic_hang": semantic,
534534+ "snapshots": snapshot_results,
535535+ "final_txt": str(final_txt),
536536+ "findings": findings,
537537+ "report_json": str((output_dir / "report.json").resolve()),
538538+ "report_md": str((output_dir / "report.md").resolve()),
539539+ }
540540+541541+ report_json_path = output_dir / "report.json"
542542+ report_json_path.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n")
543543+ report_md_path = output_dir / "report.md"
544544+ report_md_path.write_text(render_markdown_report(report) + "\n")
545545+ return report
546546+547547+548548+def build_no_output_gaps(output_times: list[float], total_duration: float) -> list[dict[str, Any]]:
549549+ gaps = []
550550+ if not output_times:
551551+ return [
552552+ {
553553+ "kind": "startup",
554554+ "start": 0.0,
555555+ "end": total_duration,
556556+ "duration": total_duration,
557557+ }
558558+ ]
559559+560560+ if output_times[0] > 0:
561561+ gaps.append(
562562+ {
563563+ "kind": "startup",
564564+ "start": 0.0,
565565+ "end": output_times[0],
566566+ "duration": output_times[0],
567567+ }
568568+ )
569569+570570+ for start, end in zip(output_times, output_times[1:]):
571571+ gaps.append(
572572+ {
573573+ "kind": "idle",
574574+ "start": start,
575575+ "end": end,
576576+ "duration": end - start,
577577+ }
578578+ )
579579+580580+ if total_duration > output_times[-1]:
581581+ gaps.append(
582582+ {
583583+ "kind": "tail",
584584+ "start": output_times[-1],
585585+ "end": total_duration,
586586+ "duration": total_duration - output_times[-1],
587587+ }
588588+ )
589589+590590+ return gaps
591591+592592+593593+def evaluate_milestones(
594594+ output_events: list[CastEvent],
595595+ patterns: list[str],
596596+) -> list[dict[str, Any]]:
597597+ if not patterns:
598598+ return []
599599+600600+ compiled = [re.compile(pattern) for pattern in patterns]
601601+ hits = [None] * len(compiled)
602602+ transcript = ""
603603+604604+ for event in output_events:
605605+ transcript += normalize_output_text(str(event.data))
606606+ for index, regex in enumerate(compiled):
607607+ if hits[index] is None and regex.search(transcript):
608608+ hits[index] = event.time
609609+610610+ result = []
611611+ for pattern, hit in zip(patterns, hits):
612612+ result.append(
613613+ {
614614+ "pattern": pattern,
615615+ "matched": hit is not None,
616616+ "time": hit,
617617+ }
618618+ )
619619+ return result
620620+621621+622622+def evaluate_semantic_hangs(
623623+ *,
624624+ output_times: list[float],
625625+ total_duration: float,
626626+ milestones: list[dict[str, Any]],
627627+ semantic_warn: float,
628628+) -> dict[str, Any]:
629629+ if not milestones:
630630+ return {
631631+ "status": "not_evaluated",
632632+ "candidates": [],
633633+ }
634634+635635+ milestone_times = sorted(
636636+ milestone["time"] for milestone in milestones if milestone["matched"] and milestone["time"] is not None
637637+ )
638638+ checkpoints = []
639639+ if output_times:
640640+ checkpoints.append(output_times[0])
641641+ checkpoints.extend(milestone_times)
642642+ checkpoints.append(total_duration)
643643+644644+ candidates = []
645645+ for start, end in zip(checkpoints, checkpoints[1:]):
646646+ if end - start < semantic_warn:
647647+ continue
648648+ if not any(start < time <= end for time in output_times):
649649+ continue
650650+ candidates.append(
651651+ {
652652+ "start": start,
653653+ "end": end,
654654+ "duration": end - start,
655655+ }
656656+ )
657657+658658+ return {
659659+ "status": "warn" if candidates else "pass",
660660+ "candidates": candidates,
661661+ }
662662+663663+664664+def write_snapshots(
665665+ *,
666666+ cast_path: Path,
667667+ snapshots_dir: Path,
668668+ final_txt: Path,
669669+ specs: list[dict[str, Any]],
670670+ total_duration: float,
671671+) -> list[dict[str, Any]]:
672672+ results = []
673673+ seen_labels = set()
674674+ for spec in specs:
675675+ label = sanitize_label(spec["label"])
676676+ if label in seen_labels:
677677+ continue
678678+ seen_labels.add(label)
679679+ if label == "final":
680680+ path = final_txt
681681+ else:
682682+ path = snapshots_dir / f"{label}.txt"
683683+ render_snapshot(cast_path, min(spec["time"], total_duration), path)
684684+ results.append(
685685+ {
686686+ "label": label,
687687+ "time": spec["time"],
688688+ "path": str(path.resolve()),
689689+ }
690690+ )
691691+ return results
692692+693693+694694+def mark_visual_change(
695695+ *,
696696+ gap: dict[str, Any],
697697+ snapshot_results: list[dict[str, Any]],
698698+ start_label: str,
699699+ end_label: str,
700700+) -> None:
701701+ start_snapshot = next((item for item in snapshot_results if item["label"] == start_label), None)
702702+ end_snapshot = next((item for item in snapshot_results if item["label"] == end_label), None)
703703+ if not start_snapshot or not end_snapshot:
704704+ return
705705+ start_text = Path(start_snapshot["path"]).read_text()
706706+ end_text = Path(end_snapshot["path"]).read_text()
707707+ gap["screen_changed"] = normalize_snapshot_text(start_text) != normalize_snapshot_text(end_text)
708708+ gap["start_snapshot"] = start_snapshot["path"]
709709+ gap["end_snapshot"] = end_snapshot["path"]
710710+711711+712712+def render_snapshot(cast_path: Path, at: float, output_path: Path) -> None:
713713+ header, events = load_cast(cast_path)
714714+ truncated = []
715715+ for event in events:
716716+ if event.time <= at + SNAPSHOT_EPSILON:
717717+ truncated.append(event.raw)
718718+ else:
719719+ break
720720+ output_path.parent.mkdir(parents=True, exist_ok=True)
721721+ with tempfile.NamedTemporaryFile("w", suffix=".cast", delete=False) as handle:
722722+ temp_cast = Path(handle.name)
723723+ handle.write(json.dumps(header) + "\n")
724724+ for raw in truncated:
725725+ handle.write(json.dumps(raw) + "\n")
726726+ try:
727727+ convert_cast_to_text(temp_cast, output_path)
728728+ finally:
729729+ temp_cast.unlink(missing_ok=True)
730730+731731+732732+def load_cast(path: Path) -> tuple[dict[str, Any], list[CastEvent]]:
733733+ lines = path.read_text().splitlines()
734734+ if not lines:
735735+ raise QaError(f"cast is empty: {path}")
736736+ try:
737737+ header = json.loads(lines[0])
738738+ except json.JSONDecodeError as exc:
739739+ raise QaError(f"invalid cast header in {path}: {exc}") from exc
740740+741741+ events = []
742742+ cumulative = 0.0
743743+ for index, line in enumerate(lines[1:], start=2):
744744+ if not line.strip():
745745+ continue
746746+ try:
747747+ raw = json.loads(line)
748748+ except json.JSONDecodeError as exc:
749749+ raise QaError(f"invalid cast event on line {index}: {exc}") from exc
750750+ if not isinstance(raw, list) or len(raw) < 3:
751751+ raise QaError(f"invalid cast event on line {index}: expected [delta, type, data]")
752752+ delta = float(raw[0])
753753+ cumulative += delta
754754+ events.append(
755755+ CastEvent(
756756+ delta=delta,
757757+ kind=str(raw[1]),
758758+ data=raw[2],
759759+ raw=raw,
760760+ time=cumulative,
761761+ )
762762+ )
763763+ return header, events
764764+765765+766766+def convert_cast_to_text(input_path: Path, output_path: Path) -> None:
767767+ asciinema = shutil.which("asciinema")
768768+ if not asciinema:
769769+ raise QaError("asciinema is required but was not found in PATH")
770770+ output_path.parent.mkdir(parents=True, exist_ok=True)
771771+ result = subprocess.run(
772772+ [asciinema, "convert", "--overwrite", str(input_path), str(output_path)],
773773+ text=True,
774774+ capture_output=True,
775775+ check=False,
776776+ )
777777+ if result.returncode != 0:
778778+ stderr = result.stderr.strip()
779779+ if stderr:
780780+ raise QaError(stderr)
781781+ raise QaError(f"failed to convert cast to text: {input_path}")
782782+783783+784784+def finding(severity: str, kind: str, message: str) -> dict[str, str]:
785785+ return {
786786+ "severity": severity,
787787+ "kind": kind,
788788+ "message": message,
789789+ }
790790+791791+792792+def severity_for_duration(duration: float, warn_after: float, fail_after: float) -> str:
793793+ if duration >= fail_after:
794794+ return "fail"
795795+ if duration >= warn_after:
796796+ return "warn"
797797+ return "pass"
798798+799799+800800+def summarize_verdict(findings: list[dict[str, Any]]) -> str:
801801+ severities = {item["severity"] for item in findings}
802802+ if "fail" in severities:
803803+ return "fail"
804804+ if "warn" in severities:
805805+ return "warn"
806806+ return "pass"
807807+808808+809809+def snapshot_spec(at: float, label: str) -> dict[str, Any]:
810810+ return {
811811+ "time": max(at, 0.0),
812812+ "label": label,
813813+ }
814814+815815+816816+def load_optional_json(path: Path) -> dict[str, Any] | None:
817817+ if not path.is_file():
818818+ return None
819819+ try:
820820+ return json.loads(path.read_text())
821821+ except json.JSONDecodeError:
822822+ return None
823823+824824+825825+def normalize_output_text(data: str) -> str:
826826+ data = ANSI_ESCAPE_RE.sub("", data)
827827+ buffer: list[str] = []
828828+ for char in data:
829829+ if char == "\r":
830830+ buffer.append("\n")
831831+ elif char == "\n" or char == "\t" or char >= " ":
832832+ buffer.append(char)
833833+ elif char == "\b":
834834+ if buffer:
835835+ buffer.pop()
836836+ else:
837837+ continue
838838+ return "".join(buffer)
839839+840840+841841+def normalize_snapshot_text(text: str) -> str:
842842+ return "\n".join(line.rstrip() for line in text.strip().splitlines())
843843+844844+845845+def render_markdown_report(report: dict[str, Any]) -> str:
846846+ lines = ["# TUI QA Report", ""]
847847+ meta = report.get("meta") or {}
848848+ command = meta.get("command")
849849+ if command:
850850+ lines.append(f"Command: `{command}`")
851851+ lines.append("")
852852+ lines.append(f"Cast: `{report['cast']}`")
853853+ lines.append(f"Verdict: **{report['summary']['verdict']}**")
854854+ lines.append("")
855855+856856+ lines.append("## Timings")
857857+ lines.append("")
858858+ lines.append(f"- Total duration: {format_seconds(report['summary']['total_duration'])}")
859859+ lines.append(
860860+ "- Time to first output: "
861861+ + (
862862+ format_seconds(report['summary']['time_to_first_output'])
863863+ if report['summary']['time_to_first_output'] is not None
864864+ else "none"
865865+ )
866866+ )
867867+ lines.append(f"- Time to exit: {format_seconds(report['summary']['time_to_exit'])}")
868868+ lines.append(f"- Output events: {report['summary']['output_event_count']}")
869869+ lines.append(
870870+ f"- Max no-output gap: {format_seconds(report['summary']['max_no_output_gap'])}"
871871+ )
872872+ lines.append("")
873873+874874+ lines.append("## Findings")
875875+ lines.append("")
876876+ if report["findings"]:
877877+ for item in report["findings"]:
878878+ lines.append(f"- {item['severity'].upper()}: {item['message']}")
879879+ else:
880880+ lines.append("- PASS: No warnings or failures.")
881881+ lines.append("")
882882+883883+ if report["milestones"]:
884884+ lines.append("## Milestones")
885885+ lines.append("")
886886+ for milestone in report["milestones"]:
887887+ if milestone["matched"]:
888888+ lines.append(
889889+ f"- Matched `{milestone['pattern']}` at {format_seconds(milestone['time'])}"
890890+ )
891891+ else:
892892+ lines.append(f"- Missing `{milestone['pattern']}`")
893893+ lines.append("")
894894+895895+ if report["semantic_hang"]["status"] == "not_evaluated":
896896+ lines.append("## Semantic Hang")
897897+ lines.append("")
898898+ lines.append("- Not evaluated. Supply one or more `--milestone` regexes.")
899899+ lines.append("")
900900+ elif report["semantic_hang"]["candidates"]:
901901+ lines.append("## Semantic Hang")
902902+ lines.append("")
903903+ for candidate in report["semantic_hang"]["candidates"]:
904904+ screen_note = ""
905905+ if "screen_changed" in candidate:
906906+ screen_note = (
907907+ " screen changed."
908908+ if candidate["screen_changed"]
909909+ else " screen did not materially change."
910910+ )
911911+ lines.append(
912912+ "- "
913913+ + (
914914+ f"{format_seconds(candidate['duration'])} without milestone progress "
915915+ f"from {format_seconds(candidate['start'])} to {format_seconds(candidate['end'])};"
916916+ f"{screen_note}"
917917+ )
918918+ )
919919+ lines.append("")
920920+921921+ lines.append("## Artifacts")
922922+ lines.append("")
923923+ lines.append(f"- Final text: `{report['final_txt']}`")
924924+ lines.append(f"- JSON report: `{report['report_json']}`")
925925+ for snapshot in report["snapshots"]:
926926+ lines.append(
927927+ f"- Snapshot `{snapshot['label']}` at {format_seconds(snapshot['time'])}: "
928928+ f"`{snapshot['path']}`"
929929+ )
930930+931931+ return "\n".join(lines).rstrip()
932932+933933+934934+def sanitize_label(label: str) -> str:
935935+ return re.sub(r"[^A-Za-z0-9._-]+", "-", label).strip("-").lower() or "snapshot"
936936+937937+938938+def format_seconds(value: float | None) -> str:
939939+ if value is None:
940940+ return "none"
941941+ return f"{value:.3f}s"
942942+943943+944944+def infer_window_size() -> str:
945945+ if sys.stdout.isatty():
946946+ size = shutil.get_terminal_size(fallback=(120, 40))
947947+ return f"{size.columns}x{size.lines}"
948948+ columns = os.environ.get("COLUMNS") or "120"
949949+ lines = os.environ.get("LINES") or "40"
950950+ return f"{columns}x{lines}"
951951+952952+953953+if __name__ == "__main__":
954954+ sys.exit(main())