Repository navigation
fix(server): read gate and watch coverage per flow, not from the newest run - #1382
Conversation
|
Thanks for your first pull request to Reticle! A maintainer aims to review within two days. Before then, |
|
| const editedAt = Math.max( | ||
| -Infinity, | ||
| ...changed.map((file) => changedFileModifiedAt(cwd, file) ?? -Infinity), | ||
| ); | ||
| const affectedSet = new Set(affected); | ||
| const passing = new Set<string>(); | ||
| for (const [name, at] of passingAt) { | ||
| if (!(affectedSet.has(name) && at < editedAt)) passing.add(name); |
There was a problem hiding this comment.
Unrelated edits invalidate passes
If two changed files affect different flows, this check applies the later file’s edit time to both. A flow that passed after its own file changed but before the unrelated file changed is marked uncovered. Gate can reject a valid verification, and watch reports a false deviation. Compare each pass with edits to that flow’s own sources.
| const editedAt = Math.max( | ||
| -Infinity, | ||
| ...changed.map((file) => changedFileModifiedAt(cwd, file) ?? -Infinity), | ||
| ); | ||
| const affectedSet = new Set(affected); | ||
| const passing = new Set<string>(); | ||
| for (const [name, at] of passingAt) { | ||
| if (!(affectedSet.has(name) && at < editedAt)) passing.add(name); |
There was a problem hiding this comment.
Deleted files preserve stale passes
For a delete-only change, none of the changed files has an on-disk modification time, so editedAt remains -Infinity and this check keeps every previous pass. If a saved flow names a deleted source file, gate can accept its pre-deletion pass as coverage of the changed state. A missing changed file needs an explicit freshness outcome.
| async latestPerFlow(): Promise<Map<string, { result: RunFlowResult; createdAt: number }>> { | ||
| const newest = new Map<string, { result: RunFlowResult; createdAt: number }>(); | ||
| for (const id of await this.list()) { | ||
| const read = await this.read(id); | ||
| if (!read.ok) continue; |
There was a problem hiding this comment.
Coverage is rebuilt only from run files that still exist. When later runs trigger global pruning, an infrequently replayed flow’s sole passing result can disappear even though that flow has neither failed nor been edited since. Gate then rejects it as uncovered; enough flowless drive runs can therefore recreate the coverage loss this change aims to prevent. Preserve each flow’s latest result independently of run-artifact retention.
| const seen = newest.get(result.name); | ||
| if (seen === undefined || read.run.createdAt > seen.createdAt) { | ||
| newest.set(result.name, { result, createdAt: read.run.createdAt }); |
There was a problem hiding this comment.
Tied results depend on listing
If a pass and a failure for the same flow have the same millisecond-resolution createdAt, this comparison keeps whichever result appears first in the unsorted directory listing. Coverage can therefore depend on listing order rather than a defined tie policy. An explicit stable policy and a conflicting equal-time test would make this edge case predictable.
Comments Outside DiffThese findings could not be posted inline.
|
divshekhar
left a comment
There was a problem hiding this comment.
Thanks, the per-flow reading is the right shape and the tests go red on main. But reverting the freshness check reopened a false green, which I reproduced:
Flows A and B replay green at t=1000. An edit lands. Only B is replayed at t=2000.
On main: the gate fails withuncovered: ["flow-a"], which is right.
On this PR:pass: true, covered 2/2. A's pass from before the edit is counted as covering the edit.
#1346's Expected section requires that a flow's result count only if it is newer than the edit being judged. Greptile's three P1s were problems with how the check was written, not reasons to drop it. Please:
- Restore the check per flow: a pass counts only if its run's
createdAtis later than the newest mtime of the changed files in that flow's ownsources. Edits outside the flow's sources don't invalidate it, which addresses Greptile's "unrelated edits" P1. - Fail closed for a changed source file that has been deleted: the flow stays uncovered.
- Apply the same rule to the
watchstatus line. - Add a test for the exact scenario above.
|
Restored the freshness check per flow in 854023e, scoped to each flow's own sources, with your A and B scenario as a test and the same rule on the watch status line. |
divshekhar
left a comment
There was a problem hiding this comment.
Good progress. The A/B scenario now fails correctly, a deleted source fails closed, and the exact test is in. One false green remains, which I reproduced:
affectedSavedFlows matches changed files to a flow's sources with a normalized path suffix, but the freshness check in currentPassing uses an exact sources.includes(f). In a monorepo the stamp is src/app.ts (the babel plugin makes it relative to the app's cwd), while git reports web/src/app.ts. The flow counts as affected but finds no "own" changed files, so its pass from before the edit still counts: pass:true, covered 2/2. An absolute stamp gives the same result.
- Use one matching rule. Match a flow's own sources with the same
normalize-and-suffix ruleaffectedFlowsuses (export it and reuse it). Add tests for a repo-root-relative changed path and for an absolute stamp. - Fix
watchfreshness.watchchecks freshness only against the current debounce batch, and passes[]at startup. So a flow made stale by an earlier edit reads "nominal" again after an unrelated save or a restart. That's Greptile's outside-diff P1, and it's still valid. - Restore the comments the size-trim commits removed.
bd6aa78reverted thecli-verify.ts:442comment to "reticle gatedecides from RunStore.latest", which this PR makes false, and the rationale onlatestPerFlow,passingFlowTimesandcurrentPassingis gone. The server size budget is nearly exhausted on main itself. That's ours to fix, not yours, so please don't trim code or comments to fit. - Reply on Greptile's pruning P1. It fails closed with 500 runs kept, so a note and a follow-up issue are fine.
|
Pushed 5e0fe1d with one shared path match, watch freshness read from the sources on disk, and the comments restored; the pruning case fails closed at 500 runs, so I left it. |
divshekhar
left a comment
There was a problem hiding this comment.
Good progress. I went through the scenarios from last time with my own probes: monorepo-relative stamp, absolute stamp, and watch after a restart plus an unrelated save. They all fail correctly now. Having one pathsMatch rule for both checks is the right shape, the comments are back, and your answer on pruning holds up, since it fails closed.
There's one false green left, and it's in atChangedPath (server/src/command/cli/cli-flow-commands.ts:201). It drops leading path segments until any file resolves, so a deleted source can pick up an unrelated file's mtime. Repro:
- flow-a is stamped
lib/util.ts, and a pass is recorded at t=1000 lib/util.tsgets deleted- an older
util.ts(mtime 500) happens to sit in the working directory reticle gate lib/util.tsreturnspass: true, uncovered: []
That breaks the "deleted source leaves the flow uncovered" promise in your changelog. Before this PR the helper only fed a non-blocking ledger check. Now it decides whether a pass counts, so it has to be strict.
Suggested fix: use an absolute path as-is, and only drop a leading segment when it matches the end of the working directory:
if (isAbsolute(file)) {
try { return read(file); } catch { return undefined; }
}
const cwdParts = cwd.split(sep);
for (let i = 0; i < parts.length; i += 1) {
if (i > 0 && cwdParts.slice(-i).join('/') !== parts.slice(0, i).join('/')) continue;
try { return read(join(cwd, ...parts.slice(i))); } catch { /* try the next one */ }
}With that, web/src/app.ts still resolves from inside web/, lib/util.ts can no longer fall back to util.ts, and readChangedFile/staleChanged get fixed too because they share the function. Please add the repro above as a test.
Two smaller things on the watch line only (the gate is fine):
- After a restart, a flow whose stamped source was deleted shows as nominal (
✓ 2/2). That comes from the "simply missing is ignored" rule around lines 233 and 252. Counting a missing source as stale would match what the gate does. - A flow with no stamped sources shows nominal at startup whatever was edited before. Nothing can attribute those edits, so just say so in the
currentPassingdoc.
Also, lines 23-24 import from affected.js twice.
The red package-quality check isn't you. Main is already at the size budget, and the raise lands with 3.6.0. label is the Labeler cancelling itself, so ignore that too.
|
Fixed atChangedPath so a leading segment is dropped only when it matches the tail of cwd, added your repro as a test, and made a missing stamped source count as stale on watch. |
divshekhar
left a comment
There was a problem hiding this comment.
Thanks @drakeo338. Every point from the last round holds up, and the new tests fail on main for the right reasons. Three small things: (1) please rebase onto main; both conflicts are mechanical (passingFlowNames in cli-flow-commands.ts, #recentRequest in run-store.ts); (2) sign off the commits for DCO; (3) please move 7e0cd5c0c (the flowsWithoutSources message) into its own PR with its own issue. It is a separate behaviour change and has not been reviewed yet. Then this is ready.
7e0cd5c to
8ab70dd
Compare
|
Rebased onto main with DCO sign-off and force-pushed (8ab70dd). Conflicts in |
divshekhar
left a comment
There was a problem hiding this comment.
Thank you for the rebase. The windows job found a real false green.
cli-gate-per-flow.test.ts:257("an absolute source stamp matches a repo-relative changed path") fails on Windows.join(dir, 'src', 'app.ts')makes a stamp with\.normalizeinserver/src/language/flows/change/affected.ts:23does not change\to/. SopathsMatchnever matches the stamp, and the old pass of the flow still counts after the edit. Add.replace(/\\/g, '/')innormalize. Every check that usespathsMatchthen gets the fix.- Commit
6ff45e73dmergesmain. The merge queue cannot replay it. Rebase onto main withgit rebase origin/mainand force-push.
Then run pnpm verify.
…st run A pass now counts only if its run is later than the newest mtime of the changed files in that flow's own sources, and a deleted changed source fails closed. affectedFlows and the freshness check share one normalized-suffix path matcher, which also accepts Windows stamps written with backslashes. The watch status line reads each flow's sources from disk, and the per-flow reading keeps main's template credit from reticlehq#1321. Fixes reticlehq#1346 AI-assisted (Claude Code). Signed-off-by: drakeo338 <[email protected]>
|
Pushed c23127b: |
c23127b to
7d28274
Compare
divshekhar
left a comment
There was a problem hiding this comment.
Thank you. Every point from the last review is done, and CI is green. Merging.
…ync after the rebase - Main now syncs when .reticle changes, with a ten-minute backstop. The branch's 30-second fingerprint poll and its own backoff did the same job, so they are removed. The signed-in gate, the refused-key stop and the background report stay. - The signed-in gate applies to the background daemon only. start()'s one cycle on close keeps main's rule, so a self-booting reticle verify in CI still sends with RETICLE_API_KEY. - Main reads each flow's newest result for the gate since #1382, so the changelog entry for the same fix from this branch is removed. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> Signed-off-by: Divyanshu Shekhar <[email protected]>
* fix(server): a worktree keeps its own tab, flows and runs Every git worktree of a project declares the same projectId. Two defects came from that: 1. A page served from a linked worktree wrote its intents, flows and runs into the main checkout when discovery knew only the main checkout. The gate in the worktree then saw nothing covered. 2. An id-less call from an agent in worktree A could land on worktree B's tab and act on code that agent never changed. Read the checkout from the .git entry (core checkoutOf, no git process). Move the matched project folder into the checkout that served the page. Scope id-less calls to the calling agent's checkout, which comes from its MCP proxy directory; refuse and name the tabs when every tab comes from another worktree. An explicit sessionId still reaches any tab. Repositories without worktrees skip every lookup. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> Signed-off-by: Divyanshu Shekhar <[email protected]> * fix(core): pages on a .localhost name connect, and map to their worktree A dev proxy such as portless serves each app, and each git worktree of it, at https://<branch>.<app>.localhost. Reticle read those pages as remote: the SDK refused to start and the bridge refused the origin. RFC 6761 reserves every .localhost name for loopback, and nobody can register one, so accept them as loopback. The Vite plugin now announces the proxy URL (PORTLESS_URL) when one runs the dev server. The daemon matches a page to its dev server by that URL before it matches by port, because the proxy serves every worktree on one port. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> Signed-off-by: Divyanshu Shekhar <[email protected]> * fix(server): a new worktree is linked and knows the main checkout's flows `.reticle/` is often git-ignored, so `git worktree add` copies no cloud.json and no flow that was not committed yet. A new worktree was unlinked (no sync, no Harness) and replay-first started from zero. When a linked worktree has no cloud link, read the main checkout's link. List and load flows from the main checkout as a fallback. Write flows only into the worktree, so they travel with its branch. core: mainCheckoutTwin(path, readFile), the same path in the main checkout. server: readTextOrUndefined, a sync reader for it. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> Signed-off-by: Divyanshu Shekhar <[email protected]> * fix(server): scope the real daemon's id-less calls to the agent's worktree The worktree scope was wired into `start` only. The daemon that agents use starts through `startDaemon`, so the scope never ran there. Every unit test passed. The new gate found it on its first run (5 of 8 checks red). Add `pnpm gate:worktrees`: real `git worktree add`, the real `reticle mcp` proxy launched in each worktree, one real daemon, and one tab per worktree on the SDK wire protocol. It checks that each agent reaches only its own tab, that an agent with no tab of its own is refused, and that an explicit sessionId still reaches any tab. CI runs it in the multi-agent job, and the release checklist runs it. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> Signed-off-by: Divyanshu Shekhar <[email protected]> * fix(server,vite-plugin): a worktree with no .reticle.json keeps the project's id and port A git worktree whose .reticle.json is git-ignored, or not committed yet, has none. Its agent read no projectId and no port, and its dev server derived a new id from the worktree path. One app became two projects on two daemons, and the agent attached to the empty one. When no .reticle.json is found at or above the directory, read the main checkout's. Only the config values come from there; the worktree stays the project folder. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> Signed-off-by: Divyanshu Shekhar <[email protected]> * feat(server): the session list names each tab's git worktree Work in a worktree is invisible unless an agent can see which tab runs which branch. A tab that a linked worktree serves now carries `worktree` (folder and branch) in the session list. Only an agent whose repository has worktrees pays for the lookup. SKILL.md says what to do when every tab comes from another worktree. Add the changelog entry for the worktree work. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> Signed-off-by: Divyanshu Shekhar <[email protected]> * feat(core,server): a run carries the git branch that served its tab Several agents can drive one project at once, each in its own git worktree. The dashboard showed their runs as one stream. A drive run now carries `project.branch`, read from the checkout of the session's artifact root. The field is optional and additive. The run schema's `project` is a plain object, so an older reader strips the field and keeps the run. Also move three doc comments back above the declarations they describe. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> Signed-off-by: Divyanshu Shekhar <[email protected]> * fix(core,server): scope bare-clone worktrees, and a port that moves to another worktree An adversarial run against the real daemon found three gaps in worktree scoping: - A tab was placed once per origin for 30 seconds. When a port moved to another worktree, the agent in the old worktree drove the new code and the right agent was refused. A tab is now placed once per connection. A reload is a new connection. - The worktrees of a bare clone have no main checkout. They looked like separate repositories, so no call was scoped. Checkouts now compare the shared git directory (commonDir), as git does. - A tab that could not be placed turned the scope off, so freshness could pick another worktree's tab. Tabs known to come from another worktree are now dropped, and unknown tabs stay. gate:worktrees now also moves a port from one worktree to another. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> Signed-off-by: Divyanshu Shekhar <[email protected]> * fix(server): every run lands in, and names, the worktree that served its page Found by driving three agents in real worktrees, one created after the others: - With worktrees inside the main checkout (`.claude/worktrees/a`), discovery finds several checkouts that declare the project. A page served from a worktree outside all of them fell back to the daemon's own folder, so agent C's runs were written into worktree A. When the candidates are all checkouts of one repository, the work now goes to the checkout that served the page. - The artifact resolver remembered each origin's serving folder for the daemon's whole life. A port that moved to another worktree kept sending runs to the old one. It now asks once for each connection. - Only a hand-driven run carried `project.branch`. A replay run and a `reticle verify` run had none. The branch is now stamped where every run is written, which replaces the session-end path. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> Signed-off-by: Divyanshu Shekhar <[email protected]> * fix(core,server): sync worktree runs, keep the rebinding guard strict, match Windows paths A review of the worktree branch found four defects. Each fix starts with a test that failed without it. - Cloud sync visited only the folders of known projects, which name the main checkout. Runs saved in a worktree were not sent. Sync now also visits the same folder in each worktree that has a .reticle/. - isLoopbackHostname accepted every *.localhost name. The HTTP plane uses it as its guard against DNS rebinding, and a browser that sends those names to DNS lets a hostile network answer them. The strict rule is back. Only isLocalPage accepts *.localhost, for pages. - A worktree read the main checkout's flows also when git tracks .reticle/. Then a flow that the branch deleted came back. It now reads them only when git ignores .reticle/. - heal and replay learning loaded a flow from the main checkout and then could not write it. The change now goes to the worktree. - On Windows, the proxy reads C:\wt\a and Vite announces C:/wt/a, in any case. Checkout paths now match without case and separators. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> Signed-off-by: Divyanshu Shekhar <[email protected]> * fix(server,next): place Next.js and portless tabs, and stamp no branch from Reticle's own storage - @reticlehq/next now announces its dev server as the Vite plugin does: port, folder, and the portless URL when one serves it. It also reads the main checkout's .reticle.json when a worktree has none. Before, a Next worktree dialled the default port. - Behind portless, the page's port is the proxy's, and lsof on it named the proxy's folder. The daemon now reads the portless route table to find the dev server's own port. A name with no route on the proxy port stays unplaced. - A run that matched no project is kept under ~/.reticle/unmatched. When the home folder is a git repository, the run named its branch. A folder inside Reticle's own storage now gives no branch. - A test pins that a worktree saves no second copy of a journey that the main checkout already named. RunStore also gets all(), which the next commit uses. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> Signed-off-by: Divyanshu Shekhar <[email protected]> * fix(server): the gate reads each flow's newest result, and replay takes its flow alias - reticle gate read only the newest run. A hand drive saves a run with no flow results when its session ends, so a run written after a passing replay hid that replay and the gate failed. The gate now takes the newest result of each flow from any run. - reticle_flow_replay named `flow` as an alias for `flowName` but still required `flowName`, so { flow: "checkout" } failed schema validation. flowName is now optional and the handler reads either. A new test checks every tool: a field named as an alias must stand in for a field the schema does not require. - The eight copies of the `flow` alias field in flow-tools.ts now share one constant, which keeps the file under the line limit. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> Signed-off-by: Divyanshu Shekhar <[email protected]> * feat(init,server): capture each prompt through agent hooks, and sync only while signed in The user's request is what a verdict is checked against. An agent relayed it only when it remembered to. Now `reticle init` installs a project-level prompt-submit hook for each agent it finds with one: Claude Code, Cursor, Codex CLI, Gemini CLI, Windsurf, VS Code Copilot and Copilot CLI, and Factory Droid. The hook runs `reticle hook prompt`. That command redacts secrets, splits and classifies the statements with the recorder that `reticle_intent` uses, and writes .reticle/request.json and requests.jsonl. It always exits 0. It runs before telemetry, so no event learns that a prompt existed. A test proves this. Agents with no prompt hook rely on the MCP instructions. These now tell the agent to declare the request verbatim, once for each user request. The daemon's background sync now sends only when the project is linked by its own cloud.json and somebody is signed in to that address. It never takes the address from the environment. It stops on logout, and on a 401 or 403 until the next sign-in. It backs off on network errors, with jitter. It looks every 30 seconds, and makes no network call when nothing local changed. Opt-outs: "backgroundSync": false and RETICLE_BACKGROUND_SYNC=0. `reticle doctor` shows both features. ReticleEnv moves to its own module, because constants.ts reached the line cap. The export is unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> Signed-off-by: Divyanshu Shekhar <[email protected]> * fix(init,server): pin the prompt hook to its version, and keep a run's request when a newer prompt arrives Found by driving the hooks with the real agents. Unpinned, `npx @reticlehq/server` ran the version its cache held. On this machine that was 3.4.0, which has no `hook` command, so every prompt was lost while the hook still exited quietly. The hook now runs `npx -y @reticlehq/server@<version>`, and a re-run of init replaces an older pinned entry. The prompt hook rewrites request.json on every prompt. A tab's run is written again after every verdict, and a rewrite after a newer prompt found no request of its own. It dropped the request it already held, both on this machine and on the platform. A rewrite now keeps the request the run already carries. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> Signed-off-by: Divyanshu Shekhar <[email protected]> * fix(server): background sync says nothing new for a project that was never linked A daemon knows every project opened on the machine. At start it wrote one `unlinked` line for each, next to the `reticle_cloud_unsynced_root` line that already says the same. A root now starts as unlinked, so only a change is logged. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> Signed-off-by: Divyanshu Shekhar <[email protected]> * test(server): the held-request run carries a project, as every run on this branch does Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> Signed-off-by: Divyanshu Shekhar <[email protected]> * docs(readme): put the Claude Code plugin first in the install step Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> Signed-off-by: Divyanshu Shekhar <[email protected]> * docs(examples): use a level-two heading in the Next example's agent rules Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> Signed-off-by: Divyanshu Shekhar <[email protected]> * fix(server): keep main's sync schedule and start()'s env-keyed exit sync after the rebase - Main now syncs when .reticle changes, with a ten-minute backstop. The branch's 30-second fingerprint poll and its own backoff did the same job, so they are removed. The signed-in gate, the refused-key stop and the background report stay. - The signed-in gate applies to the background daemon only. start()'s one cycle on close keeps main's rule, so a self-booting reticle verify in CI still sends with RETICLE_API_KEY. - Main reads each flow's newest result for the gate since reticlehq#1382, so the changelog entry for the same fix from this branch is removed. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> Signed-off-by: Divyanshu Shekhar <[email protected]> * fix(server): name a worktree's twins by the long path on Windows, and size the prompt history from the open file - worktreeTwins compared a caller's path with the paths git writes. On Windows one folder can be RUNNER~1 or runneradmin, so a short-form caller was named as its own twin. Both sides are now compared in the long form, and the test builds its folders in the long form. - The prompt hook checked the history file's size by path and then renamed it. It now reads the size from the open file (CodeQL js/file-system-race). Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> Signed-off-by: Divyanshu Shekhar <[email protected]> * fix(server): rotate the prompt history through one open file CodeQL still saw a race: the path was opened to read the size, then appended to by path. The size, the copy to requests.1.jsonl, the truncate and the append now all use the one open file. A new test covers the rotation, which had none. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> Signed-off-by: Divyanshu Shekhar <[email protected]> --------- Signed-off-by: Divyanshu Shekhar <[email protected]> Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
What & why
Closes #1346.
reticle gateand thereticle watchstatus line took coverage from the single newest run. A drive run always recordsflows: [], so after one drive session every flow that had just replayed green showed as uncovered and the gate exited 1. Coverage is now read per flow from each flow's newest result, a run with no flows neither adds nor erases a result; equal-time results break on the run id, so the answer does not depend on listing order. A flow whose newest result is a failure still counts as uncovered. A pass also counts only if its run is later than the newest mtime of the changed files in that flow's own sources: edits outside the flow's sources do not invalidate it, a changed source file that was deleted leaves the flow uncovered, and thewatchstatus line applies the same rule. A flow with no recorded sources cannot be attributed, so every changed file counts for it.How it was verified
Added tests in
cli-gate-per-flow.test.ts(A and B replay green, an edit lands, only B is replayed: A is uncovered; an edit outside a flow's sources does not invalidate it; a deleted changed source leaves its flow uncovered; the status line follows the same rule) andrun-store.test.ts(tie-break). Locally the touched test files plussrc/judgement/runspass (201 tests), andpnpm typecheckand theserverlint pass. I did not run the fullpnpm test:unitor the root lint after the last change; CI does. Two guard tests (front-door,every-build-resolves-its-aliases) failed in the worker's local run; the first fails the same without these changes, the second I have not rechecked, so CI is the check. AI-assisted (Claude Code).Gates run
pnpm lint && pnpm typecheck && pnpm test:unit(~2 min — always)pnpm test:e2e(~8 min) — touched the tool surface,core, an observer, or telemetrypnpm gate:install(~15 min) — touchedreticle init,vite-plugin,next, orbabel-pluginpnpm test:e2e:desktop(~3 min) — touchedadapters/realm/electron,adapters/realm/tauri, or desktop captureChecklist
git commit -s) — CI's DCO check fails the PR without it. Already pushed?git rebase --signoff origin/main && git push --force-with-leaseany, no free strings (wire strings live in@reticlehq/core), no non-null!console.logor internal tracking codes left in the diff.changes/(never editCHANGELOG.md— that file is assembled at release time, and editing it is what makes PRs conflict; format in.changes/README.md)docs/telemetry.md) and are covered by a test