Skip to content

fix(server): read gate and watch coverage per flow, not from the newest run - #1382

Merged
divshekhar merged 1 commit into
reticlehq:mainfrom
drakeo338:claude/1346-fix
Oct 15, 2026
Merged

divshekhar merged 1 commit into
reticlehq:mainfrom
drakeo338:claude/1346-fix

Conversation

@drakeo338

@drakeo338 drakeo338 commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

What & why

Closes #1346.

reticle gate and the reticle watch status line took coverage from the single newest run. A drive run always records flows: [], 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 the watch status 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) and run-store.test.ts (tie-break). Locally the touched test files plus src/judgement/runs pass (201 tests), and pnpm typecheck and the server lint pass. I did not run the full pnpm test:unit or 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 telemetry
  • pnpm gate:install (~15 min) — touched reticle init, vite-plugin, next, or babel-plugin
  • pnpm test:e2e:desktop (~3 min) — touched adapters/realm/electron, adapters/realm/tauri, or desktop capture
  • None of the above tiers apply to this change

Checklist

  • Every commit is signed off (git commit -s) — CI's DCO check fails the PR without it. Already pushed? git rebase --signoff origin/main && git push --force-with-lease
  • Tests added/updated (RED → GREEN); the change is covered by a test that would fail without it
  • No any, no free strings (wire strings live in @reticlehq/core), no non-null !
  • No console.log or internal tracking codes left in the diff
  • Each changed file is under the 1000-line cap
  • Docs updated, and a user-facing change adds a new file under .changes/ (never edit CHANGELOG.md — that file is assembled at release time, and editing it is what makes PRs conflict; format in .changes/README.md)
  • Security-affecting? Auth/redaction/trust-boundary changes keep the localhost-only, no-app-data-leaves-the-machine, no-arbitrary-JS posture (usage telemetry stays anonymous + opt-out per docs/telemetry.md) and are covered by a test

@drakeo338
drakeo338 requested a review from divshekhar as a code owner October 5, 2026 08:30
@github-actions

github-actions Bot commented Oct 5, 2026

Copy link
Copy Markdown

Thanks for your first pull request to Reticle! A maintainer aims to review within two days. Before then, pnpm verify locally catches most of what CI will, and CONTRIBUTING.md explains the rest. If CI does not start, a maintainer needs to approve it for first-time contributors; that is normal.

@github-actions github-actions Bot added area/server The daemon, MCP server and CLI: server/ area/docs Documentation and docs site labels Oct 5, 2026
@greptile-apps

greptile-apps Bot commented Oct 5, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 1/5

[Medium risk] Changes how the gate and watch status decide which flows are verified.

The PR is not ready to merge because gate and watch can still give incorrect coverage decisions for realistic edit and run histories.

Findings

  1. P1 Unrelated edits invalidate passes ▶
  2. P1 Deleted files preserve stale passes ▶
  3. P1 Run pruning erases coverage ▶
  4. P2 Tied results depend on listing ▶

Summary

The PR changes gate and watch coverage from the newest run’s flows to each flow’s newest recorded result, and adds edit-time freshness checks and focused tests.

  • Flowless runs no longer immediately hide earlier replay results.
  • Freshness and retained-history edge cases still produce incorrect gate or watch status.
Diagram
%%{init: {'theme': 'neutral'}}%%
flowchart LR
  A[Retained run artifacts] --> B[Newest result per flow]
  B --> C[Passing flow times]
  D[Changed files and affected flows] --> E[Edit freshness check]
  C --> E
  E --> F[Gate decision]
  E --> G[Watch status]
Loading

Reviews (1) · Last reviewed commit: "fix(server): count a pass only if it is ..."

Comment on lines +235 to +242
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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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.

Comment on lines +235 to +242
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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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.

Comment on lines +171 to +175
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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Run pruning erases coverage

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.

