# Harbour > A minimal web app that displays your task tracker's projects and issues as a collapsible tree with a CLI aesthetic. Backed by Linear, GitHub, GitHub Projects, Jira, or a local store. ## Getting started (onboarding a person) - **Onboarding guide for coding agents**: `/getting-started.md` (plain markdown; the agent reads this one) and `/getting-started` (HTML). Public, no session. Read it in full before acting: it says which setup steps only the person can do (signing in, an OpenRouter key, minting a runner key) and which you can do. - The landing page (`/`) offers the same guide as a copyable prompt under the sign-in buttons. ## Library The papers and essays behind Harbour are published unauthenticated at `/library` — a searchable index of every document, no session required. - **Index**: `/library` lists the six "Start here" items, then the essays, the papers, and the Archive editions; a document whose kind is not `essay` or `paper` (a check, data or pre-registration) is served at its URL but not shelved - **Search**: `/library?q=` — server-rendered, works without JavaScript; always `X-Robots-Tag: noindex` - **A document**: `/library/` (a paper) or `/library/doc/` (a listed document); the wrong form 404s - **Plain markdown**: append `.md` to any document URL — `/library/.md` or `/library/doc/.md` - **Start here**: `/archive/2`; `/library/doc/the-folded-loop`; `/library/doc/ladder`; `/library/writing-length`; `/library/review-loops`; `/library/learning-while-the-tools-change` - **Crawler files**: `/sitemap.xml` (canonical, query-free URLs) and `/robots.txt` ## Overview - Syncs with your workspace's provider (Linear, GitHub, GitHub Projects, Jira, or a local store) via OAuth or token auth - Supports multiple workspaces - Displays projects and issues in a tree structure with box-drawing characters ## Navigation - **Workspace selector**: `workspace: {name}` in nav bar - click to switch - **View switcher**: `.nav-views` row in the header - switch between views (observation/swipe/swim/settings/kpis/proxy + flag-gated roadmap, plus dispatch for an owner whose workspace has a live runner key or queue token); at the top of the page, reachable without scrolling on mobile - **Team filter**: `team: {name}` in nav bar - filter issues by team - **Expand/collapse**: Click task row or ▶/▼ arrow - **Project collapse**: Click project header - **Reset**: Click "reset" to restore default view state ## Status Indicators - ○ (todo) - `data-status="todo"` - Ready to start (planned for soon) - ◌ (backlog) - `data-status="backlog"` - In the queue (not coming soon) - ◐ (in-progress) - `data-status="in-progress"` - In progress - ✓ (done) - `data-status="done"` - Completed - ✕ (done) - `data-status="done"` - Cancelled (a duplicate task; a canceled task is not shown on the dashboard) ## DOM Structure ### Landmarks - `main` - main content area - `[role="region"][aria-label="In Progress Tasks"]` - in-progress section (rows ranked: needs you, next step, running, the rest; each row's state words are `[data-testid="issue-row-state"]` with `data-tone` = needs-you, failed, running, queued, ready or quiet, LIN-3833, LIN-4009). `[data-testid="in-progress-count"]` is the count line in the page header under the workspace title (LIN-4009) - `[role="region"][aria-label="Projects"]` - projects section ### Node Container Structure Issues are wrapped in nested `.node` containers: ``` .node[data-id="{id}"] - node container .line[data-id="{id}"] - task row (keyboard-operable: role=button, tabindex=0, aria-expanded; no arrow glyph, LIN-4010: the project is muted text after the title, and a container's row names its subtask, e.g. `LIN-N · Research next`) .details[data-details-for] - details panel (hidden by default; the row's opened task on Home, LIN-3833) [data-testid="opened-task"] - the contained block around the opened task, set apart from the rows (LIN-3968) [data-testid="opened-task-facts"] - the facts line: ID, project, when it was last updated .home-prompt-mount[data-prompt-mount] - the opened task's control, always visible when the row is open (no Prompts fold) [data-testid="opened-task-state"] - the state block. On Home it is one line (`home-task-status`, no "Harbour:" label; a waiting task reads `needs you` or `parked`; none while a session runs, the running box says it, and an idle task says "No session running." with its Last line below the box; none for an idle task with no sessions, where the run box says the next step, and none on a parent whose subtask needs you, where `opened-task-needs-you` is the state, LIN-4024). A copy-first viewer's line is `opened-task-copy-state`: the tracker word, then "No prompt copied from here yet." (never "idle" or "No sessions yet", LIN-4010). The task page's state block: the task's pill and sentence (a failed stage's pill reads `failed`, in the error colour; the exact time follows the sentence, inside its paragraph, as `task-sentence-at` (LIN-4009), a `time` in local time with its zone; LIN-3991); dispatch on, also its stage strip (`task-page-strip`, one chip per stage in `task-strip-chip`, the offered step marked `data-offered="true"`; LIN-3968) and a "Last: … · All N" line that opens the Sessions fold. A copy-first viewer gets the sentence and "You copied the … prompt …" A subtask that needs you: `opened-task-needs-you`, with `opened-task-needs-you-open` ("Open", its task page). A task waiting on a question shows the owner's question card in the state block, `opened-task-question` (`data-question-slot`; the card is `session-question-card`, LIN-4024). [data-testid="opened-task-run"] - the run block (dispatch on). On Home (LIN-4010) the next run is one box, `opened-task-next-run`: the step name, `opened-task-step-what`, `opened-task-step-why` (with `run-ask-harbour`), then `opened-task-go`, the Runs-on line and Change, then Another step and See prompt. A running task is `opened-task-running` ("Running now", `opened-task-watch`, `run-stop`, and the Next line), and a container's run box runs on the container itself (LIN-4024): its one main action is "Run to PR" (`opened-task-go`, `data-action="go"`), and no subtask's stage is offered from Home. A finished task's run box has no `opened-task-next-run` (no step, `opened-task-go` or Runs-on, LIN-4042): only Another step, with its steps counted as left out. The task page keeps its own block: the step line (`opened-task-step`), the Runs-on line (`opened-task-runs-on`, `run-on-what`, `run-source`), `run-change`, the main action (`opened-task-go`: "Run " for the step the sessions offer, a failed stage again; "Run to PR" only when nothing is running, queued or waiting; none while a stage runs or waits), the after-run line (`opened-task-queued` with `run-undo`; `opened-task-running` with `run-stop` on a polled target, and the Next step; the run's state sentence is said once, in the state block, LIN-3991), and More (`run-see-prompt`, `run-another`) [data-testid="run-change-panel"] - the Change drawer for this run only (LIN-4010): choice cards, `run-default` ("Default for ") and each saved preset (`run-preset`), then where it runs (`run-target-{cli|web|local}`); `run-choose` ("Choose for this run") reveals the decision panel (`run-choose-panel`, `run-harness`, `run-model`, `run-effort`, not for opencode), `run-agent-access`, `run-use` ("Use for this run"), `run-reset`, and `run-change-x` (×, phone) closes it. A side drawer on desktop, a full-screen sheet under 640px; Tab stays inside it while it is open. [data-testid="opened-task-write"] - "✦ Write the prompt" (copy-first); then `opened-task-copy` "Copy prompt" with the copy instruction beside it (`opened-task-copy-row` > `opened-task-paste`, LIN-3991), the bounded box (`.opened-task-box` > `.opened-task-text`: the prompt as the text that will be pasted), `opened-task-regenerate` (Rewrite) and `opened-task-download` [data-testid="opened-task-target"] - "This prompt is for LIN-N , a subtask of LIN-M…" when Harbour wrote the prompt for a subtask (copy-first; absent for the opened task itself; LIN-3968) [data-testid="opened-task-progress-key"] - "Let your agent post progress to this task" (copy-first; the account's task-key default) [data-testid="home-copy-first-card"] - the first-visit card above Home's list (no runner, not yet dismissed); `home-copy-first-dismiss` ("Got it", the shared button) dismisses it for good. - `[data-home-fetch="{backlog|up-next|more-of-parent}"]` - a Home fold (`[data-testid="home-fold-{kind}"]`, a button): its rows come from `GET /workspace/{urlKey}/api/home-rows?kind=…` and go where the fold sits. A project's Backlog and its Up next overflow sit under the project; a started parent's "N more" sits under its children. Done tasks are never returned. - `[data-lazy-results]` - the search rows the server found (`[data-testid="home-search-results"]`), each under its ancestors; the page's own matches stay where they are. [data-testid="opened-task-folds"] - the one row of folds (LIN-4010): "Sessions (n) ▶" (run controls only; lazy, `sessions-toggle`; the count from the state read), then Description and Comments. Each body opens under the row. .detail-toggle[data-toggle="description"] - "Description ▶" fold: description and metadata, with Edit (`issue-description-toggle`, `issue-edit-link`) .detail-content[data-content="description"] - description + metadata (hidden). On Home (LIN-4024) the description is `.home-description` (`data-raw-desc`, base64): the client renders its markdown on the first open, so the fold never shows raw text. .detail-toggle[data-toggle="comments"] - "Comments ▶" toggle (auth only, nested) .detail-toggle[data-toggle="attachments"] - "Attachments (N) ▶" toggle (auth only, nested; image gallery via /api/image relay, attachment-capable providers only) [data-testid="issue-task-page-link"] - "Task page ›" (Home's links row); `issue-chat-link` "Chat ↗" (taskChat flag); `issue-provider-link` - "Open in {provider} ↗" (provider name is dynamic, e.g. "Open in Linear") [data-testid="opened-task-runner-link"] - "Harbour can also run steps for you, on your own machine. Set up a runner ›" (link to /workspace/{urlKey}/runner) (copy-first viewers only: a run-controls viewer already has a runner); it is the foot of the Home box, `.opened-task-runner-line` (LIN-4010, rendered by the client), one run of prose and link, the link at the sentence size (LIN-3968) .children - container for nested child nodes .node[data-id="{child-id}"] - child node (same structure) ``` ### Detail Section Toggles When a task is expanded on Home, its opened task leads with its state and one control, then folds (LIN-3833): - The control (no fold): the state block, then the run control (one main action: "Run to PR" or "Run <step>"), or for a viewer with no runner the copy-first view ("✦ Write the prompt", then "Copy prompt") - **Description ▶**: Description and metadata (assignee, dates, labels), with Edit - **Sessions ▶**: The task's sessions, in place (run controls only) - **Comments ▶** and **Attachments (N) ▶**: as before (authenticated users only) - **Task page ›**, **Chat ↗**, **Open in {provider} ↗**: the links row (provider name is dynamic, e.g. "Open in Linear") - **Set up a runner ›**: the runner setup line, after "Harbour can also run steps for you, on your own machine." (copy-first viewers only) Toggle behavior: - Both sections start collapsed (▶ arrow) - Click toggle to expand (▼ arrow) and show content - Toggle state is per-issue and does not persist across page loads - Same task in different sections (In Progress vs Project) has independent toggles ### Task Selectors Both In Progress and Projects sections use the same nested structure: - `.node[data-id="{id}"]` - node container wrapping line, details, and children - `.node > .line` - task row element within a node - `.node > .details` - details panel (sibling of .line within same node) - `.node > .children` - container for nested child nodes - `[data-id="{issue-id}"]` - task by issue ID (on both .node and .line; the provider's own canonical id, not Linear-specific) - `[data-testid="issue-line"]` - stable task-row hook (on .line; pair with `[data-id]` for a specific issue) - `[data-testid="issue-source"]` - per-task source flag, e.g. `data-source="github"` (present only in a multi-provider workspace; absent for single-source/Linear-only) - `[data-testid="dashboard-source-notice"]` - notice that a secondary source (e.g. `data-source="github"`) failed to load after one renewal attempt; the rest of the board still renders (absent when every source loaded) - `[data-testid="error-detail-source"]` / `[data-testid="error-detail-reference"]` - rows in the 503 error page's detail block when a data source fails (LIN-3775): which source failed (e.g. `GitHub Issues (owner/repo)`) and an 8-hex reference that matches the `[ref ...]` on the server log line. A `Code` row reads `HTTP <status>` when the source answered. Only a credential rejection offers a reconnect or settings link; absent on the generic 503 page - `[data-testid="project"][data-project-name="{name}"]` - project card by name (`[data-testid="project-header"]` on its header) - `[data-identifier="{LIN-123}"]` - task by Linear identifier (on .line element) - `[data-status="todo|in-progress|done"]` - filter by status - `[data-section="in-progress|project"]` - scope to section (expand state is independent per section) - `[data-parent="{id}"]` - parent ID (on .line element) - `[data-depth="{n}"]` - nesting level (0 = top-level, on .line element) - `.expandable` - tasks that can be expanded; keyboard-operable controls (role=button, tabindex=0, Enter/Space to toggle, aria-expanded reflects state) - `.in-progress-project` - project name badge shown in brackets for In Progress items ### Traversing the Tree - Find node by ID: `.node[data-id="{id}"]` - Find node's details: `.node[data-id="{id}"] > .details` - Find node's direct children: `.node[data-id="{id}"] > .children > .node` - Find all descendants: `.node[data-id="{id}"] .node` ### Project Selectors - `.project[data-id="{project-id}"]` - project container - `.project-header` - clickable project header - `[data-completed-for="{project-id}"]` - completed tasks section (the landing page only; Home counts done tasks as `[data-testid="home-done-count"]` and never sends them) - `[data-testid="create-task"]` - create-task affordance (project level, authenticated only). For providers with in-app create support (ui.inlineCreate) this is a plain link, `[data-testid="create-task-trigger"]`, navigating to the dedicated task-create page `/workspace/{urlKey}/task/new?projectId={projectId}`. Providers with only an external deep-link instead render `[data-action="create-task"]` (a `target="_blank"` link) with no `/task/new` involved. On the create page, `[data-testid="task-create-form"]` holds EXACTLY the provider's declared `createFields()` — never a fixed set — so which of these appear varies by provider: `[data-testid="task-create-title"]` (always), `[data-testid="task-create-description"]`, `[data-testid="task-create-teamId"]`, `[data-testid="task-create-projectId"]`, `[data-testid="task-create-stateId"]`, `[data-testid="task-create-priority"]`. Each of teamId/projectId/stateId is a `<select>` of the provider's real ids when the list is available, or a text input fallback when it isn't (never a 500). Picking a team (Linear only — states are team-scoped) resubmits the page as a GET with `?teamId=` to rescope the state list. Submitting POSTs `/workspace/{urlKey}/api/issues` and returns to the dashboard; a submitted stateId/priority the provider didn't declare a control for is rejected 400. The create always targets the workspace's active provider (one source per kind, LIN-3332); the request body's `source` is not read, so creating on another provider means making it active in Settings first. `labels` never appears on this page. - `[data-testid="issue-edit-link"]` - edit affordance inside an issue's Details (only when the provider supports in-app edit, ui.inlineEdit). A plain link (`.detail-link`), not a toggle: it navigates to the dedicated task-edit page `/workspace/{urlKey}/task/{issueId}/edit`, which accepts either a UUID or an identifier like `LIN-123`. On that page, `[data-testid="task-edit-form"]` holds the four editable fields — `[data-testid="task-edit-title"]`, `[data-testid="task-edit-description"]`, `[data-testid="task-edit-stateId"]` (a `<select>` of the provider's workflow states, or a text input when the provider cannot supply them), `[data-testid="task-edit-priority"]` — plus `[data-testid="task-edit-submit"]` and `[data-testid="task-edit-cancel"]`. The description field has a Write/Preview toggle (LIN-1575): `[data-testid="task-edit-tab-write"]` / `[data-testid="task-edit-tab-preview"]` swap `[data-testid="task-edit-description"]` (the raw Markdown textarea, still the sole source submitted) against `[data-testid="task-edit-preview"]` (a read-only rendered pane); switching tabs never mutates the textarea's value, so saving while Preview is showing still submits the raw Markdown. The pair is a real ARIA tablist, so `role=tab` selection ("Write" / "Preview") works too, and `aria-selected` tells you which is active. Saving PATCHes `/workspace/{urlKey}/api/issues/{issueId}` and returns to the dashboard. An unknown id renders `[data-testid="task-edit-not-found"]` with a 404. - `[data-testid="issue-task-page-link"]` - task-page affordance inside an issue's Details, on Home (LIN-3331). A plain link to the task page `/workspace/{urlKey}/task/{identifier}`, carrying `?source=<kind>` when the issue's kind is known. NOT gated on `ui.inlineEdit` — the task page is a drill-down with no feature flag, so a read-only workspace still links. Omitted on the landing page and when there is no url key. - `.project[data-project-type="periodicals"]` - synthetic Periodicals group (recurring maintenance templates, not Linear issues; workspace-flag gated). Its rows expand to a dispatchable prompt; they have no Linear URL and no "Add task" link. ### Search Selectors - `.search-toggle` - opens/closes search panel (in nav bar's `.nav-actions`) - `#search-panel` - search input panel (below the page title, not the nav bar; LIN-1512) - `#search-input` - text input for keyword search - `.search-clear` / `#search-clear` - clears search and restores full view - `#search-no-results` - "no matching issues" message (shown when search has zero results) - `[data-search-text]` - lowercased searchable content on each `.line` (title, description, assignee, ID, labels) ### Navigation Selectors - `#workspace-toggle` - workspace dropdown button - `#team-toggle` - team dropdown button - `#workspace-options` - workspace dropdown panel - `#team-options` - team dropdown panel - `[data-team="{id}"]` - team filter option (use "all" for all teams) - `.nav-views` - header-level cross-view switcher row (LIN-978); single scrolling row at the top of every workspace page - `[data-testid="nav-view-{name}"]` - a view-switcher link by its visible text (e.g. `observation`, `swipe`, `swim`, `settings`, `kpis`, `proxy`, and the gated `roadmap`/`dispatch`); the active view renders as a `<strong class="nav-view-current">` instead of an `<a>`. First-class views and `proxy` always show; `dispatch` only with run controls (the workspace owner, with a live runner key or queue token); `roadmap` only with its per-user flag; experimental views appear in the `⋯ more` overflow group when their own flag is on (same gated-inclusion policy as power-user views, not Settings-only) ### Workspace KPIs page (`/workspace/:urlKey/kpis`) One workspace's counts, last 7 and 30 UTC days, plus 30-day trend charts as rates (LIN-3711), and a running total of tasks done behind a rate/total toggle on the tasks-done chart (LIN-3732); nothing instance-wide. Reads only the session's own workspace (a workspace the session lacks gets the 404 page, no session redirects to `/`). - `[data-testid="workspace-kpis-section-{activity|done-per-day|runs-by-kind|cost|funnel|kinds|stored}"]` - the sections (the three trend sections are hidden by the empty line) - `[data-testid="workspace-kpis-spark-{dispatches|autopilot-runs|agent-actions}"]` - 30-day sparkline on those three activity cards (none on feedback notes); an inline SVG once the page script has run - `[data-testid="workspace-kpis-chart-{done-per-day|runs-by-kind|cost}"]` - the chart canvas (`id="chart-{name}"`); replaced by a short note when there is nothing to draw. done-per-day: tasks the agent marked done per day (its marker, not a verified merge). runs-by-kind: dispatches per 7-day week, top 3 kinds plus other, stacked. cost: API-equivalent cost per day by the day each task started, "priced N of M tasks" - `workspace-kpis-toggle-done-per-day` - the tasks-done chart's rate/total toggle (two buttons with `data-mode="rate|total"` and `aria-pressed`; rate is the default and the canvas is made by the page script). `workspace-kpis-done-since` - "Tasks done since D Mon YYYY: N" (absent until the daily rollup has a finished day). `workspace-kpis-total-caption` - hidden until the total view shows it (through yesterday, approximate). `workspace-kpis-total-empty` - "○ no running total yet", shown by the total view when there is no rollup row - `[data-testid="workspace-kpis-numbers-{name}"]` - the closed "Show the numbers" `<details>` of each chart; `workspace-kpis-table-{name}` is its table, rendered on the server so it works without JavaScript. `workspace-kpis-noscript` says charts need JavaScript - `[data-testid="workspace-kpis-card-{dispatches|autopilot-runs|agent-actions|feedback-notes}"]` - activity cards; values at `workspace-kpis-value-{card}-7d` and `-30d` - `[data-testid="workspace-kpis-funnel-{dispatched|taken|reported|completed}"]`, `workspace-kpis-median-wait` (minutes, `-` when unknown) - the 30-day funnel - `[data-testid="workspace-kpis-card-merge-time"]` - "median Xh · p90 Yh" and "n = N merges · 30d" from the forge's own PR opened/merged times; under 3 merges it reads "too few merges with forge times". `workspace-kpis-merge-time-note` says it counts only merges Harbour saw on a PR check - `[data-testid="workspace-kpis-kind-row"]` - one row per dispatch kind (top 8) - `[data-testid="workspace-kpis-card-{custom-prompts|roadmap-reports}"]` - stored counts - `[data-testid="workspace-kpis-empty"]` - replaces activity, funnel and kinds when there were no dispatches or agent actions in 30 days ### Footer Selectors - `.page-footer` - footer container - `.footer-actions` - container for the `reset` action (authenticated pages only; the cross-view links moved to the header `.nav-views` switcher in LIN-978) - `.footer-action` - action link (reset) - `.reset-view` - reset button (in footer) - `.footer-deploy` - deploy information container - `.footer-link` - link within deploy info (commit hash or GitHub repo) - `.footer-ai-status` - AI connection status indicator - `.footer-ai-status.free` - free tier status (yellow, shows "ai: ● free") - `[data-ai-source]` - AI source attribute on footer status (`oauth`, `env`, `free`) - `.footer-ai-model` / `[data-ai-model]` - workspace LLM model name, filled client-side from `/api/recommend/status` - Settings is one page per area (LIN-3846): `/workspace/{urlKey}/settings` lists the areas (`[data-testid="settings-nav"]`, each row `settings-nav-{area}` with its state word `settings-state-{area}`); `/settings/{sources|models|running|agent-access|health|change-log|features|account}` is a page (`[data-testid="settings-area-{area}"]`). Rows are switches (`settings-toggle-{key}`), rows that open in place or link, and actions. A setting for everyone is one row whose state is the control (`settings-periodicals-state`, `settings-modelRoutingProxyWrites-state`): opening it shows an in-place panel with `settings-{name}-confirm` and `-cancel`, and the latest change is undone on its row (`settings-undo-{id}`) and listed in the change log. Sources is one row per source (`settings-provider-binding`, opening to `settings-provider-activate`, `settings-provider-refresh`, `settings-provider-remove`), plus `settings-section-providers-add` for what this server can add. Agent access: `settings-key-{proxy|dispatch}` rows lead with the name; Revoke is `settings-key-revoke` with `-confirm`. Health: `settings-health-problem`, `settings-health-keys-ok`, `settings-health-last-poll`. Account: `settings-section-account` (You), `settings-section-ai-key`, `settings-group-theme` (the theme is `[data-testid="settings-theme"]`), `settings-logout` (Sign out). The footer has no theme link. Also: `settings-pane` (the page beside the list), `settings-area-purpose` (the one line under an area's title), `settings-dot-{area}` (a state's dot), `settings-section-{group}-title` (a group's heading), `settings-openrouter-{status|connect|disconnect}` (Account's OpenRouter row), `settings-newkey-label` (Proxy key name input), `settings-provider-active` (a source's active marker), and on Dispatch `dispatch-section-{send-prompt|halt|queue|tokens|guide|history}` (each section) - Settings asks in place (LIN-3890): each ask is a `<details>` with `data-testid="{id}"`, its Confirm `{id}-confirm`, its Cancel `{id}-cancel` (a link back to the area); a key's Revoke is `settings-key-revoke` inside `settings-key-{source}`; the per-run keys are one row `settings-keys-run`; a change row is `settings-change-{source}` with its Undo ask as the next row (`settings-undo-{id}`); the page header is `settings-page-head`, hidden on a phone area view. - Settings › Models (LIN-3855, LIN-3952): the page is one staged form, `[data-testid="models-changes-form"]`, whose rows are `models-job-{id}` and `models-group-{surface}-{group}`; each row opens as a `<details>`, holds its decision panel's `select[data-decision-field="harness|effort"]` and `input[data-decision-field="model"]`, and shows its spend at rest (`models-figures-{id}`). A job with no runs, or past the first six with runs, sits behind `models-fold-agent` or `models-fold-harbour` ("Show N more"). The pending bar is `[data-testid="models-bar"]`: the reason is `models-reason` (one line), Save is `models-save`, Discard is `models-discard`. A saved row has `data-saved="true"`; the notice after a save is `models-saved`, with its Undo `models-saved-undo-{id}`. Presets are `models-presets`; the change log is `models-log`, with each entry's Undo an ask (`models-undo-{id}`). The No job suggestion is not on Models: it is on Dispatch (`dispatch-no-job`). The retired model forms (`settings-section-ai-model`, `settings-section-dispatch-defaults`) are gone. - Settings switches are `.settings-switch` (`role="switch"`, `aria-checked`) inside `[data-feature]` rows; the JSON `+proxy` switch is `form[data-settings-json]`. Settings rows carry no `line` or `toggle-btn` class (LIN-3889). - Dispatch hooks (LIN-3889): the Send Prompt panel is `[data-send-panel]`; the halt controls are `[data-halt-form]` on the form and `[data-halt-mode="pause|stop|resume"]` on each button (absent for a member). Settings › Running shows the same controls as ask-in-place forms. - The feedback widget is switched only in Settings (`[data-feature="feedbackWidget"]`, the per-user `feedbackWidget` flag via `POST /workspace/{urlKey}/settings/features`); the footer carries no feedback link (LIN-3848) - `[data-testid="feedback-widget-root"]` - feedback widget mount (`data-enabled` reflects the flag); the popup is built client-side only when enabled - `[data-testid="nav-feedback-trigger"]` - feedback trigger in the nav bar's `.nav-actions` row (opens the popup; `aria-expanded` tracks it). LIN-2298 moved this out of the fixed `.feedback-fab` it replaces: the FAB was a `position: fixed` element over full-width content, which LIN-2272 proved no CSS reserve can clear at every scroll offset. There is no floating feedback button any more. - `[data-testid="feedback-popup"]` - feedback popup; `feedback-message` (textarea), `feedback-priority` (0-4 select), `feedback-file` (screenshot), `feedback-minimize` (keeps draft), `feedback-submit` → `POST /workspace/{urlKey}/api/feedback` (creates on the workspace's active provider; a body `source` is not read) ### Interactive Elements - `.toggle` - expand/collapse arrow for child tasks (▶/▼) - `.detail-toggle` - expand/collapse toggle for detail folds (Description ▶ / Sessions ▶ / Comments ▶) - `.detail-toggle[data-toggle="description"]` - Description fold toggle (LIN-3833; was the Details toggle) - `.detail-content[data-content="description"]` - Description fold content (hidden by default) - `.detail-toggle[data-toggle="comments"]` - Comments toggle (nested in Details, lazy-loaded on first expand) - `.detail-toggle[data-toggle="brief"]` - Brief toggle (nested in Details, lazy AI current-state brief) - `.detail-toggle[data-toggle="recap"]` - Recap toggle (nested in Details, lazy AI progress recap) - `.detail-toggle[data-toggle="scan"]` - Scan toggle (nested in Details, human-triggered scan for an operator-worthy blocker — one click still starts every run; never auto-runs on expand, only a cheap status check does. A run may be this single scan or the Observation Scan-due tab's own bounded/refusable/abortable batch of N (LIN-2707, see Autopilot Observation Endpoints below) — that tab now ships its own "Scan selected (N)" / Stop controls (selection and pricing from LIN-2706, the run itself from LIN-2707), so this single scan is no longer the only run any of this starts. A found blocker renders a question + options + an answer box (durable comment write) and a dismiss action.) - `.detail-toggle[data-toggle="context"]` - Context toggle (nested in Details, lazy relationship diagram: blockers/blocked/parent/children/related). Each task node is `.context-node` with `data-context-nav="LIN-123"`. - `.detail-toggle[data-toggle="sessions"]` - Dispatched Sessions toggle (nested in Details, run controls only) - `.detail-content[data-content="prompts"]` - Prompts section content (hidden by default) - `.in-progress-header` - collapsible section header ## Workflow Labels Tasks use 1 workflow label: **`bug`** - Investigating unexpected behavior - Use when investigating unexpected behavior - Document reproduction steps and hypotheses - Remove when bug is fixed Blocked-ness is NOT a label. A task is blocked when it has an incomplete `blocks`/`blocked-by` relationship to another task — record blockers as a relationship between the tasks, not as a label. ### Label Rules 1. **Remove when done**: Remove the `bug` label when its purpose is complete 2. **Blocking is a relationship**: capture blockers via `blocks`/`blocked-by` relations, never a label ### Typical Flow 1. New task arrives → it is Ready once it's in a backlog/unstarted state 2. Work begins → set status to "In Progress" 3. If blocked → add a `blocks`/`blocked-by` relationship to the blocking task 4. If bug found → add `bug` label 5. Work complete → set status to "Done" ### Readiness Checks - `blocked`: "Can work resume based on this analysis?" - `bug`: "Could someone implement a fix based on this?" **DOM Selectors:** - `.prompt-container[data-prompt-for="{id}"]` - container for a generated prompt (periodical Mint / Setup Prompt rows) - `.prompt-text` - the prompt text (inside container) - `.prompt-copy` - copy button (inside container) - `.prompt-download` - download button (inside container); saves the prompt as a `.md` file. Use this instead of copy for very large prompts (e.g. retro on an epic) that exceed clipboard limits. The file matches what copy yields, including the `+proxy` block when that toggle is on. Filename: `<identifier>-<promptName>.md`. - **Opened-task component (Home & Swipe):** `[data-prompt-mount]` → `.prompt-section`; Go (starts the run) is `[data-testid="opened-task-go"]`, the ✦ next-step prompt button is `[data-testid="opened-task-next-step"]`, templates are `[data-testid="other-prompts"] .swipe-prompt-btn[data-prompt="<key>"]`, the prompt text is `[data-prompt-body]`, reasoning is `[data-testid="opened-task-reasoning"]` (visible by default), and copy/download are `.swipe-prompt-copy` / `.swipe-prompt-download`. ### Available Prompt Templates (17 total) **Default Prompts** (shown for every issue, including completed/canceled): - `look-into` - look into: Quick overview and context for any task - `research` - research: Investigate unknowns and gather information - `plan` - plan: Step-by-step implementation with test coverage - `implementation` - implement: Active coding phase guidance **More Prompts** (behind "more" toggle): - `blocked` - blocked: Analyze and resolve blockers - `bug` - bug: Find root cause and potential fixes - `triage` - triage: Review and update metadata (labels, priority) - `breakdown` - breakdown: Break large tasks into actionable subtasks - `scoping` - scoping: Define boundaries and success criteria - `design` - design: Evaluate approaches and recommend architecture - `spike` - spike: Time-boxed exploration for proof-of-concept - `context` - context: Synthesize current state when joining mid-task - `plan-review` - plan-review: Verify a plan's grounding claims with fresh context before implementation starts - `review` - review: Verify implementation before merge - `close-out` - close-out: Discharge the review ledger, then merge, set Done, post the summary, and file follow-ups - `retro` - retro: Retrospective look-back (or in-flight reorient) — reconstruct what happened, assess downstream effects/risks, surface lessons; presents findings without writing back - `retrospective-audit` - retrospective audit: Audit a merged change's claims and test integrity after the fact; presents findings without writing back ### Public Templates Page All 17 templates above (never the routing prompt) are also published, unauthenticated, at `/templates` — a public page with no session/workspace requirement, linked from every other page's footer (`a.footer-legal[href="/templates"]`; on `/templates` itself the footer shows it as plain current-page `<strong>` text, not a link). Each template renders in a `.prompt-card`, with its full text inside that card's `<details class="prompt-details"><pre class="prompt-text">` — so fetching the HTML is enough to read every template, no JavaScript required. The `.template-copy-btn` copy control is **not** in the served HTML: `/templates.js` injects one per card on `DOMContentLoaded` (client-only; no dispatch, no proxy token), so with JS off the cards and their text remain, minus the copy buttons. Indexable (no `noindex`) — unlike `/kpis`/`/styleguide`, discoverability is the point. ### More Prompts Inline Access additional prompt templates from any issue, regardless of its labels or state. A "more" control appears in the opened-task component's "other prompts" group; clicking reveals the remaining templates inline. Templates (including `retro`) are available on completed/canceled issues too. **DOM Selectors:** - `[data-testid="other-prompts"] [data-prompt="__more__"]` - "more ▾" toggle (reveals hidden templates) - `[data-testid="other-prompts"] .swipe-more-prompts` - hidden group containing additional template buttons **Usage:** 1. Expand an issue and open "Prompts ▶" 2. Click "more ▾" in the "other prompts" group 3. Additional template buttons appear inline 4. Click any template to load it into the component ### AI Recommendation Selectors (shared opened-task component) - `[data-testid="opened-task-go"]` - Go: one press starts the run (fetches the kickoff and dispatches it, bounded to the PR) - `[data-testid="opened-task-next-step"]` - ✦ next step (AI-tailored prompt; requires OpenRouter or free tier) - `[data-testid="opened-task-primary-reason"]` - plain-words reason when the ✦ action is disabled - `[data-testid="opened-task-started"]` - the started/running line shown in the Go slot (carries the task-page link); Go is replaced while a run is live - `[data-testid="opened-task-reasoning"]` - AI reasoning text (visible by default) - `[data-prompt-body]` - generated prompt text - `[data-testid="opened-task-ladder"]` - copy → run-this-step ladder (the run-whole-task rung is Go now) - `[data-free-tier-status]` - free tier status on settings page - `[data-free-tier-usage]` - free-tier runs-per-day allowance on settings page ## Dispatched Sessions (Swipe) - `GET /workspace/{urlKey}/api/sessions/{identifier}` → `{ sessions: [...] }` (newest-first dispatch loops for one issue, with feedback) - Swipe card "Dispatched Sessions [N]" accordion (`data-accordion="sessions"`); shown only with run controls (the workspace owner with a live runner key or queue token). `[N]` count is baked into the card server-side. ## Task Page (first-class drill-down) The task page (`/workspace/{urlKey}/task/{identifier}`, LIN-3329; the PR section and merge click LIN-3340; the stage bar and plain-words PR LIN-3356) is the page per task over its whole life, top to bottom: the answer `[data-testid="task-page-answer"]`: the tracker's own state `[data-testid="task-page-tracker-state"]` ("Tracker: In Review", outside the repainted mount), then the status mount `[data-testid="task-page-answer-status"]` (status pill, one sentence, and — when the brief is cached — the brief's `## Current` paragraph `[data-testid="task-page-summary"]`); then, for the owner with an unfinished task, the run control `[data-testid="task-page-run-control"]` (the same shared panel Home uses, surface `task`: it names the next step, offers Change and Stop while a stage runs, and carries the copy-first view with its runner link when no runner is set up; LIN-3975). Staged chat proposals for the task's runs sit in the run control as `[data-testid="opened-task-proposal"]` rows, with Apply and Decline (posting to the run page's own proposal endpoints; Apply resumes that session on its own harness and model, so Change does not apply to it). The owner's chat fold `[data-testid="task-page-chat-fold"]` sits under the run control, behind the per-user `taskChat` flag, and is propose-only. A finished task, a member, and a guest have no run control: they read `[data-testid="task-page-next"]` in its place, one plain line saying what comes next, and a member or guest has no chat fold. When the owner is being asked — the pill says waiting and the wait is on an open ruling (LIN-4038; a shelved ruling or a bare `[blocked]` reads parked, as on Home, and shows no card; the next line then reads "Nothing comes next while it is parked.") — the owner (never a guest or a member) also sees the question and its answer box `[data-testid="task-page-question"]` right under it — the same pinned card the run page shows (`session-question-card`, its `-question`, `-why`, `-option`, `-input`, `-answer`, `-dismiss`, `-if-unanswered` parts), kept outside the repainted answer so a half-typed answer survives a poll; the **Stages** — one bar row per stage `[data-testid="task-page-stage"]` (`data-state`, `data-tone`, `data-kind`, `data-loop-id` = the stage's first session), each with its name, state word, "started … ago" and "took …"/"running …" clocks and a head button `[data-testid="task-page-stage-head"]` that opens it to its sessions `[data-testid="task-page-session"]` (message `task-page-session-message`, `[evidence]` links `task-page-session-link`) and its folded check-ins `[data-testid="task-page-checkins"]` (a closed disclosure; the orchestrator's kick-off, check-in and abort rows are folded into the stage they waited on by time order, never top-level rows). The running or blocked stage starts open; stages still ahead are quiet lines `[data-testid="task-page-stage-ahead"]` ("usually next", not boxes; a guess, not a promise). The track is `[data-testid="task-page-track"]` inside the repainted mount `task-page-track-mount`; it is the page's one progress picture. Then the task's **Pull request** `[data-testid="task-page-pr-mount"]` — a justification in plain words for every viewer (`task-page-pr-lead`, `task-page-pr-detail`, no SHAs or ISO times), for the owner one primary merge button under it (`[data-testid="run-evidence-closeout-press"]`, "Merge PR #N", only when the close-out is ready; the close-out box `[data-testid="run-evidence-closeout"]` carries `data-issue-id`/`data-source` and is driven by `public/close-out.js`), and the raw evidence `[data-testid="run-evidence"]` and ledger in a closed disclosure `task-page-pr-raw`. The PR section renders only when there is a PR or a ledger and sits outside every repainted mount. Then the brief and recap (live widgets for the owner; beside the stages on desktop, ≥1024px, in a plain column — not sticky), the description `[data-testid="task-page-description"]` (shown without a fold, cut at about twelve lines, with `task-page-description-more` "Read all of it"), and the comments (a closed disclosure `task-page-comments`, with a count); both are upgraded to markdown client-side, and the details. The owner's brief/recap widgets mount from `[data-testid="task-page-owner-widgets"]` (which threads the issue's `source`). The state endpoint (`/api/task/{identifier}/state`) returns the status, track and sessions only, never a brief or recap, so the brief and recap on the page are the ones it loaded; it updates the answer and the stages from stored data. It is a **drill-down**, like `/task/{issueId}/edit` and the run page — NOT a view-tier member, so no feature flag and no nav entry. It is a different thing from the run page (`/observation/session/{sessionId}`), which follows one autopilot run that can span several tasks. The page reads the issue's OWN provider kind from `?source=<kind>` (as the Edit/Chat links carry it); without a source it resolves to the workspace's active provider, which Settings → "make active" can change. Without a source the link is still rendered plain: it resolves to the active provider, exactly as the Edit link does. Every surface that shows a task links there through the shared helper (`lib/task-page-href.js` / `window.taskPageHref`), never a hand-built path. The per-surface link test-ids are: - `[data-testid="issue-task-page-link"]` - Home, inside an issue's Details - `[data-testid="swipe-task-page-link"]` - Swipe card - `[data-testid="sessions-task-page-link"]` - Dispatched Sessions list header - `[data-testid="session-task-page-link"]` / `[data-testid="session-task-link"]` - run page seed row / tasks-touched chips - `[data-testid="obs-task-page-link"]` - Observation per-task block - `[data-testid="live-console-lane-run"]` - Live Console lane's link to its run page (`/workspace/{urlKey}/observation/session/{sessionId}`); the lane's task identifier links to the task page (LIN-3977: the separate Observation link is gone) - `[data-testid="queue-item-task-page-link"]` / `[data-testid="history-task-page-link"]` - dispatch queue / history rows - `[data-testid="fc-decision-task-link"]` / `[data-testid="chat-task-link"]` - Flight Companion decision row / a seen task identifier in bubble text; `[data-testid="fc-obs-attention-issue-link"]` - Flight Companion observer-report attention row (`row.issue` only; a `loopId` stays plain text) ### Task Share (LIN-3330) A task share link is the SAME task page seen by a signed-out guest, at an unguessable key: `GET /t/{token}` (and its stored-data repaint `GET /t/{token}/state`, the same JSON as the owner state route). The token is 43 base64url chars; only its SHA-256 is stored in the `task_share_links` collection (never `shares`). A guest sees the page with only the owner controls hidden (the widget marker, the close-out box, the run control, and the share/revoke controls); links back into Harbour stay, and the evidence, description and comments are visible. A member (a signed-in workspace member who is not the owner) reads the same page as a guest, signed in: no owner control, and the next-step line in place of the run control (LIN-3975). Owner controls on the task page are `[data-testid="task-share-create"]` (mint — the one-time URL shows in `[data-testid="task-share-new"]`), `[data-testid="task-share-list"]` (one `[data-testid="task-share-row"]` per link, each with `[data-testid="task-share-revoke"]`). Owner routes (session auth): `POST /workspace/{urlKey}/api/task/{identifier}/share` (201 `{id, path, createdAt}`), `GET .../shares`, `POST .../shares/{id}/revoke`. A revoked or unknown link is a plain 404 for the guest; `/s/<anything>` stays 404 (the retired run-share surface). No rate limit; public-page secret scanning is LIN-3261. ## Collective (experimental) The Collective page (`/workspace/{urlKey}/collective`) dispatches full Claude Code sessions — one per chosen character (persona) — into one Yap chat channel for a watched cross-project discussion. Each character is bound to a connected workspace/repo. Requires the per-user `collective` feature flag; surfaced only via a link in Settings. ### Collective DOM Structure ``` main.collective-page[data-url-key] - page container .collective-char-check[value] - character selection checkbox (value = character id) [data-testid=collective-char-repo] - define-new: connected-repo select [data-testid=collective-char-{role|lens|objective|value|disposition}] - persona inputs [data-testid=collective-char-add] - add the defined character to the picker #collective-channel - Yap channel input (random default: #word-word-YYYY-MM-DD) #collective-topic - discussion topic textarea #collective-target - dispatch target select (cli | web) #collective-start - start-discussion button #collective-view-prompt - build & show the participant prompt #collective-prompt-preview / #collective-prompt-copy - prompt preview + copy #collective-transcript - live message log (ul) #collective-say-input / #collective-say-btn - inject human input ``` ### Collective Endpoints - `POST /workspace/{urlKey}/collective/start` — fan-out `{ workspaceUrlKeys[], channel, topic, target }` - `POST /workspace/{urlKey}/collective/preview` — `{ channel, topic }` → `{ prompt }` (view/copy, no dispatch) - `GET /workspace/{urlKey}/api/collective/state?channel=&since=` — poll the channel; messages normalized to `{ id, nick, text, type, timestamp }` - `POST /workspace/{urlKey}/api/collective/say` — `{ channel, message }` - `window.__COLLECTIVE_DATA__` carries `{ urlKey, defaultChannel, yapConfigured }` ## Autopilot Observation (first-class) The Autopilot Observation page (`/workspace/{urlKey}/observation`) is a realtime, mobile-first feed of autopilot **sessions** merged across ALL connected workspaces. First-class: a header view-switcher link (`.nav-views`, LIN-978), no feature flag (it superseded the retired experimental `dashboard` flag/view; `/workspace/{urlKey}/dashboard` now 302-redirects here). NOTE: distinct from the first-class project tree view (the "dashboard" at `/workspace/{urlKey}/`). A **session** is the LIN-591 reconstructed `sessionId` spine — an autopilot orchestrator dispatch plus every worker it spawned across the seed/epic's descended + spun-off tasks — NOT a per-task grouping. The live feed reads Mongo only (no Linear calls per poll, no LLM per poll). Session terminality follows the anchor autopilot loop (a `[done]`/`[failed]`/… feedback marker, lib/dispatch-terminal.js, folds in), so finished sessions leave the Active feed for the collapsible Completed archive. ### Autopilot Observation DOM Structure ``` main.obs-page[data-url-key] - page container #obs-banner - status bar (holds the live poll status) #obs-poll-status - live poll status line #obs-tabs - view tabs (`.tabs__tab[data-tab][data-testid="obs-tab-<key>"]`): runs | rulings | due; `.is-active` and `aria-selected` on the selected one #obs-view-hint - one line under the tabs: what the active tab holds #obs-waiting - "waiting on you" block above the runs feed; `hidden` when nothing waits and off the runs view. `#obs-waiting-rulings` (up to 3 ruling rows, the Rulings tab's own rows), `#obs-waiting-runs` (`.obs-waiting-run` rows for runs waiting with no ruling row, each linking "reply on its run page →"), `#obs-waiting-more` ("N more in Rulings →") #obs-filter - runs filter (group): `.obs-filter-btn[data-filter]` autopilot (default) | standalone | all; `aria-pressed` on the selected one #obs-scope - workspace scope (group, shown with 2+ workspaces): `[data-testid="obs-scope-current"]` "This workspace" (default; the feed opens on this workspace, as Live Console does) | `[data-testid="obs-scope-all"]` "All workspaces"; `aria-pressed` on the selected one #obs-chips - workspace filter chips (group); `hidden` unless "All workspaces" is chosen. Under "This workspace" the waiting block still covers every workspace .obs-chip[data-ws].is-on - one per workspace (data-ws = urlKey); .is-on when shown (this workspace starts on) .obs-chip-dot[data-state] - workspace health (worst live session status: error>running>done>queued), not on/off #obs-active / #obs-active-empty - active sessions feed (ul) + empty state #obs-archive-toggle / #obs-archive-count - collapsible completed archive header + count #obs-archive-body - (when open) the completed archive body #obs-recent / #obs-recent-empty - completed sessions feed (ul) + empty state li.obs-session[data-session][data-status] - one autopilot session .obs-session-head - header container (NOT the toggle; LIN-928) .obs-session-topline - header cluster: .obs-session-headmain (left) + .obs-session-side (right) .obs-session-headmain > .obs-session-idline - .obs-session-dot[data-state] + .obs-session-ident (mono, ellipsizes) .obs-session-headmain > .obs-session-name - bold session/seed title, truncates (omitted when it would only echo the ident) .obs-session-side - fixed right column: status pill stacked above updated time .status-pill.status-pill--{running|done|error|queued} - theme StatusPill (LIN-783); dot + label, the task page's own words: running | waiting | done | failed | idle. done-with-warning = status-pill--done + a .status-pill__warn ⚠ marker (no 5th colour); stale → idle .obs-session-summary - one sentence saying what the run does, never repeating the pill's word: a waiting card's sentence is the question (runner markers removed; `.obs-summary-decision-options` offers the answer choices only while the question is open); a running card's is `{stage}, {time} in`; a failed card's is `{stage}, last activity {time}` .obs-session-meta-line - labelled runtime / model (+ workspace, task count) .obs-progress-row - per-worker-run progress bar + N/M count .segment-bar.obs-progress > .segment-bar__cell--{running|done|error|queued}[data-live] - theme SegmentBar cells (live cell pulses) .obs-progress-count - the count in words: `N of M runs finished` (one cell per run) .obs-summary-gen - (terminal, uncached) explicit "summarise this session" button button.obs-disc[aria-expanded] - disclosure control (LIN-928): click to expand/collapse ("Show run detail · N runs" / "Hide run detail") .obs-session-body - (when open) objective eyebrow + status line + Level-3 drill-down .obs-objective - session seed goal (design §4.6; id never reprinted) .obs-tasks > .obs-task - one block per touched task (seed-first) .obs-task-head - provenance tag + identifier (link when hydrated) + title + live state/labels .obs-rels > .obs-rel - relationship neighborhood (parent/children/blocked by/blocks/related) .obs-worker-tree > li.obs-worker[data-state][data-open] - worker-session nodes on a phase-timeline rail .obs-worker-node[data-state] - phase-rail status node (✓/◐/✕/○, decorative) button.obs-worker-head[data-loop] - click to expand a worker run .obs-worker-phase - bordered phase tag (research/plan/implement/review…) .obs-worker-attempt - "attempt N" (surfaces the run's iteration) .obs-worker-state[data-state] - textual run state (complete/running/error/queued) .obs-worker-recap - one-line recap (cached run-summary or agent summary) .obs-chips-metric > .obs-chip-metric - bordered runtime / tool activity / artifact-count chips .obs-worker-body - (when open) error box + activity log + produced-artifact links + next .obs-error-box - (error runs) error-as-direction: cause + runtime + view-log link .obs-acts > .obs-act - activity log rows: tool chips + right-aligned duration column .obs-artifacts > .obs-artifact - icon-led produced-artifact links .obs-run-gen[data-loop] - (terminal, uncached) explicit "summarise this run" button ``` ### Autopilot Observation Endpoints - `GET /workspace/{urlKey}/api/dashboard/sessions` — merged cross-workspace sessionId-grouped feed → `{ workspaces[], active[], recent[], counts, generatedAt }`; each session is workspace-tagged (`workspaceUrlKey`, `workspaceName`) and carries `{ sessionId, seedIssue, seedTitle, tasksTouched[], status, terminal, runCount, runs[], runtime, model, lastActivity }`. Each `runs[]` entry carries its Level-3 drill-down payload too — `{ loopId, issueIdentifier, issueTitle, issueUrl, agentState, stage, promptName, kind, iteration, agentSummary, runtime, metrics[], producedArtifacts[] }` (all read-only telemetry, LIN-594). Polled every 5s (visibility-gated). Mongo-only, no LLM. - `GET /workspace/{urlKey}/api/dashboard/loops` — merged cross-workspace flat runs → `{ workspaces[], active[], recent[], counts, generatedAt }`; each run is workspace-tagged. Polled at 5s. - `GET|POST /workspace/{urlKey}/api/dashboard/run-summary/{loopId}` — cached short run summary `{ outcome, whatHappened[], blockers[], next }`. POST forces regeneration. `GET ?cachedOnly=1` peeks the cache without generating (204 on a miss). 409 if the run is still active (terminal-only), 404 if unknown. - `GET|POST /workspace/{urlKey}/api/dashboard/session-summary/{sessionId}` — cached one-sentence session rollup (terminal-gated). A live session returns a cheap `live:true` status-line proxy (no generation). `GET ?cachedOnly=1` peeks without spending. - `GET /workspace/{urlKey}/api/dashboard/session-context/{sessionId}` — deterministic tasks-touched + relationship graph (lazy Linear hydration on drill-down). - `GET /workspace/{urlKey}/api/dashboard/hydrate/{wsUrlKey}/{identifier}` — best-effort live Linear state/labels for a session's issue (drill-down only); returns `{ hydrated: false }` rather than erroring on an expired token. - `GET /workspace/{urlKey}/api/dashboard/sessions` sessions also carry `stageWord` (the stage's plain word, the Live Console's own `stageLabel`), `stageSince` (when that stage started, ISO), and `waitingLoopIds` (LIN-3983 PR 2). - The view, filter and workspace scope are in the URL: `?view=runs|rulings|due&filter=autopilot|standalone|all&scope=all`, written with `replaceState`, so a reload or a shared link reopens the same place. Defaults are omitted (a bare URL is runs + autopilot on This workspace; `scope=all` is All workspaces). `?view=rulings` and `?view=due` are unchanged; `?view=sessions` (the old Sessions tab) opens on the all filter, and `?view=autopilot` on the autopilot filter. Every tab and filter survives a reload. - The Scan-due tab (`[data-testid="obs-tab-due"]`) additionally supports selecting due-check rows for a bulk scan (LIN-2706): an exact `N selected` count and a separate honest cost estimate (`unknown`, never `$0.00`, when the workspace has no priced 30-day scan history) update as the selection changes, and selecting past `BULK_SCAN_MAX_PER_RUN` refuses rather than silently truncating. The tab's select-all control (`#obs-due-select-all`, labeled "select all due + unknown", LIN-2760) selects the loaded rows whose due status is due or unknown and skips not-due and errored rows — per-row checkboxes stay free to hand-pick any row, and unchecking clears every loaded row. When every loaded row is skipped (coverable empty), the select-all checkbox's state keys on all loaded rows instead, so a hand-picked selection on such a page can still be cleared from the select-all control itself. A "Scan selected (N)" button starts the run and a Stop button aborts it (LIN-2707): at most `BULK_SCAN_CONCURRENCY` = 2 scans in flight, at most `BULK_SCAN_MAX_PER_RUN` = 40 rows per run, one live run at a time (a second start while one is live is refused), and each scanned row paints exactly one of seven result badges — `scanned — new decision raised`, `scanned — nothing found`, `scanned — already ruled on, nothing changed`, `scan failed`, `skipped — quota`, `unavailable — AI not configured`, `cancelled`. Stop aborts what has not been issued; already-issued scans are left to complete and still bill. - `window.__OBSERVATION_DATA__` carries `{ urlKey, workspaces, scanCostEstimate }`. `scanCostEstimate` (LIN-2706) is `{calls, pricedCalls, meanUsd, unknown}` — a 30-day per-call mean feeding the Scan-due tab's pre-run estimate above; `unknown:true` when the workspace has no priced scan history yet, never collapsed to a `$0.00`/`null` mean. - The Rulings tab (`[data-testid="obs-tab-rulings"]`, `#obs-rulings-section`) lists unanswered decisions, one `.obs-ruling` row each. A row names its task by id and title (`.obs-ruling-issue`, `.obs-ruling-title`), never by workspace key; its workspace's name (`.obs-ruling-ws`) shows only when several are showing. Its controls are plain words: `[data-testid="obs-ruling-answer"]` "Answer", `obs-ruling-dismiss` "Dismiss" (this wasn't worth asking), `obs-ruling-shelve` "Shelve" (it comes back later; `.obs-ruling-actions-note` says when). The row's "If unanswered" line reads "If you don't answer". A row carrying a standing agent proposal (`suggestedDismissal`, LIN-2444/LIN-2790 — a SUGGESTION only, never itself a discharge) additionally renders a banner (`.obs-ruling-suggestion`) reading either `proposed dismissal` or `proposed answer: <option label>` (kind-aware), plus the reason/attribution/time. Every control on this tab names the outcome it performs, never a bare "agree" (LIN-2757): `.obs-ruling-agree` reads `dismiss as proposed` or `answer as proposed`, branching on the proposal's own kind; `.obs-ruling-keep` reads `keep open` and withdraws the standing suggestion without answering the ruling; `.obs-ruling-dismiss`/`.obs-ruling-shelve` are the row's own ordinary dismiss/shelve controls, unrelated to a proposal. When the row also renders its own option buttons (the shared `window.ChatUI.appendOptions` primitive — see `.fc-decision` above), the recommended option additionally carries an `agent recommends` text marker beside the existing star, opt-in on THIS tab only (`recommendedLabel`) — Flight Companion's `.fc-decision` cards render only the star, unchanged. A bulk bar (`#obs-ruling-bulk-bar`) lets an operator select multiple suggested rows at once: `#obs-ruling-select-all` ("select all proposed") is deliberately kind-neutral, since it spans both dismiss and answer proposals; `#obs-ruling-agree-selected` reads `Apply N as proposed` and discharges the whole selection, and asks in the page, not through a native dialog: `#obs-ruling-bulk-confirm` names the real per-kind breakdown (dismissals vs. answers, plus any skipped rows) with `[data-testid="obs-ruling-bulk-confirm-yes"]` "Apply N" and `obs-ruling-bulk-confirm-no` "Cancel" (LIN-3983 PR 2). A mixed selection has no single outcome verb to collapse onto. ## Task Chat (experimental) The Task Chat page (`/workspace/{urlKey}/task-chat`) lets you hold a grounded, multi-turn conversation with a single task. The task answers in the first person from its own Linear context (description, comments, subtasks, status). Requires the per-user `taskChat` feature flag. Reached from Settings, and (LIN-3975) by the owner from the task page, as a fold under the run control; there a turn only proposes, and a proposal is staged for the run control to apply or decline. An ordinary chat can queue a follow-up to the owner's runner. No Linear writes; history is in-browser and ephemeral. ### Task Chat DOM Structure ``` main.task-chat-page[data-url-key] - page container #task-chat-id - task identifier input (e.g. LIN-123); the standalone page's controls also carry `data-task-chat` hooks (LIN-3975), which the shared client finds within its root [data-task-chat-root] (the fold on the task page; the page root here, `data-task-chat-auto`) #task-chat-transcript - conversation log (ul) li.task-chat-msg.task-chat-msg-user - your turn li.task-chat-msg.task-chat-msg-assistant - the task's turn .task-chat-msg-body (chat-md) - the assistant turn's text span carries `chat-md` with rendered Markdown once the turn completes (LIN-2670; same for Flight Companion's `.fc-msg-body`) — the user's own turn stays plain text #task-chat-question - question input #task-chat-send - ask button #task-chat-reset - clear the conversation ``` ### Task Chat Endpoint - `POST /workspace/{urlKey}/api/task-chat/{issueId}` — SSE stream; body `{ question, history[] }`. Events: `token` (`{ token }`), `done`, `error`. Issue IDs accept identifiers like `LIN-123`. - `window.__TASK_CHAT_DATA__` carries `{ urlKey }`. ## Flight Companion (experimental) The Flight Companion page (`/workspace/{urlKey}/flight-companion`) is a live, in-page chat for LIN-751 ("realtime chat with work in flight"): a companion that watches the workspace's dispatch feed and checks in with you on its own client-driven cadence (visible-tab only, 30s base, doubling to a 180s cap on nothing-to-report ticks; the server's own pre-call gate is always the real spend authority), or answers directly when you send it a message. It renders through the shared chat UI (`chat.css`/`window.ChatUI`) and streams turns over SSE, including tool-use breadcrumbs (LIN-2632) via the SAME shared `window.ChatUI.toolBreadcrumbLabel` Task Chat uses — never a page-local label fork. On an auto-wake turn, a proposed follow-up renders an inline Approve/Dismiss control and only an Approve click can enqueue it — the model can never dispatch one unapproved there. On a turn you start yourself, though, the model's `send_follow_up` executes directly (no proposal card, no approval needed) — the approval gate is auto-wake-only, not a blanket guarantee. Requires the per-user `flightCompanion` feature flag; reached only via a link in Settings. Chat is the FIRST section on the page (LIN-2443; it has no visible title since LIN-3360, only `aria-label="Flight Companion chat"`) — `How to use` and the kickoff prompt follow it, and the read-only observer report stays last; `How to use` and the observer report keep their headings but their bodies start in closed disclosures (LIN-3360). Above the thread sits a closed `settings` disclosure (`details.fc-settings`, LIN-3360) holding the status strip (LIN-2621; the tools-off warning and free-tier note stay outside it in `#flight-companion-strip-notices` so they show with the fold closed). Page timestamps (`time[data-local-time]`: last check-in, sweep last seen, observer generated/grounded) are rewritten to local time by the shared `window.initLocalTimes` (public/common.js). The strip: model id, tools on/off (both resolved server-side ONCE per page load via `resolveAiOperationModel({opKind:'flight-companion'})`/`isToolCapableModel` — since LIN-2623 the SAME single-resolver call the turn core itself uses, lib/agent-turn.js, so the strip can never describe a different model than the one the turn will actually use), last check-in, next check-in due, sweep liveness/no-census, a mode line (`mode: read-only · proposes, never acts · rung 1 of 3`, byte-pinned; LIN-2626 turns it into a real control later), and — since LIN-2623 beat 3 — the per-turn model picker, its rate card, and an always-emitted tools-off warning (`hidden` while the model that will run is tools-on; visible when the resolved default is not tool-capable, and toggled by the picker on a pick, LIN-3363). A silent auto-wake tick (one that completes with no text, the designed common outcome) paints NO chat bubble at all: instead the single, non-stacking status line (`#flight-companion-checkin`, `aria-live="polite"`, hidden until first use) reads `checking in…` for the duration of the tick, then settles to e.g. `checked in 22:03 · nothing new` on `done` — consecutive silent ticks overwrite that one node rather than appending rows, and a non-spend `gate-silent` tick refreshes the same line the same way (LIN-2632 beat 3). A turn YOU start instead gets the thinking state: an assistant row appears IMMEDIATELY, before any token or tool event, in the in-progress pill state with a quiet `thinking…` placeholder — the placeholder is display-only and never enters the client-held history, so an empty turn still ends with an honest `no reply — nothing to add` on that SAME row rather than a second one. Either way the speaker pill leaves the in-progress state on `done`/`error`. Every `done` frame carries the turn's own summed usage (LIN-2631 sums hop usage server-side onto the FINAL `done` only). A visible turn's own bubble grows a trailing meta line (`.fc-msg-meta`, "N tokens · $x") reading exactly what that frame carried, no caveat text; the strip's running total and check-in count (LIN-2621 beat 3) accumulate on EVERY `done` this tab observes — visible or silent, any turn kind — since a silent tick's cost otherwise has nowhere to appear. `lib/openrouter.js` does not yet attach per-hop usage to a tool-using turn's intermediate frames, so a multi-hop turn's displayed cost under-reports today until that lands as its own follow-up — a known, out-of-scope gap, not a defect of this page. Tool use is visible (LIN-2632 beat 2, "it doesn't appear to use tools?"): a `tool` event's `call` phase renders a quiet breadcrumb row (`↳ <label> …`, `.fc-inline-note`), correlated by the event's own `id` to the matching `result` (which settles it — drops the ellipsis, keeps the call-time label) or `error` (which marks it, recomputed as `<name> failed: <reason>`) that closes it out; a `proposed` event still renders the Approve/Dismiss card instead, and `cap` still shows the tool-call-limit note. A `list_pending_decisions` result ALSO renders each returned decision as an option-button card (`.fc-decision`, LIN-2621 beat 4) in the thread, reusing the rulings tab's own primitive (`window.ChatUI.appendOptions`, `chat.css`'s `.chat-options*`) verbatim — no fork. Since LIN-3361 a breadcrumb is an expandable tool row (`.chat-tool-row`, a closed `<details>` with the arguments and clipped result), a run of adjacent ones compacts into one `.chat-tool-group` ("checked N things"), a result's decisions sit in one closed `.fc-decisions-fold`, and a long boot readout folds under "full readout" (`.chat-fold`). Breadcrumbs are rows, not bubbles — they never enter chat history and never trigger bubble creation on their own; on a user-initiated turn a breadcrumb arriving mid-hop inserts BEFORE the (already-present) thinking/answer row, reading `you → ↳ tool → the answer`. **Narrow viewports (≤600px) take the phone shape** (Shape B, LIN-1412 D2 / LIN-2632 beat 4): `.flight-companion-chat-section` becomes an ordinary (non-positioned) `height: 100dvh` column — the thread grows to fill the available height (chat.css's `--chat-thread-max-height` overridden to `none` here, rather than the shared 40vh default) instead of sitting in a short box, and the composer ends up flush with the viewport's own bottom edge as the column's last child once scrolled to, staying anchored even as a software keyboard shrinks the visual viewport (the `dvh` unit, not `vh`). No `position` of its own is needed: the page's own shared header (`.nav-bar`) is already `position: sticky`, which closes the header-height gap for free. `How to use` / `Kickoff prompt` / `Latest observer report` fold below — genuinely reachable (clickable, not merely geometrically in the viewport) by scrolling past the chat section. An earlier revision made the section itself `position: sticky`, which review (LIN-2632 finding F2) found pinned it over those sections for nearly the whole page scroll instead — fixed by dropping `position` entirely. This is a per-surface override of chat.css's own documented `--chat-thread-max-height` contract, not a fork of it — chat.css itself is untouched, so every other `.chat-thread`/`.chat-composer` consumer (Task Chat, Collective, the session reply surface) is unaffected. Above 600px the page is unstyled by this rule (Shape A: the thread stays inside the ordinary scrolling page). Since LIN-2620 the companion is also reachable directly over the workspace API proxy — `POST /api/proxy/flight-companion/turn` (`read` scope, billed to the token creator's own OpenRouter key) — so an agent can drive a turn without a browser tab at all: send `message`/`history` for a normal turn, or omit `message` for a message-less check-in tick (its own reservation instance, `stream: true` for the identical SSE frames or the default aggregated JSON body). It is always propose-only regardless of message presence — a model-proposed follow-up still waits for a human on the session-authed approve endpoint. See `GET /api/proxy/instructions` for the full request/response shape. Since LIN-2634, `GET /api/proxy/flight-companion/transcripts` (also `read` scope) is the other half of that loop — it returns the token creator's own saved companion transcripts (LIN-2437's opt-in saved chats), never another user's, so an agent can read what the companion told a human rather than only talk to it. The page also surfaces a copyable kickoff prompt (`lib/prompts/flight-companion-kickoff.js`) that you paste into a real Claude Code session, for when a full agent session — not just chat — is wanted; the copy carries a `readWrite` token that also holds the dispatch grant (LIN-3136), so that session can queue work. Since LIN-2618 the two are no longer separate mechanisms with separate copy: both render the SAME brief (`lib/prompts/flight-companion-brief.js`) — one persona, one disposition, one readout shape, one surfacing policy, one vocabulary, one propose-then-wait gate — and differ only in transport, the kickoff adding its proxy setup and curl catalog, the chat adding its tool names, the census seed, the clock and the turn kind. What is still separate is the SESSION each drives, not what the companion is. It monitors the same dispatch feedback stream (`GET /api/proxy/dispatch?status=taken` — plus its companion `GET /api/proxy/dispatch?status=blocked` for runs parked waiting on a human, which since LIN-2079 no longer appear under `status=taken` — and `GET /api/proxy/dispatch/{id}`, NOT the browser-cookie observation endpoints). `copy prompt` always appends the Workspace API access block (a FORCED append through the shared `ProxyToggle` seam, LIN-1764/LIN-3079; there is no `+proxy` toggle on this page): it mints the workspace owner's single-use DRIVER credential (`{ "purpose": "driver" }`, holding the dispatch grant), and for anyone else the copy is refused with a visible message and nothing is copied. Since LIN-2443 the prompt TEXT sits inside a `<details class="disclosure">` collapsed by default; `copy prompt`, the `+proxy` toggle and the copy-status text stay OUTSIDE it and are always actionable, so copying never requires expanding the disclosure (the copy path reads the `<pre>`'s `textContent`, which a closed `<details>` still has). ### Flight Companion DOM Structure ``` main.flight-companion-page[data-url-key] - page container section.flight-companion-chat-section - "Chat" — the FIRST section (LIN-2443). Below 600px this is ALSO the phone-shape (Shape B) root: one viewport tall, no `position` of its own (public/flight- companion.css; LIN-2632 review F2 dropped an earlier position:sticky, which pinned it over the sections below instead) #flight-companion-strip-notices - (LIN-3360) always-emitted notices slot (tools-off warning — always present, `hidden` when tools are on — and free-tier note), above the Settings fold; takes no height while both children are hidden details.fc-settings - (LIN-3360) closed native disclosure, summary "settings", wrapping the strip #flight-companion-strip - the status strip (LIN-2621), inside the Settings fold — server-rendered, above the thread. Prepending it does not reorder any sibling below, so the phone- shape selectors keyed on that order are unaffected; the strip itself joins the fixed-height (flex: 0 0 auto) group .fc-strip-model - "model: <code>…</code>" — this page's own resolveWorkspaceModel read, once per page load (see the note above on why this can differ from what a no-override turn actually resolves to since beat 1) .fc-strip-tools - "tools: on|off" — isToolCapableModel on that SAME resolved model id. Byte-identical, untouched by LIN-2623 beat 3's additions below .fc-strip-tools-warning - (LIN-2623, LIN-3363) "⚠ tools are off for this model — …", ALWAYS emitted (so the picker has something to reveal), carrying data-default-tools-on. `hidden` iff the model that will run is tools-on: on load, the resolved DEFAULT; after a pick, the pick's own data-tools (a model that is not tool-capable shows the warning; a tool-capable one hides it — LIN-3882: tools follow the catalog's supported_parameters when present, else curated membership). A free-tier page ignores any pick (the clamp runs the default) .fc-strip-checkin - "last check-in: <time>|never" — the companion doc's own lastTurnAt #flight-companion-strip-next - "next check-in: —" server-rendered placeholder; filled/updated client-side from the wake cadence's own countdown — no server-side schedule exists to render .fc-strip-sweep - "sweep alive|stale · last seen …" or LIN-2487's own "no fleet scan yet" wording (reused verbatim, not re-implemented) #flight-companion-strip-tab-total - (LIN-3360: now in #flight-companion- checkin-row beside the check-in line, hidden until the first check-in) "N check-ins · $x this tab" — server- rendered with its TRUE initial value ("0 check-ins · $0.00 this tab"); updated in place client-side on EVERY `done` frame this tab observes, visible or silent, any turn kind (LIN-2621 beat 3) .fc-strip-mode - "mode: read-only · proposes, never acts · rung 1 of 3" — byte-pinned text; LIN-2626 turns this into a real control later .fc-strip-model-picker - (LIN-2623, LIN-3363) the per-turn model override combobox, beside the rate card. Offers the Settings dispatch-row list: curated AVAILABLE_MODELS ids plus the live catalog (searchable by id or name), which is exactly what resolveTurnModelOverride accepts, so no offered choice 400s. An option that is not tool-capable is tagged "tools off" (LIN-3882: a catalog-only model listing `tools` is tool-capable); free ones are tagged "free" #flight-companion-model-search - the visible <input type="search" role="combobox">, aria-label "Per-turn model override", aria-controls the list. Typing filters (case-insensitive substring of id or name); Down/Up/Enter/ Escape work; typed text that matches nothing commits nothing. Disabled outright on a free-tier request #flight-companion-model - the COMMITTED choice (hidden input). value="" ("— current default —") means an untouched picker sends NO `model` field on the turn request at all, so beat 1's resolveAiOperationModel decides exactly as before — it can never silently swap a possibly-uncurated workspace default for some other offered id. Only a list pick writes it; a stored id no longer offered is reset to "" on restore and re-saved #flight-companion-model-list - ul[role=listbox], IN FLOW (not an absolute popup, which the Settings fold's overflow would clip), max-height ~9rem with its own scroll so it fits the fold's 40dvh cap. Rows: li[role=option] with data-id, data-name, data-pricing, data-tools="off", data-free; the first row (data-id="") is "— current default —" #flight-companion-model-price - the rate card, next to the picker — the selected option's own data-pricing attribute (getModelPricingHint), "—" for an id with no known rate (never a fabricated or zero price); updates live on each pick .fc-strip-freetier - (LIN-2623) "free tier: model selection is not honored — …", rendered only on a free-tier page load, alongside a disabled picker — the server-side `forceDefault` clamp always wins there, so the control is disabled rather than accepting a choice that would be silently discarded #flight-companion-thread - live chat thread (ul.chat-thread). Below 600px this fills the section instead of chat.css's 40vh default (--chat-thread- max-height overridden to none) .fc-msg (chat-msg) - a turn's bubble. A user-initiated turn's assistant row appears IMMEDIATELY (before any token), pill in-progress, body "thinking…" — replaced outright by the first token, or by "no reply — nothing to add" on an empty done (LIN-2632 beat 3) .fc-msg-meta - a visible turn's own cost meta line ("N tokens · $x"), appended once `done` carries usage — reads exactly what the frame carried, never a fabricated "0 tokens · $0.00" when usage is absent (LIN-2621 beat 3) .chat-tool-row - (LIN-3361) a tool call: li > closed <details>; summary "↳ <label> …" while pending, settled/marked in place on the matching result/error by the tool event's own id (LIN-2632 beat 2); the body holds the arguments and the clipped result as <pre> text. Not a bubble, not persisted (rows are gone after a reload) .chat-tool-group - (LIN-3361) a run of 2+ DOM-adjacent tool rows: li > details, summary "checked N things" (… while any is pending, [data-has-error] if any failed) over ul.chat-tool-group-list. Any other li (a decision, proposal card, note, bubble) between rows breaks the run .fc-inline-note (chat-note) - a cap-limit / proposal-fallback / error note (LIN-2632 beat 2) — notes break a tool run .fc-decisions-fold - (LIN-3361) ONE closed <details> per `list_pending_decisions` result, summary "N decisions waiting", holding a Rulings link `[data-testid="fc-decisions-rulings-link"]` (/workspace/{urlKey}/observation?view= rulings) and the `.fc-decision` cards. The cards stay in the DOM while it is closed, so their buttons work once opened .fc-decision-ref a - the decision's task link `[data-testid="fc-decision-task-link"]` .chat-fold - (LIN-3361) inside an assistant bubble: a closed <details>, summary "full readout", holding everything from the first h1-h6/hr (or a bold label naming a readout heading, `data-fc-readout-headings` on the page element) on. Only when something precedes the anchor and the tail is ≥ ~280 characters; otherwise the bubble is not folded. Display only: history and saved transcripts keep the raw text a.chat-task-link - (LIN-3361) `[data-testid="chat-task-link"]` a task identifier in bubble text, linked ONLY when it was seen in structured tool data (args/results) or a decision row this session (the seen set is persisted with the session); never linked inside a/code/pre/summary/button .fc-decision - a waiting-on-you decision, rendered from a `list_pending_decisions` tool result (LIN-2621 beat 4) — the question plus the SAME option-button row the rulings tab uses (`.chat-options-row`/`.chat-option-btn`, `window.ChatUI.appendOptions`; a star marks the recommended option). On the RULINGS TAB specifically, that row's caption now names the row's resolved `effect` when it can act — "Answer & resume" / "Answer & start a run" / "Record answer" (LIN-2775) — falling back to the disposition caption otherwise. This `.fc-decision` card does NOT: its own tool result (`projectPendingDecision`, lib/chat- tools.js) carries no `effect` field, so its caption stays disposition-keyed exactly as before, and its tap stays comment-only regardless (see below). A read-only disposition renders `.chat-options-- readonly` (a caption only, no buttons). A tap POSTs through `window.ReplyDelivery. postComment` ONLY (never a dispatch/resume call) and settles the card in place (`.fc-decision--resolved`, `.fc-decision- feedback`); buttons disable immediately so a second tap cannot double-post #flight-companion-chat-empty - empty state, hidden once a row exists #flight-companion-start - "start" button (LIN-2622), visible only in the empty state; POSTs the boot turn (see Endpoints below) and renders it as a user-initiated turn with a synthetic "Start" user row #flight-companion-checkin - the one check-in status line: hidden until first use, then either the transient "checking in…" (for the duration of a silent/gate-silent auto-wake tick) or the settled "checked in HH:MM · …" text it overwrites in place on `done` (aria-live=polite; never appended to) #flight-companion-reorient - "reorient" button (LIN-2622), the complementary half of #flight-companion-start — hidden until the empty state is gone, then visible; drives the SAME boot turn. Carries the existing `.action-btn.connect` colour variant (LIN-2622 close-out finding F1, fixed by LIN-2621 beat 2) rather than the bare, effectively-invisible `.action-btn` it used to — kept in this same sibling position rather than folded into the status strip above #flight-companion-question - chat composer (textarea, auto-grows to an 8.5rem cap then scrolls; Enter sends, Shift+Enter inserts a newline; whitespace-only never sends) #flight-companion-send - send button .fc-proposal - an inline model-proposed follow-up .fc-proposal-approve - approve — POSTs to approve-follow-up .fc-proposal-dismiss - dismiss — client-only, no request section (How to use) - short intro + the three steps. Below 600px, folds below the chat section, reachable by scrolling past it section (Kickoff prompt) - the paste-to-Claude-Code transport (same shared brief as the chat since LIN-2618). Below 600px, folds below the chat section too #flight-companion-copy - copy-to-clipboard button (outside the collapse) .prompt-proxy-toggle - +proxy toggle (always present on workspace pages) #flight-companion-copy-feedback - copy status text details.disclosure - collapsed by default (rendered without `open`) #flight-companion-prompt - the kickoff prompt itself (pre, copyable) section (Latest observer report) - read-only observer-pass panel, stays last (folds below on narrow viewports too) ``` ### Flight Companion Endpoints - `POST /workspace/{urlKey}/api/flight-companion/turn` — SSE chat turn; body `{ message?, history[], model?, timeZone? }` (an absent/empty `message` is an auto-wake tick, never a client-asserted flag). `model` (LIN-2623 beat 2) is the picker's own optional per-turn override — omitted or empty means "no override" (beat 1's `resolveAiOperationModel` decides); an explicitly supplied id must be a curated `AVAILABLE_MODELS` id or an id in the cached model catalog (the Settings model list) or the request is a clean `400` (`not an offered model id`) (never a silent tools-off degrade); a valid override is still clamped to the free-tier default when the request is free-tier. `timeZone` (LIN-3362) is optional: an IANA zone, canonicalised server-side, that sets the clock the model is told — an invalid zone keeps the UK clock. Events: `token`, `hop-text` (`{ text }`: a tool hop's interim prose; browser user-initiated and boot turns only, never on auto-wake), `tool` (`phase`: `call`/`result`/`error`/`cap`/`proposed`), `usage` (`{ iteration, usage }`: one tool hop's usage, already summed into `done.usage`; LIN-2705), `done` (an auto-wake `done` carries `surface`), `error`. A non-spend auto-wake tick returns plain JSON `{ turnKind, spent:false, reason }` instead of streaming. - `POST /workspace/{urlKey}/api/flight-companion/boot` — SSE boot (orient) turn (LIN-2622), driven by `#flight-companion-start`/`#flight-companion-reorient`; body `{ history[], timeZone? }` only (`timeZone` as on `/turn`: an optional IANA zone, canonicalised server-side, an invalid zone keeps the UK clock) — `message`/turn kind/`followUpMode`/budget are all endpoint-hardcoded server-side, never read from the body (the same "never client-asserted" rule as `/turn`). Propose-only (never executes a follow-up), its own budget (5 tool iterations / 4000 tokens, `FC_MAX_TOKENS`) and lease, and it charges the free tier with the SAME 429 shape a typed turn gets — never auto-wake's silent refusal. Same SSE frame set as `/turn` (`token`/`hop-text`/`tool`/`done`/`error`), consumed by the identical client stream reader — no boot-specific parsing exists. - `POST /workspace/{urlKey}/api/flight-companion/approve-follow-up` — body `{ sessionId, prompt }`; enqueues the follow-up the model proposed on a prior auto-wake turn. A decision-card tap (`.fc-decision`, LIN-2621 beat 4) is NOT a Flight-Companion-specific endpoint: it reuses the general `POST /workspace/{urlKey}/api/comments/{issueId}` write via `window.ReplyDelivery.postComment` — the same session-auth reply path the rulings tab and `public/scan.js`'s answer form already use. A loop-anchored decision (resumable/gone) best-effort-stamps `markDecisionAnswered` (`decisionLoopId`/`decisionId` in the body); a task-bound decision best-effort-stamps `markOutcome` the same way (`taskDecisionId`/`taskDecisionIssueId` in the body, LIN-2704) — the tool result (`lib/chat-tools.js`'s `projectPendingDecision`) projects the raw issue UUID and the scan store's own record id alongside `issueIdentifier`, so a task-bound tap leaves the pending set on the next listing just like a loop-anchored one. ## Passage Planner (experimental) The Passage Planner page (`/workspace/{urlKey}/passage-planner`) is Flight Companion parity (LIN-1849) for the Passage Planner prompt. It surfaces the validated live passage-planning session kickoff prompt (`lib/prompts/passage-planner-kickoff.js`, which reads `docs/passage-planner-prompt.md` at HEAD with its design-artifact preamble stripped) that you paste into a fresh Claude Code session with a `readWrite` proxy token holding the dispatch grant to orient off the real state of the workspace and negotiate a small ratified passage of work together. Requires the per-user `passagePlanner` feature flag; reached only via a link in Settings. Unlike Flight Companion, there is no user-facing `+proxy` toggle — the prompt requires the access block, so copy always attempts a FORCED append (`ProxyToggle.maybeAppend(text, urlKey, {force:true})`) (there is no per-user proxy switch, LIN-3849). That append mints the workspace owner's DRIVER credential (holding the dispatch grant the planner's single kickoff call needs); a non-owner's copy is refused with a visible message and nothing is copied (LIN-3136). ### Passage Planner DOM Structure ``` main.passage-planner-page[data-url-key] - page container #passage-planner-prompt - the kickoff prompt (pre, copyable) #passage-planner-copy - copy-to-clipboard button #passage-planner-copy-feedback - copy status text ``` ## Live Console The Live Console page (`/workspace/{urlKey}/live-console`) is an ambient, "lean-back" realtime feed of agent activity merged across ALL connected workspaces. It has TWO sources, shaped by `lib/live-console.js`: the agent-status log (research/implementation/review/close-out steps → the event STREAM) and lean dispatch loops (running loops → currently-working LANES carrying their latest HEARTBEAT — tool count / elapsed / per-tool breakdown — plus `[evidence]` artifacts as linked stream events; heartbeat beats also feed the tempo sparkline so it keeps moving between discrete steps). Reads Mongo only — no Linear calls and no LLM per poll. Promoted out of experimental (LIN-3977): on for an owner with a live runner key or queue token, with no stored flag needed; the per-user `liveConsole` flag is an opt-out (a stored `false` turns it off; a non-owner without a stored `true` keeps today's behaviour). It sits in the nav's roadmap tier; the route redirects to `/settings/features` when off. It opens on the CURRENT workspace (`?scope=current`, the default); `?scope=all` widens it to every connected workspace, and the chips filter inside that view. The task identifier in a lane, a stream row and the timeline's detail line links to the task page (LIN-4035). Each run has its own labelled link to its run page (LIN-3976) by the session ledger's id: the lane's "open run ↗", a stream row's "run ↗", and the timeline bar itself. A lane says its task title ("ID · title"), its plain stage word, and "waiting on you" (with its time when the wait is marked) instead of a raw kind. The activity section starts collapsed on every width (LIN-4035): its heading is a disclosure button with "· N new" for events that arrive after load, and the viewer's choice is remembered per browser. Polls every 5s (chained, in-flight-guarded, exponential backoff on failure; pauses while the tab is hidden). A "view earlier activity" control pages OLDER status events (before page load) into a history region below the live feed. Purely presentational — no writes. ### Live Console DOM Structure ``` main.lc-page[data-url-key] - page container [data-testid="live-console-banner"] - status banner #live-console-dot[data-health] - health dot (live|waiting|done|error|idle); error only while a shown run failed and nothing has followed it (LIN-3977) #live-console-status[aria-live=polite] - one-line fleet status / connection state [data-testid="live-console-strip"] - flowing activity strip section (LIN-1505 Phase C: zoom controls) [data-testid="live-console-pulse-presets"][role=group][aria-label="Strip range"] - 3m/15m/1h/6h range buttons (reuses .lc-timeline-preset styling verbatim); 3m is the always-on-load default, never data-dependent [data-testid="live-console-pulse-preset-3m|15m|1h|6h"][data-range][aria-pressed] - one per rung #live-console-pulse-span-text - visible "last N minutes/hours" label, kept in sync with the active span (same phrasing convention as the timeline's window label) .lc-pulse > #live-console-tempo - full-width flowing activity strip <canvas>: scrolls right→left in real time (now = right edge, NEVER pans — zoom only); a soft heartbeat "hum" area under colour-coded event blips that drift left and fade. rAF-animated, reduced-motion aware (static snapshot instead of scrolling, but a preset/gesture still repaints immediately). Zoom: ctrl/meta+wheel (desktop) or two-finger pinch (touch) on `.lc-pulse` itself, gated identically to the timeline's; deliberately NO one-finger gesture here — a one-finger vertical swipe over the strip still scrolls the page (`touch-action: pan-y`, unlike the timeline's `none`). Independent of the timeline's own zoom/pan — never coupled, never shares state or constants with it. [data-testid="live-console-scope-current|all"][data-scope][aria-pressed] - the scope control (LIN-3977; hidden when <2 workspaces): this workspace (default) or all [data-testid="live-console-chips"] - workspace filter chips (hidden when <2 workspaces, and only shown under scope=all) .lc-chip[data-ws][aria-pressed] - one toggle per workspace (.lc-chip--off when filtered out) .lc-timeline-section - swimlane timeline: recent dispatch runs as bars, default window is an auto-fit view latched at first paint (clamped to a 1h floor), zoomable/pannable (LIN-1743); the axis' fixed bound stays the server's 24h data window (LIN-1742/LIN-1928) .lc-timeline-presets - fit/1h/24h range buttons; the section label, its aria-label, and the empty-state text below all reflect whichever window is actually active, not a literal "24 hours" (LIN-1928) #live-console-timeline-axis - time axis mount, same page column as the label above (no full-bleed breakout) [data-testid="live-console-timeline"] - bars viewport (internal vertical scroll, no row cap) [data-testid="live-console-timeline-connectors"] - SVG overlay: one curved path per run-to-run connector (server `timeline.connectors` {fromId,toId} edges), sits behind the bars, skipped when either endpoint bar is hidden [data-testid="live-console-timeline-bar"][data-kind][data-ws] - one bar per run (LIN-3977: its label is an <a> to the run page, so Tab and Enter open it) (LIN-2905: a `kind:'wake'` run is folded into the target run it resumes and never gets its own bar); data-kind vocabulary matches the stream's (below); .lc-timeline-bar--clipped marks a run whose true start predates the 24h window (clipped to the left edge, not dropped); .lc-timeline-bar-label child renders the visible "ticket identifier — kind/promptName" text (title/ aria-label carry the same string) [data-testid="live-console-timeline-wake-marker"] - one thin tick per folded wake (server `run.wakeMarkers[]`), positioned at the wake's own dispatch time within the bar's own span; title/aria-label read "check-in: woken by <issueIdentifier>"; [data-still-running]/[data-outcome] mirror the folded wake's own `stillRunning`/`outcomeKind` so a wake that is still running or ended non-done renders distinctly from a quiet, done one; the bar's own title/label also gets a " · N check-ins" suffix when `run.wakeCount > 0` [data-testid="live-console-timeline-key"] - the key sentence under the timeline (amber running, green done, red failed; a tick is a check-in) [data-testid="live-console-timeline-detail"] - the detail line under the key: a bar's full label ("ID · title — stage", then ticket progress and check-ins), the identifier linked to the task page, and "open run →" and "open task →" links (LIN-4035). Set by hover, focus, or a touch tap (a second tap opens the run); a bar's own label is a link to its run page (LIN-3977) [data-testid="live-console-lanes"] - "working now" rail (<ul>) [data-testid="live-console-lane"] - one breathing lane per working or waiting agent (`.lc-lane--waiting` while waiting) a.lc-lane-task[href] - the task identifier, linked to its task page (`?source=` carried) .lc-lane-title - the task's title, after a `·` separator (`.lc-lane-title-sep`); hidden when none (LIN-4035) .lc-lane-stage - the plain stage word ("Build", "Plan review"), never a raw kind [data-testid="live-console-lane-action"][data-waiting="1"] - "waiting on you" (+ "since <time>" when marked); "working"; or "parked since …" (LIN-3977) a.lc-lane-run[href] - the run's page (`/workspace/{urlKey}/observation/session/{sessionId}`); hidden when the run has no session .lc-lane-cred - shown only when the credential is dead ("re-issue the token") [data-testid="live-console-heartbeat"] - live tool-count/elapsed/breakdown tick (empty when none); escaped HTML — an idle beat (state:'idle') shows the shared ".obs-act-chip.obs-act-idle" "no tools" chip (style.css, LIN-3977) instead of the numeric count [data-testid="live-console-activity"] - the activity section (LIN-4035: collapsed on every width) button#live-console-activity-toggle[aria-expanded][aria-controls] - the heading as a disclosure button; `#live-console-activity-new` reads "· N new" once events arrive after the first feed (hidden at zero; cumulative for the page session) [data-testid="live-console-activity-body"][hidden] - the body: stream, empty state, history and "view earlier". Collapsed in the server HTML; the open choice is one localStorage key (`live-console-activity`, per browser), and a storage that throws falls back to collapsed [data-testid="live-console-stream"] - live activity stream (<ol>, newest first) [data-testid="live-console-event"][data-kind] - one event; data-kind: done|working|blocked|failed|evidence|info a.lc-event-task[href] - the identifier, linked to the task page (`?source=` carried), plain text when there is no task page (LIN-4035: never the run page); `.lc-event-action` is the plain word (LIN-3977) .lc-event-title - the task's title after the identifier (LIN-4035): clipped on desktop, hidden under 560px; the task link's title and aria-label carry it a.lc-event-run[href] - "run ↗" to the run page (`/observation/session/{sessionId}`), when the row has a session (LIN-4035) a.lc-event-summary-link[href] - (evidence only) links to the produced artifact [data-testid="live-console-history"] - paged-in OLDER events (<ol>, below the live stream) [data-testid="live-console-more"] - "view earlier activity" pager button ``` - `data-kind` vocabulary: `done` (✓), `working` (◐), `blocked` (◑), `failed` (✗), `evidence` (↗), `info` (·) — colour AND glyph carry the state (never colour alone). - New events on later polls get a transient `.lc-event--new` class; lane pulses + heartbeat ticks persist across polls via a keyed reconcile (nodes are updated in place, not rebuilt). ### Live Console Endpoint - `GET /workspace/{urlKey}/api/live-console/events?scope=current|all` — merged, normalized live feed (session-auth; 403 when the flag is off; `scope` defaults to `current`). Returns `{ events:[{id,kind,ts,iso,workspaceUrlKey,workspaceName,task,action,actionWord,status,summary,url,loopId,sessionId,derived}], lanes:[{workspaceUrlKey,workspaceName,task,title,stage,sessionId,action,summary,sinceMs,lastActivityMs,heartbeat:{toolCount,elapsedSeconds,breakdown,total,state},waiting,waitingSince,waitingFor,credential,ticketWalk,parkedWait,issueSource?}], tempo:[n,...], pulse:{bucketMs,endTs,buckets:[n,...],load:[n,...]}, timeline:{rows:[[{id,issueIdentifier,kind,promptName,outcomeKind,start,end,stillRunning,clippedStart,groupKey,followUpTo,workspaceUrlKey,connectorTruncated,wakeMarkers,wakeCount},...],...], connectors:[{fromId,toId},...], truncated, totalInWindow}, serverNow, summary:{active,working,waiting,failedNow,done,failed,blocked,total}, hasMore, oldestTs }`. LIN-3977: a lane's `waiting` is true when a run waits on a person (either channel); `waitingSince` is set only for a `[blocked]`-marked wait (epoch ms, else null); `title` is the identity source's issue title; `stage` is the stage word; `sessionId` is the run id (null when the run has no session). `credential` is `{state,label}` and the page shows it only when `dead`. `summary.failedNow` counts tasks whose latest run in the window failed and that nothing has followed: the status dot is red only for those. `pulse` is heartbeat-only fine-grained density (the strip's hum); `serverNow` anchors the strip's time axis (also the timeline's view-window right edge, whichever window is active). `timeline.rows` is pre-packed into non-overlapping display rows (`lib/live-console.js`'s `packTimelineRows`, LIN-1908 Phase A: a two-level pack — same-session runs pack within their own group-row as before, then group-rows from EVERY session are first-fit across groups, sorted by start ascending, admitting a group-row into a shared display row only when it starts at least `TIMELINE_ROW_BUFFER_MS` (~8.6min) past that row's current end) — each row is an array of runs never overlapping in time, but a row no longer means "one session lineage" (it can hold several unrelated short sessions) and rows are emitted in ascending-start creation order, not most-recently-active-first; `outcomeKind` (not dispatch `kind`) is the colour source; a `kind:'wake'` run is folded into the target run it resumes instead of being emitted as its own row — the target's `wakeMarkers` (`{id,at,issueIdentifier,outcomeKind,stillRunning}` per folded wake) and `wakeCount` (`=== wakeMarkers.length`) carry the fold, and a wake whose target aged out of the 24h window or was dropped by the 500-run cap is dropped silently (no orphan bar, no marker); `end:null` + `stillRunning:true` is an open-ended (still-running) bar; `stillRunning:'unknown'` is a stale-but-not-yet-terminal run; `truncated`/`totalInWindow` disclose when the 500-run cap dropped older rows (LIN-1494 pattern). No model call. LIN-1908 Phase C: `pulse.load[]` is additive alongside `buckets[]` — same length/anchors, each bucket summing its beats' `toolCount` (falling back to `total`) rather than counting `+= 1` — so the client can render magnitude nested inside the beat-count hum area on its own decaying scale, never replacing `buckets[]`'s own meaning; `heartbeat.state` (`'running'|'idle'|null`, from `lib/session-telemetry.js`'s `parseHeartbeat`) is likewise additive and surfaces as an escaped idle chip on a lane's heartbeat tick when `'idle'`. - `GET …/events?before=<epochMs>&limit=<n>` — a HISTORY page: older status events strictly before the cursor. Returns `{ events:[…], hasMore, oldestTs }`. ## Common Workflows 1. **Find in-progress tasks**: Look in "In Progress" section at top (displays as tree with subtasks) 2. **Drill into project**: Click project header to expand, click task to see details 3. **Expand subtasks**: Click parent task or ▶ arrow to show child tasks (works in both sections) 4. **Filter by team**: Use team selector in nav bar 5. **Search for issues**: Click "search" in nav bar (or press "/"), type keywords to filter issues across all sections 6. **Open in provider**: Expand task, click the "View in {provider} →" link (text is dynamic, e.g. "View in Linear") 7. **View task details**: Expand task, click "Details ▶" to show description and metadata 8. **Generate prompt for task**: Expand task, click "Prompts ▶" to reveal the shared opened-task component, click a template under "other prompts", copy the generated prompt 9. **Get the next step (AI)**: Expand task, click "Prompts ▶", click the "✦ next step" button (free tier: prompts unlimited; runs per account per day) ## Limitations - Task editing depends on the provider: the Edit link and the task-edit page (`/workspace/{urlKey}/task/{issueId}/edit`) appear only when the provider's `ui.inlineEdit` is true; otherwise the task-edit page redirects to the dashboard and tasks are view-only - Logout ends the session and returns to the landing page; signing in again uses whichever methods the server has configured (Linear, GitHub, Jira, or email). In PAT mode (`LINEAR_ACCESS_TOKEN` set) the next visit signs in again automatically, so logout does not stay signed out - Expand/collapse state persists in localStorage (reset restores defaults) - Team filter selection is remembered per workspace: a localStorage cache in the browser plus the account's server-side preferences, so it carries across sessions and devices - Landing page (`/`, unauthenticated) is a bespoke Harbour showcase: a brand hero with one plain sentence that says what Harbour does, and the sign-in buttons; fake-data glimpses of real surfaces (observation feed, swim board, grounded prompt); a "try it yourself" callout that repeats the sign-in buttons; a providers strip; a distinct Harbour OS section; the Library and the Archive; and a footer whose `/swipe` and `/swim` links are labelled as previews. It is NOT the project-tree renderer; section blocks carry `[data-testid="landing-{hero,plain,loop,observation,swim,prompt,try,providers,os,library,archive}"]`. The `/swipe` and `/swim` unauthenticated previews still render sample tree data.