Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,12 +151,12 @@ Styles in `styles.go` are declared **bare** and constructed **only** in `ApplyPa
- **`Available()` and `TeamKeys()` are called from the Update goroutine** and therefore touch no network and no keychain — two atomics and two small file reads. The keychain read happens once, in `warmLinear()` from `Init`. `Resolved()` is separate from `Available()` on purpose: before the warm finishes, "no credential" is ignorance, not a fact, and anything acting on the *absence* of one (the discovery tip) must wait.
- Team keys come from `.fleet.json`/`.fleet.local.json` `{"linear":{"team":"BRZ"}}` (a local `team` **replaces** the committed one; `teams` lists append and dedupe — see the `.fleet.json` bullet below), falling back to `team_id` in a committed `.linear.toml`. That file belongs to the CLI fleet no longer uses, but reading one key out of a file someone already has costs nothing and makes this zero-touch for them; **`api_key` in the same file is never read** (`TestTeamKeysReadOnlyTeamID`). Gating is on the **set**, not one key — a workspace routinely has several teams and one repo may see branches from both.
- One GraphQL round trip does everything (`issueFullQuery`): description, comments with author and timestamp, labels, assignee, priority, parent/children, attachments, **and the team's workflow states**, so the optional state write needs no second query. Measured at **87 complexity points** against a 10,000-per-query cap; rate limits are 2,500 req/hr and 3M complexity/hr against roughly 2 calls per worktree, so **no throttling machinery exists**. `issue(id: "BRZ-3182")` takes the shorthand identifier. Search is `searchIssues(term:)` — confirmed against the live schema, where `issueSearch(query:)` also exists and neither is deprecated — and is deliberately **unscoped by team**: the repo gate already decides *whether* to search, and someone typing prose wants matches, not a filter they didn't ask for.
- **Error classification cannot key on HTTP status** (`TestGraphQLErrorClassification`). Captured from the live API: an unknown issue returns **HTTP 200** with an `errors[]` entry whose own `extensions` carry `statusCode 400` and the message `Entity not found: Issue`; a bad token returns 401 with code `AUTHENTICATION_ERROR`. Reading the status alone would file "no such issue" as a generic failure and break the negative pin that stops fleet re-asking on every session start.
- **Error classification cannot key on HTTP status** (`TestGraphQLErrorClassification`). Captured from the live API: an unknown issue returns **HTTP 200** with an `errors[]` entry whose own `extensions` carry `statusCode 400` and the message `Entity not found: Issue`; a bad token returns 401 with code `AUTHENTICATION_ERROR`. Reading the status alone would file "no such issue" as a generic failure, and two callers separate it out: `fleet worktree --ticket` leans on it to reject a bad identifier *before* anything is created, and `ticketStatusLine` gives it its own line. That line is new — `ErrNotFound` used to be swallowed as a resting state, which was right while inference guessed identifiers out of branch names and wrong the moment that went away: the only caller left is worktree creation, where the user picked the ticket in the `w` dialog and `Materialize` re-fetched it, so "not found" means deleted or access-changed in the seconds since, and swallowing it leaves a worktree that opens with no prompt and no explanation. It is worded, not `%v`-formatted — the sentinel reads `linear: issue not found`, which would render as `Linear: linear: issue not found`.
- **Extensions are recovered, not trusted.** Linear's default alt text is literally `image.png` and its upload URLs carry no filename, so a real PNG would land unnamed and unextensioned — and an agent's file-read tool dispatches on extension, making a perfectly downloaded screenshot unreadable. `detectExt` sniffs magic bytes (`http.DetectContentType`), which also rejects a 401 HTML body that would otherwise sit beside real screenshots. Recovering the extension and rewriting the markdown links are a **matched pair**: fix one and the agent still sees nothing. `findImages` takes **only** `http(s)` targets (`TestFindImagesTakesOnlyRemoteLinks`) — a relative path or a `file:` link in a description is not something fleet has any business reading off disk and copying into a worktree.
- Files land at `<worktree>/.fleet/ticket/<ID>/` — inside the worktree so the agent reads them with a relative path and **no permission prompt**, since a prompt on the session's first act renders as `-` waiting, the friction this feature removes. Git exclusion uses `git rev-parse --git-path info/exclude`, **never `--git-dir` + `info/exclude`**: `info` is on git's shared-path list, so a linked worktree's `--git-dir` gives a path git never reads — the entry would look installed and exclude nothing (`TestAddFleetExcludeFromLinkedWorktree` proves it with `check-ignore`). The entry is therefore repo-wide and written once, idempotently, and it is `.gitignore`'s opposite on purpose: `.gitignore` is tracked, so writing it would dirty a fresh worktree and risk committing customer screenshots. The exclude is written **before the first byte**, since a window where the files exist and the exclude does not is a window where `git add -A` sweeps a customer screenshot into a commit.
- Branch names are `brz-3182-<slug-of-title>` (`BranchNameFor`), **not** Linear's own `branchName`, which carries an owner prefix (`alice/brz-3182-…`). Linear links a PR by finding the identifier anywhere in the name, so both link identically; this form matches the convention already on disk.
- Inference is **team-gated** (`IdentifierFromBranch`). The regex is deliberately loose about the prefix because the *caller* gates on the repo's real team keys; ungated it reads `fix-123-thing` as `FIX-123` and `release-2024-cleanup` as `RELEASE-2024`, identifiers for teams that don't exist. The gate is what makes a non-ticket branch cost nothing.
- **Nothing polls.** Ticket work is event-driven and one-shot: at worktree creation, and at session creation when the branch names an issue and `.fleet/ticket/<ID>/` is absent — the directory is the ledger, so it survives restarts and deleting it is the natural "refresh". `TestTicketWorkStaysOffTheWorkers` keeps it out of `refreshAllGitAndPR`, whose `workerStallThreshold` (90s) is already sized against ~70s of git + `gh` per repo. This is only affordable because there is no badge, hence no live state to keep fresh.
- **Nothing polls, and a prompt is seeded exactly once.** Ticket work is event-driven and one-shot: it happens at **worktree creation and nowhere else** (`w` + a ticket, `fleet worktree --ticket`), so a session added by hand with `a`/`n`/`A` starts empty like every other manually created session. `handleSessionCreate` therefore does no inference at all — it passes `msg.prompt` through untouched. fleet used to infer a ticket from the branch on *every* session creation, plus reuse the already-written `prompt.txt` when `.fleet/ticket/<ID>/` was present. Both re-asked the original task on every session after the first, and the reuse branch never looked at the branch at all, so a worktree that once held a ticket kept seeding it after the checkout had moved on to `master`. A seeded first message is the *gesture* of starting a worktree from a ticket, not a property of the directory. `TestTicketWorkStaysOffTheWorkers` keeps it out of `refreshAllGitAndPR`, whose `workerStallThreshold` (90s) is already sized against ~70s of git + `gh` per repo. This is only affordable because there is no badge, hence no live state to keep fresh.
- The one mutation resolves the team's started state by **type**, against a position-sorted list (`TestStartedStateResolvesByTypeAndPosition`), so it works on a team whose started state is called "In Dev" or "Doing". Position matters as much as type: a real team has several started states (In Progress at position 2, In Review at 1002) and the lowest is what a human means by "I'm starting this" — any other choice would move a fresh ticket straight to review. Fires **only on create-from-ticket** (config `linear_ticket_start`, default true), never when a later session opens in an existing worktree — by then a human may have moved the issue on, and dragging it backwards is the worst thing this could do. `meta.json` records `state_write` so it stays exactly-once.
- The seeded prompt is a **short pointer that tells the agent not to start** (`TestSeedPromptTellsAgentNotToStart`), stated at the top and bottom because a first message describing a task reads as an instruction to perform it. Line 1 leads with the identifier before the title because it is three surfaces at once: the agent's instruction, the preview pane's prompt strip, and the input to `naming.GenerateTitle`, which cuts at ~50 runes. It rides `sessionCreateMsg.prompt` -> `Session.InitialPrompt`.
- **Ticket suggestions live in the `w` dialog's existing New branch field, not a new field and not a mode** (`internal/ui/workspace_picker_ticket.go`). The field IS the literal option, so nothing duplicates it and only one thing ever claims Enter. Two rules make that hold: **exactly one highlight, and the caret lives with it** — arrowing onto a ticket blurs the input, typing returns both and keeps the keystroke (`isTypingKey`, borrowed from the snooze dialog, whose "the highlight is the promise" rule this follows); and **shape decides the default, never a mode** — text matching a team's identifier shape resolves *in place* (`LooksLikeIdentifier`), prose stays literal with tickets one down-arrow below. *In place* means the field **becomes the branch name** (`applyResolvedBranchName` → `BranchNameFor`) while the highlight stays put; it does **not** mean "only record the ticket". That half was missing at first, so typing `BRZ-3217` fetched the ticket, materialized it and named the session after it — and then created a git worktree literally called `BRZ-3217`, breaking the invariant `pickTicket`'s own comment states, that both ways of naming a ticket end up identical. The rewrite is gated on the field still holding **nothing but that identifier**: the generation counter drops a reply a later keystroke invalidated, but it cannot see a *current* reply for a shorter identifier you paused on en route (`BRZ-321` while typing `BRZ-3217`), which would otherwise drop `brz-321-<slug>` under the cursor and let the rest of the typing land on the end of it. The same check leaves a tail you edited by hand alone. The confirmation line reads **`✓ named from BRZ-3217 · <title>`**, not a bare identifier: it renders in the same place the selectable ticket rows do, so on its own it read as a row you might still need to arrow onto when the naming had already happened — and it is the only thing on screen that explains why the field rewrote itself a moment earlier. The wording holds in both states `d.resolved` can be in, including a tail you typed yourself, because `onFieldChanged` drops the resolution the moment the text stops leading with the identifier. `✓` is U+2713, East-Asian-Neutral, so it is always one column — the same width check the priority gauge had to pass. The highlight never moves on its own; a picker that jumps its own selection is the ambiguity coming back through the window. `setSelection` is the single writer of `focus`/`ticketCursor`, enforced by `TestWorktreeSelectionMutatorIsTheOnlyWriter`, because a stray write skips the clamp and renders two selection markers. The footer names what Enter will do and changes as the highlight moves.
Expand Down
5 changes: 5 additions & 0 deletions changelog/unreleased/manual-session-no-ticket-prompt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
type: fixed
---

**Fresh sessions start fresh** — A session you add by hand with `a`/`n`/`A` no longer inherits the Linear ticket prompt from the worktree it sits in. Only the session created alongside the worktree is briefed on the ticket.
32 changes: 0 additions & 32 deletions internal/linear/linear_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -443,38 +443,6 @@ func TestAuthHeaderFormDiffersByKind(t *testing.T) {
}
}

func TestExistingPromptIsTheReuseLedger(t *testing.T) {
wt := t.TempDir()
if _, ok := ExistingPrompt(wt); ok {
t.Error("empty worktree should have no prompt")
}
dir := TicketDir(wt, "BRZ-3182")
if err := os.MkdirAll(dir, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, promptFile), []byte("seeded"), 0644); err != nil {
t.Fatal(err)
}
got, ok := ExistingPrompt(wt)
if !ok || got != "seeded" {
t.Errorf("ExistingPrompt = (%q, %v), want (seeded, true)", got, ok)
}
}

func TestNegativePinStopsRefetch(t *testing.T) {
wt := t.TempDir()
if NegativelyPinned(wt, "FIX-123") {
t.Error("nothing pinned yet")
}
pinNoTicket(wt, "FIX-123")
if !NegativelyPinned(wt, "FIX-123") {
t.Error("a branch that resolved to no-such-issue must cost one subprocess ever, not one per session")
}
if NegativelyPinned(wt, "BRZ-1") {
t.Error("the pin must be identifier-specific")
}
}

// resetCredentialForTest drops the cached credential so a test can re-resolve.
func resetCredentialForTest() {
credState.mu.Lock()
Expand Down
60 changes: 6 additions & 54 deletions internal/linear/materialize.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package linear
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
Expand All @@ -20,13 +19,12 @@ import (
// JetBrains Fleet owns .fleet/ in project roots and a repo may legitimately
// commit .fleet/settings.json.
const (
fleetDir = ".fleet"
ticketDir = "ticket"
imagesDir = "images"
ticketFile = "ticket.md"
promptFile = "prompt.txt"
metaFile = "meta.json"
noTicketPin = ".no-ticket"
fleetDir = ".fleet"
ticketDir = "ticket"
imagesDir = "images"
ticketFile = "ticket.md"
promptFile = "prompt.txt"
Comment thread
hayke102 marked this conversation as resolved.
metaFile = "meta.json"
)

// Result describes what a Materialize call put on disk.
Expand Down Expand Up @@ -72,46 +70,6 @@ func TicketDir(worktreePath, id string) string {
return filepath.Join(worktreePath, fleetDir, ticketDir, strings.ToUpper(id))
}

// ExistingPrompt returns a previously materialized prompt for this worktree.
//
// This is the fast path and the steady state: every session after the first in
// a ticket worktree hits it, at the cost of one ReadDir and one ReadFile, with
// no network. The filesystem is the ledger — it survives
// restarts, survives losing state.db, and a user who deletes the directory gets
// a re-fetch, which is the natural "refresh this ticket" gesture.
func ExistingPrompt(worktreePath string) (string, bool) {
base := filepath.Join(worktreePath, fleetDir, ticketDir)
entries, err := os.ReadDir(base)
if err != nil {
return "", false
}
for _, e := range entries {
if !e.IsDir() {
continue
}
data, err := os.ReadFile(filepath.Join(base, e.Name(), promptFile))
if err == nil && len(data) > 0 {
return string(data), true
}
}
return "", false
}

// NegativelyPinned reports whether this worktree's branch was already resolved
// to "no such issue", so inference does not re-ask on every session start.
func NegativelyPinned(worktreePath, id string) bool {
data, err := os.ReadFile(filepath.Join(worktreePath, fleetDir, ticketDir, noTicketPin))
return err == nil && strings.EqualFold(strings.TrimSpace(string(data)), id)
}

func pinNoTicket(worktreePath, id string) {
dir := filepath.Join(worktreePath, fleetDir, ticketDir)
if err := os.MkdirAll(dir, 0755); err != nil {
return
}
_ = os.WriteFile(filepath.Join(dir, noTicketPin), []byte(id), 0644)
}

// Materialize fetches a ticket and writes it, with its screenshots, into the
// worktree.
//
Expand All @@ -138,12 +96,6 @@ func Materialize(ctx context.Context, o Opts) (Result, error) {
// team's workflow states so the optional state write needs no second query.
issue, err := fetchFull(ctx, id)
if err != nil {
// errors.Is, not ==: a wrapped sentinel would skip the negative pin and
// make inference re-ask Linear on every session start — the exact cost
// NegativelyPinned exists to avoid.
if errors.Is(err, ErrNotFound) {
pinNoTicket(o.WorktreePath, id)
}
return res, err
}

Expand Down
27 changes: 5 additions & 22 deletions internal/ui/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -1751,18 +1751,6 @@ func (h *Home) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
dialog, cmd := h.connectLinear.Update(msg)
h.connectLinear = dialog
return h, cmd
case ticketReadyMsg:
// Inference finished. The session starts either way — a Linear failure
// costs the seeded prompt, never the pane.
if line := ticketStatusLine(msg.res, msg.err); line != "" {
h.setInfo(line)
}
create := msg.create
if msg.res != nil {
create.prompt = msg.res.Prompt
}
return h, h.startSessionCmd(create)

case deleteCleanupDoneMsg:
for i, pd := range h.finalizingDeletes {
if pd.Session.ID == msg.sessionID {
Expand Down Expand Up @@ -3376,16 +3364,11 @@ func (h *Home) handleSessionCreate(msg sessionCreateMsg) (tea.Model, tea.Cmd) {
h.setInfo(conflict.Message(msg.account))
}
}
// A branch that names a Linear issue gets the ticket read for it. The fast
// path (a worktree already materialized) is one stat and returns inline;
// only a first-time fetch defers the launch, and even then a failure starts
// the session anyway.
if prompt, cmd := h.ticketPromptFor(msg); cmd != nil {
h.setInfo("Fetching the Linear ticket for this branch…")
return h, cmd
} else if prompt != "" {
msg.prompt = prompt
}
// msg.prompt is whatever the caller set and nothing more. Deliberately no
// inference here: a seeded first message is the worktree-creation gesture
// ("start on this ticket"), and a session added by hand to a checkout that
// already holds a materialized ticket is not that gesture — it re-asked the
// original task, forever, on every session after the first.
return h, h.startSessionCmd(msg)
}

Expand Down
Loading
Loading