Comment on lines +177 to +179
const seen = newest.get(result.name);
if (seen === undefined || read.run.createdAt > seen.createdAt) {
newest.set(result.name, { result, createdAt: read.run.createdAt });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.

@greptile-apps

greptile-apps Bot commented Oct 5, 2026

Copy link
Copy Markdown

Comments Outside Diff

These findings could not be posted inline.

  • P1 Watch forgets stale flows server/src/command/cli/cli-flow-commands.ts:146 ▶

    Watch checks freshness only against files in the current debounce batch. If one edit makes a flow’s pass stale, a later unrelated edit removes that flow from result.affected, so its old pass counts as nominal again without a replay. The startup call also passes empty change lists, so it cannot identify edits made before watch started. Preserve outstanding stale state when producing the status line.

@divshekhar divshekhar left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 with uncovered: ["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:

  1. Restore the check per flow: a pass counts only if its run's createdAt is later than the newest mtime of the changed files in that flow's own sources. Edits outside the flow's sources don't invalidate it, which addresses Greptile's "unrelated edits" P1.
  2. Fail closed for a changed source file that has been deleted: the flow stays uncovered.
  3. Apply the same rule to the watch status line.
  4. Add a test for the exact scenario above.

@drakeo338

Copy link
Copy Markdown
Contributor Author

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 divshekhar left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

  1. Use one matching rule. Match a flow's own sources with the same normalize-and-suffix rule affectedFlows uses (export it and reuse it). Add tests for a repo-root-relative changed path and for an absolute stamp.
  2. Fix watch freshness. watch checks 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.
  3. Restore the comments the size-trim commits removed. bd6aa78 reverted the cli-verify.ts:442 comment to "reticle gate decides from RunStore.latest", which this PR makes false, and the rationale on latestPerFlow, passingFlowTimes and currentPassing is 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.
  4. Reply on Greptile's pruning P1. It fails closed with 500 runs kept, so a note and a follow-up issue are fine.

@drakeo338

Copy link
Copy Markdown
Contributor Author

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 divshekhar left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.ts gets deleted
  • an older util.ts (mtime 500) happens to sit in the working directory
  • reticle gate lib/util.ts returns pass: 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 currentPassing doc.

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.

@drakeo338

Copy link
Copy Markdown
Contributor Author

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 divshekhar left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@drakeo338

Copy link
Copy Markdown
Contributor Author

Rebased onto main with DCO sign-off and force-pushed (8ab70dd). Conflicts in passingFlowNames and run-store.ts (beside #recentRequest) resolved keeping both sides; the per-flow reading now applies main's template credit rule from #1321, with a test (last commit). 7e0cd5c is dropped from this PR and I'll open it separately with its own issue.

@divshekhar divshekhar left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for the rebase. The windows job found a real false green.

  1. 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 \. normalize in server/src/language/flows/change/affected.ts:23 does not change \ to /. So pathsMatch never matches the stamp, and the old pass of the flow still counts after the edit. Add .replace(/\\/g, '/') in normalize. Every check that uses pathsMatch then gets the fix.
  2. Commit 6ff45e73d merges main. The merge queue cannot replay it. Rebase onto main with git rebase origin/main and 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]>
@drakeo338

Copy link
Copy Markdown
Contributor Author

Pushed c23127b: normalize now converts \ to / first, with a test for a C:\repo\src\app.ts stamp against src/app.ts that fails without it; pnpm verify passes. The rebase onto main without the merge commit is ready locally as one signed-off commit; I will force-push it next.

@divshekhar divshekhar left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you. Every point from the last review is done, and CI is green. Merging.

@divshekhar
divshekhar added this pull request to the merge queue Oct 15, 2026
Merged via the queue into reticlehq:main with commit 07d8f49 Oct 15, 2026
40 checks passed
divshekhar added a commit that referenced this pull request Oct 18, 2026
…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]>
tagadearpit pushed a commit to tagadearpit/reticle that referenced this pull request Oct 18, 2026
* 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]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/docs Documentation and docs site area/server The daemon, MCP server and CLI: server/

Projects

None yet

Development

Successfully merging this pull request may close these issues.

gate only reads the newest run, so a drive session hides every earlier flow pass

2 participants