Last updated: 2026-07-04
Owner: Sparsh (CEO)
Status key: [ ] = pending · [x] = shipped · [~] = in progress
This document is the authoritative backlog for everything not yet shipped. Each item includes what it is, why it matters, and how to implement it. Update the date at the top whenever this file is edited.
What: Connect the TryLapse GitHub repo to Railway via CLI, set all required environment variables, and verify the dashboard is publicly accessible at a stable URL.
Why: The product currently only runs on localhost. No investor, design partner, or beta user can see it. Every other item in this backlog is invisible until this is done. This is the single highest-leverage unblocked task.
Current state: Dockerfile, railway.json, and docker-entrypoint.sh all exist and are correct. The image builds the Vite frontend, embeds it in the Python package static dir, seeds the demo artifacts volume on first boot, and starts rehearse serve --host 0.0.0.0 --port $PORT. The Railway CLI is installed locally (railway 4.66.1). Blocked only on Railway login + railway up.
How:
- In terminal:
railway login(opens browser auth) cd /Users/sparshnagpal/Desktop/projects/TryLapse && railway init— create new Railway project named "TryLapse"railway volume add— add a persistent volume mounted at/data/artifacts(run data survives redeploys)- Set env vars via Railway dashboard or CLI:
REHEARSE_JWT_SECRET=<64-char hex — run: openssl rand -hex 32> DEEPSEEK_API_KEY=<key> NVIDIA_NIM_API_KEY=<key> REHEARSE_ADMIN_EMAILS=0sparsh2@gmail.com REHEARSE_EMAIL=<target site login email> REHEARSE_PASSWORD=<target site login password> REHEARSE_CORS_ORIGIN=https://<railway-domain>.up.railway.app railway up— triggers first deploy; watch build logs- Verify:
curl https://<domain>/api/health→{"status":"ok"} - Open dashboard URL → confirm demo runs are visible
Acceptance: Public URL returns the dashboard with at least one real completed run visible.
What: Trigger a new rehearsal run against faculty-dashboard-eight.vercel.app (Argyle) or the enterprise config after Railway is live, so the dashboard shows evidence dated today rather than weeks ago.
Why: Investors and design partners look at the "last run" timestamp. A run from weeks ago reads as abandoned product, not active development.
How:
- After Railway is live, POST to
/api/jobswith the argyle config, or use the dashboard trigger UI - Monitor via admin dashboard → live jobs section
- Verify run completes, scorecard has current date, evidence artifacts are accessible
- Optionally set a Railway cron to run weekly against the demo target
Root problem: the crawler follows links. That is not the same as covering the app. These items close the gap between "pages visited" and "app graph covered."
Shipped 2026-07-04. route_graph.py created — unified URL-to-template normalizer with version-segment detection (v1/v2 → {version}) and RouteTemplateGraph with Jaccard-based semantic merging (threshold 0.85). Replaces the duplicated inline implementations in both crawler.py and deep_crawler.py. _url_pattern() in deep_crawler.py now delegates to route_graph.url_to_template().
What: After each journey step executes, re-scan the link/route graph. Any new routes that appear after the action that weren't present before = state-dependent routes. Add them to the crawl queue tagged with the action sequence that reveals them.
Why: Checkout confirmation pages, onboarding step 3, empty-state screens, post-purchase flows — these pages only exist after specific user actions. A link-following crawler never reaches them. They are exactly the pages most likely to have bugs (high-stakes, low-traffic, rarely tested).
How:
- Before each step, snapshot
document.querySelectorAll('a[href]')→ set of hrefs - After step completes, snapshot again. Diff = new routes.
- Tag new routes with
{ "discovered_via": [list of steps], "type": "state_dependent" } - Add to crawl queue with the prerequisite step sequence stored
- In coverage report:
"state_dependent_routes_discovered": N
Files: crawler.py, route_graph.py
What: During the crawl phase, intercept all outgoing network requests. Extract API endpoint patterns. Cross-reference with the frontend route graph. Endpoints with no corresponding frontend route = "dark paths."
Why: The frontend only reveals what someone intentionally linked. Backend API endpoints reveal what the app actually does. Dark paths are often admin functions, legacy flows, or partially-built features — all high-risk areas.
How:
- Attach
page.on('request', ...)handler during crawl phase - Collect all
fetch/XHR requests: method, URL, status - After crawl, cluster API endpoints by path pattern
- Cross-reference with discovered frontend routes — routes with no frontend equivalent are dark paths
- Include in crawl output:
{ "api_endpoints_observed": 34, "dark_paths": 3, "dark_path_urls": [...] }
Files: crawler.py, evidence.py
What: Replace "N routes visited" with a real coverage metric: X% of reachable app graph covered. Show which subgraphs were not reached.
Why: "Visited 47 routes" means nothing to an engineering team. "Covered 78% of the reachable app graph; these 3 subgraphs were not reached" is actionable and builds trust.
How:
- Build a directed graph: nodes = unique route templates, edges = navigation paths between them
- From entry point, compute all reachable nodes via BFS
- Track which reachable nodes were actually visited
- Coverage =
visited / reachable * 100 - Expose:
GET /api/runs/{id}/coverage→{ "coverage_pct": 78, "reachable_templates": 15, "visited_templates": 12, "uncovered": [...] } - Frontend: coverage ring on run detail page
Files: route_graph.py, analysis_export.py, runs.$runId.tsx
What: If the crawler navigates to a URL and the resulting page contains <input type="password">, mark that route as auth-gated regardless of HTTP status code.
Why: Many apps return HTTP 200 for unauthenticated users but serve a login page. The crawler currently classifies these as "visited" when they're actually auth-blocked.
How:
- After every navigation, check:
page.query_selector('input[type="password"]') is not None - If true, mark route as
auth_gated: true - Do not count auth-gated routes as "covered" in the coverage metric
- If auth credentials are configured, re-crawl those routes with auth state applied
Files: crawler.py, route_graph.py
What: Detect URL patterns like /users/:id, /products/:slug. Store as route templates, not instances. When testing, use synthetic valid IDs drawn from prior crawl responses.
Why: Without this, 500 user profile pages create 500 entries in the route graph, each identical. We want to test the template once with a known-valid ID.
How:
- After crawl, cluster URLs by structure (already done in
route_graph.py) - During XHR capture (A3), harvest real IDs from API responses — valid synthetic test values
- Store:
{ "template": "/users/{id}", "instances": [...], "synthetic_test_id": "123" } - Journey runner uses
synthetic_test_idwhen constructing test URLs
Files: route_graph.py, crawler.py
What: Confirm that the existing flake_rate field and _mark_flaky_steps logic in evidence.py/heuristics.py is correctly gating: a finding is only filed if it reproduces in ≥2 of 3 seeds. Write a test that proves the gate fires correctly.
Why: The code was written but never explicitly end-to-end tested. If the gate is misconfigured, we either file false positives (destroy trust) or miss real bugs (miss coverage). At <3% flake rate we can claim reliability.
How:
- Read
evidence.py:_mark_flaky_stepsandheuristics.pyflake-rate check - Write
tests/test_flake_gate.py: mock 3 seed runs where a step fails in 1/3, 2/3, and 3/3 cases; assert finding only filed for ≥2/3 - Run the test; if it fails, fix the gate
Files: evidence.py, heuristics.py, tests/test_flake_gate.py
The synthesizer is currently the weakest link: LLM calls where deterministic code should live, no full semantic deduplication, no calibration loop.
What: Replace current string-matching finding dedup with sentence-transformer embeddings. Two findings with cosine similarity > 0.85 are treated as the same bug. LLM only writes the merged description after dedup.
Why: The same bug surfaced by 3 personas creates 3 duplicate findings. Users see noise. And if dedup uses an LLM, running the synthesizer twice on the same findings produces different dedup decisions — unacceptable for a testing tool.
Note: TFIDFEmbedder in embeddings.py exists and handles finding dedup with Jaccard + TF-IDF cosine. This item upgrades to full sentence-transformer embeddings (all-MiniLM-L6-v2) for higher accuracy, or confirms TF-IDF is sufficient.
How:
- Evaluate whether
TFIDFEmbedderfalse-positive/negative rate is acceptable by running on real run data - If not:
pip install sentence-transformers, embed withall-MiniLM-L6-v2, compute cosine similarity matrix - Group findings where similarity > 0.85 into clusters
- For each cluster, take the finding with most evidence as canonical; LLM writes merged description
- Store
merged_from: [finding_id_1, finding_id_2]on the canonical finding
Files: synthesizer.py, embeddings.py
What: Replace LLM-determined severity (P0/P1/P2/P3) with a pure deterministic function. LLM only writes the human-readable explanation for why the severity was assigned.
Why: Same evidence → different severity on different runs is a fundamental correctness failure. If a developer sees a finding flip from P0 to P1 between runs without any code change, they stop trusting the tool immediately.
Decision tree:
P0 = journey-blocking failure with step evidence (step failed, no recovery)
P1 = journey completes but a significant defect observed with evidence
P2 = non-blocking observation with evidence
P3 = observation without direct evidence (hypothesis)
How:
- Create
severity.pywithclassify_severity(finding: dict) -> strpure function - Inputs:
finding_type,evidence_count,journey_impact(blocked/degraded/completed),reproduction_count(out of 3 seeds) - Map to P0/P1/P2/P3 deterministically via if/elif tree
- LLM call: given severity + evidence, write
severity_explanation(one sentence) - Replace all LLM-determined severity in
synthesizer.pywithclassify_severity()
Files: severity.py (new), synthesizer.py
Shipped 2026-07-04. baseline_store.py created — computes rolling 90-run z-scores for readiness, flakeRate, blockerCount, issueCount, stepCount, durationSec, pagesCrawled, plus per-dimension scores. Wired into analysis_export.py; result stored as summary.baseline in the bundle JSON.
What: For every run after the first, the primary dashboard view shows: new findings, resolved findings, score delta per axis, new P0s since last run. The full scorecard is secondary.
Why: Returning users are not asking "what is the state?" They are asking "what changed since Tuesday?" Showing a static scorecard every time trains users to ignore it.
Note: _cross_run_delta() exists in analysis_export.py. The backend computation may be partially done. Frontend panel is missing entirely.
How:
- Verify
_cross_run_delta()output shape inanalysis_export.py— confirm it's wired into the bundlesummary - If not wired: call it and store as
summary.delta - Frontend: if
bundle.summary.deltaexists, show delta panel above the full scorecard:- "2 blockers resolved ✓" (green)
- "1 new P1 since last run" (amber)
- Score delta per dimension sparkline
- Full scorecard stays below, collapsed by default when delta panel is shown
Files: analysis_export.py, runs.$runId.tsx
Shipped (pre-existing). finding_outcome_store.py + PUT /api/runs/{run_id}/findings/{id}/outcome endpoint + FindingOutcomeButtons frontend component all exist. Finding outcomes are stored in artifacts/feedback/{slug}.jsonl.
What: Group findings by likely root cause. Three symptoms of a broken checkout flow should be presented as one P0, not three separate findings.
Why: "Fix before you launch" with 7 items is overwhelming. The human-facing rule: never more than 3 items in the top bucket. If there are more P0s, they're symptoms of fewer root causes.
How:
- After deduplication, cluster remaining findings by: same page URL, same journey, same step range (within 2 steps), correlated appearance/disappearance across runs
- Within each cluster, elect the highest-severity finding as the cluster representative
- Store:
{ "cluster_id": "checkout-flow-3", "root_finding": {...}, "related_findings": [...] } - "Fix before you launch" shows cluster representatives, not individual findings
- Clicking a cluster expands to show all related findings
Files: synthesizer.py, clustering.py (new), run-detail.tsx
Shipped 2026-07-04. Backend _write_partial_bundle() already existed. Added: refetchInterval to useRunBundle (polls every 5s when summary.partial === true, stops when run finalizes) + "Analysis in progress — N of M personas complete" banner in runs.$runId.tsx. RunSummary type extended with partial, personasComplete, personasTotal.
Shipped 2026-07-04. seo.py created — checks missing meta description, duplicate titles, noindex on non-admin pages, images missing alt, broken canonical. Called from analysis_export.py via run_seo_checks(seo_snapshots). SEO snapshots collected during crawl in deep_crawler.py via SEO_EXTRACTION_SCRIPT.
Shipped 2026-07-04. check_and_dismiss_consent_banner() added to browser.py — detects fixed/sticky overlays covering ≥15% viewport with consent keywords, attempts auto-dismiss via 14 selectors. Result stamped on snap.note as [consent-banner:dismissed] or [consent-banner:blocked]. Heuristic in heuristics.py raises P1 if blocked, notes delight if dismissed.
Root concern: partial states must be impossible. State isolation must be complete. LLM calls must be confined to the right boundary.
Shipped (pre-existing). _cleanup_stale_artifacts() exists in runner.py at line ~99. Scans for *.tmp* files before run start and removes them.
What: Verify in code (and add a test) that every persona always gets a fresh browser.new_context() call. Make it structurally impossible to reuse a context between personas.
Why: Context reuse is the primary source of state bleed. Cookies, localStorage, service workers, cached responses from persona 1 leak into persona 2. This produces findings that are artifacts of test ordering rather than real bugs.
How:
- Read
browser.py:BrowserSessionand_ThreadSession— verify context is created fresh per persona - Write
tests/test_context_isolation.py: run two personas sequentially, verify localStorage from persona 1 is empty in persona 2's context - Add a log line at context creation:
logger.info("New context for persona %s", persona_id)
Files: browser.py, journey_agent.py, tests/test_context_isolation.py
What: After context.close(), assert that no pages from that context are still attached. Log context lifetime.
Why: A context that closes with attached pages is a resource leak. Over a multi-persona run, leaked pages accumulate and cause memory growth.
How:
- In
BrowserSession.__exit__: logcontext closed: created_at={t0}, closed_at={t1}, pages_opened={n} - Store in phase timings:
evidence.phase_timings["context_lifetime_ms"]
Files: browser.py, evidence.py
What: Before closing a browser context, wait for all in-flight network requests to complete (or timeout at 5s).
Why: A slow POST from persona 1 (e.g., a form submission that takes 3s) can resolve its response handler after persona 2's context has started. Shared state touched in that handler = contaminated persona 2 results.
How:
- Before
context.close(), callpage.wait_for_load_state("networkidle", timeout=5000)on all open pages - Catch
TimeoutErrorand log:"Context close: network idle wait timed out after 5s for persona {id}" - Proceed with close regardless
Files: browser.py
What: Read every LLM call in synthesizer.py. For each one: if it can be replaced with deterministic code, replace it. If not, add temperature=0 and document why LLM is appropriate.
Why: A testing tool that produces different scores for the same inputs on different days is not a testing tool — it's a random number generator with an expensive API.
How:
- List every
_call_llm()invocation insynthesizer.py - Classify:
deterministic_replacement_possible(severity, deduplication, score calculation) vs.llm_appropriate(narrative writing, explanations) - Replace all deterministic cases with code (feeds into
severity.pyitem above) - Add
temperature=0on all remaining LLM calls - Write
tests/test_determinism.py: run synthesizer twice on identical inputs; assert outputs are byte-identical (except timestamps)
Files: synthesizer.py, tests/test_determinism.py
What: Reduce from ~40 serial LLM calls per run (4 personas × ~10 steps × 1 call/step) to ≤4 calls per run (one synthesis call per persona at journey end).
Why: 40 serial LLM calls at ~2s each = 80s of pure LLM wait, not counting browser time. This is unnecessary. The per-step calls are making micro-decisions that should be deterministic code.
How:
- Remove per-step LLM calls from
journey_agent.pystep execution loop - After all steps for a persona complete, pass the full
[StepSnapshot]list to one LLM call - LLM prompt: "Given these N steps of evidence, write findings in structured format"
- All deterministic analysis (severity, dedup, scoring) runs on LLM output as post-processing
- Measure before/after:
evidence.phase_timings["llm_total_ms"]
Files: journey_agent.py, synthesizer.py
What: Change all screenshot captures from PNG to WebP format. Write screenshot files asynchronously so the browser thread never blocks on disk I/O.
Why: 40 screenshots at ~1.5MB PNG each = ~60MB per run. WebP reduces this ~4× to ~15MB. On a slow disk or high-concurrency run, synchronous PNG writes block the Playwright page object — introducing artificial delays and inflating timing measurements.
How:
- In
browser.py, change allpage.screenshot()calls: addtype="webp",quality=85 - Change file extensions from
.pngto.webp - Wrap screenshot write in
asyncio.to_threador threaded write to avoid blocking the browser - Update any frontend
<img>tags that hardcode.pngextension
Files: browser.py, frontend components referencing screenshot URLs
What: Restart the browser process (not just context) between personas. Assert browser process RSS is under 500MB at the start of each persona.
Why: Long-running Playwright browser processes accumulate V8 heap snapshots, cached frames, and unreleased page objects. On Railway's constrained containers, this triggers OOM kills on long runs with large pages.
How:
- In
BrowserSession, after each persona's context is closed, callbrowser.close()and reopen a newplaywright.chromium.launch() - Before launching new browser, check process RSS:
import resource; rss = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss - Log warning if RSS > 500MB
- Test: run a 4-persona rehearsal on Railway and monitor memory across the run
Files: browser.py, journey_agent.py
Standard: a non-technical founder must understand the verdict in 3 seconds.
What: When a run completes, the notification (email or Slack) includes the full Layer 1 verdict sentence, the readiness band color, and the count of P0 findings — without requiring the user to click through to the dashboard.
Why: If a founder has to open the dashboard to know whether to delay launch, the notification is useless. The verdict must travel to wherever the founder is looking.
How:
- In
server.py, after a run transitions to COMPLETE, check forREHEARSE_NOTIFY_EMAILorREHEARSE_SLACK_WEBHOOKenv vars - Compose:
f"[{band.upper()}] {verdict_sentence}\n\nBlockers: {p0_count} · Fix this week: {p1_count}\n\nFull report: {run_url}" - Email: use
smtpliborsendgridSDK; Slack: POST to webhook URL
Files: dashboard/server.py, notify.py (new)
What: When "Fix before you launch" has more than 3 items, group related P0 findings by causal proximity and present as one item with a count: "Checkout is broken (3 signals)."
Why: "Fix before you launch" with 7 items is overwhelming and loses the priority signal. The human-facing rule: never more than 3 items in the top bucket.
How:
- After causal clustering (see A5-Causal clustering), use cluster structure to group P0s
- If a cluster has ≥2 findings:
"[Checkout flow] 3 issues found"with expand/collapse - If ≤3 clusters/individuals in P0 bucket: show all. If >3: show top 3, collapse rest into "and N more critical issues"
Files: run-detail.tsx, clustering.py
Shipped (pre-existing). share.py exists with build_share_payload() — LLM-polished share message generation. Stored as narrative.layer4Forward in the bundle.
Shipped (pre-existing). signing.py exists with sign_path(), verify_signature(), build_signed_file_url() — HMAC-signed URLs with expiry. Used in share.py for P0 finding video clips.
TryLapse's distribution moat. When built, the developer's AI assistant becomes the primary UI.
Shipped (pre-existing). mcp_server.py exists — full JSON-RPC stdio adapter, 7 tools. .claude/mcp.json created 2026-07-04 pointing at the venv Python + local artifacts dir.
Shipped (pre-existing). All 7 tools implemented in mcp_server.py: get_readiness_score, get_blockers, list_runs, compare_runs, trigger_run, get_run_status, explain_finding.
What: A backend endpoint POST /api/findings/{finding_id}/explain that takes a finding's evidence and generates: numbered reproduction steps and a suggested fix paragraph.
Why: This is the highest-value MCP tool. When Claude Code calls explain_finding, it gets specific reproduction steps and a suggested fix — the developer can then say "fix it" and Claude Code opens the relevant file. This closes the loop from "TryLapse found a bug" to "Claude Code fixed the bug."
How:
- Add
POST /api/findings/{finding_id}/explaintoserver.py - Load finding evidence from the run bundle
- LLM prompt: "Given this finding evidence, write: (1) numbered reproduction steps, (2) one paragraph describing the likely root cause and suggested fix. Be specific about file paths and function names if evident."
- Return:
{ "reproduction_steps": ["Step 1: ...", ...], "suggested_fix": "..." } - Cache the result — expensive, should not be recomputed per request
Files: dashboard/server.py, synthesizer.py
What: A tested, working configuration snippet for ~/.claude/mcp.json that connects Claude Code to a Railway-hosted TryLapse instance. Tested end-to-end against a real run.
Why: Documentation that hasn't been tested end-to-end is not documentation — it's aspirational writing.
How:
- After Railway is live, test the full flow: install MCP config → open Claude Code → ask "what's blocking my launch?" → get a real answer
- Write
trylapse-mcp/README.mdwith the exact config snippet using the Railway URL - Add to landing page MCP section
Files: .claude/mcp.json, trylapse-mcp/README.md
What: A GitHub Actions action trylapse/action@v1 that triggers a TryLapse run, polls until complete, and fails CI if the readiness band is amber or red.
Why: The CI integration is the enterprise sales hook. "Our CI pipeline won't merge to main if TryLapse finds a blocker" is a purchasing conversation, not an evaluation conversation.
How:
- Create
trylapse/actionGitHub repo withaction.yml:inputs: api_key: required project_url: required fail_on: { default: "amber" }
trylapse-cibinary: trigger run, pollget_run_statusevery 30s, exit 1 if band is amber/red- Publish to GitHub Actions Marketplace
- Add usage example to landing page
What: P0 finding video URLs returned by MCP tools must be publicly accessible without auth (using signed URLs). MCP clients can share these without requiring the recipient to log into TryLapse.
Why: If Claude Code calls get_blockers() and the video URL requires auth, the LLM can't surface the video — it prints a URL that errors for the user.
How: Reuse the existing signing.py implementation. MCP tools automatically use signed URLs for any video_url field in responses. Wire in mcp_server.py:get_blockers().
Files: mcp_server.py, dashboard/server.py
What: Contact 10 warm targets; get 5 intros sent; book 2 calls.
Why: All engineering hardening in sections A-E is meaningless without validation that real users would pay for this. Three "would-pay" design partners by Sep 30 is the gate for Phase 2. Without starting outreach now, that gate cannot be met.
How:
- Read
DESIGN_PARTNER_OUTREACH.mdfor the warm targets list - Personalize each message: "I saw you shipped [recent feature] — we built a tool that would have caught [specific type of issue] before you launched"
- Lead with the video evidence, not the product description
- Ask for 20 minutes, not a demo — lower friction
- Track: contacted / replied / call booked / would-pay verdict
Deadline: Calls booked by Jul 15, 2026.
What: Convert design partner outreach into 5 active users and 3 explicit "yes I would pay for this" responses.
Why: This is the Phase 2 gate. Without it, no public beta, no pricing page. "Would-pay" means they said it unprompted or in response to a direct pricing question.
Target: Sep 30, 2026
What: A waitlist page (or form on the landing page) that captures emails from people who want access. Public announcement on Twitter/LinkedIn.
Why: Building a waitlist before the public beta costs nothing and creates momentum. Even 50 signups provides social proof for design partner conversations.
Target: Sep 30, 2026
What: Decide whether to open-source the launch-rehearsal/ core.
Why: Open-sourcing creates distribution (GitHub stars, HN posts, developer trust) but removes a potential moat. The decision depends on whether 3 would-pay partners have been found.
Target: Aug 1, 2026
| Priority | Item | Reason |
|---|---|---|
| 1 | Railway deploy | Nothing is visible without this. Config exists — blocked only on railway login + railway up |
| 2 | Fresh demo run | Investors and partners look at the timestamp |
| 3 | Design partner outreach | Only validation that matters. Deadline: Jul 15 |
| 4 | Deterministic severity (severity.py) |
Reproducibility is a trust prerequisite |
| 5 | LLM call audit (B5) |
Feeds #4; identifies all non-deterministic synthesis |
| 6 | LLM serial latency fix (B6) |
80s → 10s LLM wait; changes the product feel |
| 7 | Cross-run delta view | Makes returning users sticky; backend may already have this |
| 8 | Context isolation test (B2) |
Correctness; flake rate root cause |
| 9 | Network drain guard (B4) |
Correctness; subtle state bleed vector |
| 10 | Browser memory restart (B8) |
OOM prevention on Railway |
| 11 | Three-run flake gate test | Confirms the ≥2/3 gate actually fires |
| 12 | State-dependent route discovery (A2) |
Coverage numbers become real |
| 13 | explain_finding endpoint (E3) |
Highest-value MCP tool |
| 14 | Verdict notification (D1) |
Verdict travels to founder, not vice versa |
| 15 | GitHub Actions action (E5) |
Enterprise sales hook |
| — | B3, B7, A3, A4, A5, A6 | Important but not blocking trust or deploy |
| — | Causal clustering, D3 | Polish layer, high-value but after trust items |
Next review: update this doc and the date at the top whenever a [ ] item ships or priority changes.