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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.20.0] - 2026-09-01

### Added

- **`linear tasks` auto-scopes to the current directory's project.** With no
`--project` and no `--all`, the queue (and `--board`) now scope to the Linear
project bound to the current working directory, so an agent launched inside a
project folder sees that project's work instead of the entire workspace — the
fix for agents picking up tasks from the wrong project. The directory→project
mapping comes from the `agents projects` CLI (`for-cwd` does a longest-match
over every bound root and monorepo subpath), consulted via two bounded,
fail-open subprocess calls: no `agents` on `PATH`, no def for this cwd, or a
slow/broken call leaves the queue unscoped exactly as before. A cwd binding
that resolves to a project id which is not a live project on the team (a
renamed/recreated project or a stale recorded id) is also validated fail-open
— it falls back to the whole-team view with a one-line note, never aborting
the command the way a mistyped explicit `--project` does. Overrides:
`--all` (whole team), `--project X` (a specific project), or `autoScope: false`
in `~/.linear-cli/config.json` (disable globally). The `--json` output gains a
`project: {id, name, auto}` field (null when unscoped) so consumers can tell an
auto-scoped queue from the full workspace.

## [0.19.1] - 2026-08-14

### Fixed
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,8 @@ linear tasks --assignee me # by assignee: me | none | someone@x.com
linear tasks --project "Rush App" # scope to one project (name or UUID)
linear tasks --milestone "v1.0" # scope to one milestone (the whole deliverable)
linear tasks --project "Rush App" --by-milestone # group by milestone; unmatched fall in "No milestone"
linear tasks --json | jq # machine-readable
linear tasks --all # whole team, ignoring the cwd project auto-scope
linear tasks --json | jq # machine-readable (adds "project": {id, name, auto})

linear update ANT-42 --pickup # claim (In Progress)
linear update ANT-42 --comment "..." # progress note
Expand Down Expand Up @@ -146,6 +147,7 @@ Full help: `linear <command> --help`.
The same CLI works whether you're typing or a subagent is. Driving Linear from either shouldn't require shelling out to `@linear/sdk`, hand-rolling GraphQL, or parsing HTML.

- **Assignee-as-queue.** `linear tasks` returns what *you* own in the active cycle. Widen with `--cycle all` (whole team) or `--cycle none` (backlog), or filter by `--assignee me|none|<email>`. No dashboards, no saved views.
- **Directory-aware scope.** When `agents projects` binds the current directory to a Linear project, `linear tasks` (and `--board`) auto-scope to that project — so an agent launched inside a project folder works that project's queue, not the whole workspace. `--all` shows every project, `--project X` overrides, and `autoScope: false` in `~/.linear-cli/config.json` disables it. Fail-open: with no `agents` CLI or no binding for the cwd, nothing changes. The `--json` output carries `project: {id, name, auto}` (null when unscoped).
- **Milestones as deliverables.** `--milestone` scopes to one deliverable across all cycles; `--by-milestone` groups a project's issues by milestone (with a *No milestone* bucket for unmatched work), each row annotated with its cycle so you see which iteration a deliverable's work is scheduled in. `linear projects` / `milestones list` roll up per-milestone % done, so a deliverable's progress sits next to its target date. Scoping to `--project`/`--milestone` widens to all cycles by default (the whole deliverable, not just this cycle's slice).
- **Native agent delegation.** `linear update ANT-42 --delegate claude` sets Linear's `delegateId`: the human stays assignee, the agent becomes delegate, and review ownership stays clear.
- **One ownership model.** `delegate` is the only thing that owns an issue. `linear tasks --agent claude` filters to issues delegated to Claude; the default view adds the issues nobody has been delegated (`delegate` is null). `linear tasks --board` groups its columns by delegate. There is no label lane — an unknown `--agent` aborts rather than printing an empty queue.
Expand Down
200 changes: 176 additions & 24 deletions linear
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ _LOCK_CONTENDED_ERRNOS = tuple(
if e is not None
)

__version__ = "0.19.1"
__version__ = "0.20.0"

# Sentinel for "flag not supplied" — distinct from None, which means an explicit
# clear (e.g. `--project none`). Lets update pre-resolve a field once and pass
Expand Down Expand Up @@ -1142,6 +1142,77 @@ def resolve_project_id(api_key: str, team_id: str, value: str,
return match["id"]


def resolve_cwd_project(timeout: float = 3.0) -> tuple[str | None, str | None]:
"""Best-effort: which Linear project owns the current working directory?

The directory -> project mapping is owned by the `agents` CLI: `agents
projects` binds a repo root and its monorepo subpaths to a Linear project
(`linear.projectId` / `linear.name`). We ask it rather than re-deriving the
mapping, because `for-cwd` does a longest-match over every bound root and
subpath — a worktree, a subdir, and two projects sharing one monorepo root
all resolve correctly, none of which a basename comparison could do.

Returns (projectId, projectName), or (None, None) when nothing claims this
directory. Fail-open by design: no `agents` on PATH, no def for this cwd, a
def with no Linear binding, or a slow/broken call all yield (None, None) and
the caller falls back to the unscoped team view. Never raises — this runs on
the hot path of the most-used command and must not brick it.

Two bounded subprocess calls, ~0.3s each on an idle box. `for-cwd` returns
the LOCAL def name ("prix"); `list` carries the Linear binding, and several
defs may point at one project, so the id/name come from the matched def.
"""
def _agents_json(*argv: str):
try:
out = subprocess.run(["agents", *argv], capture_output=True,
text=True, timeout=timeout)
except (OSError, ValueError, subprocess.SubprocessError):
return None
if out.returncode != 0 or not out.stdout.strip():
return None
try:
return json.loads(out.stdout)
except (ValueError, TypeError):
return None

for_cwd = _agents_json("projects", "for-cwd", "--json")
def_name = (for_cwd or {}).get("name") if isinstance(for_cwd, dict) else None
if not def_name:
return (None, None)

defs = _agents_json("projects", "list", "--json")
if not isinstance(defs, list):
return (None, None)
for d in defs:
if isinstance(d, dict) and d.get("name") == def_name:
lin = d.get("linear") or {}
return (lin.get("projectId"), lin.get("name"))
return (None, None)


def resolve_auto_project_id(api_key: str, team_id: str, value: str) -> str | None:
"""Validate an auto-detected (cwd) project id/name against the team's live
projects. Returns the matching projectId, or None when it doesn't match.

NEVER raises and never exits — the key difference from
resolve_project_id(strict=True). An explicit --project that doesn't resolve
is a user typo worth aborting on (RUSH-1496); an auto-detected value that
doesn't resolve just means the `agents projects` binding drifted (a project
renamed/recreated, or a stale recorded id) — a condition outside this CLI's
control that must fall back to the unscoped view, not brick the most-common
command. Membership is checked against the live project list, so it also
catches a stale-but-UUID-shaped id that resolve_project_id's shape-only UUID
passthrough would wave through into a filter that silently matches nothing.
"""
if not value:
return None
want = value.lower()
for p in list_team_projects(api_key, team_id):
if p.get("id") == value or (p.get("name") or "").lower() == want:
return p.get("id")
return None


def list_project_statuses(api_key: str) -> list[dict]:
"""Workspace project statuses (Backlog / Planned / In Progress / ...)."""
data = gql(api_key, """
Expand Down Expand Up @@ -2014,15 +2085,56 @@ def list_tasks(args, cfg, api_key, team_id):
(`all`, cross-cycle + backlog), or the backlog only (`none`).
`--assignee EMAIL|me|none` filters by who the issue is assigned to.
`--project NAME|UUID` scopes to one project (strict: unknown name aborts).
With no `--project` and no `--all`, the queue auto-scopes to the project
bound to the current directory (via `agents projects`), so an agent launched
inside a project folder sees that project's work, not the whole workspace.
Tasks are sorted by priority, then due date (earliest first), then id.
Results are fully paginated — no silent truncation at Linear's 50-issue cap.
"""
# Project scope, resolved up front because it also widens the cycle scope (a
# project view spans all cycles, not just the active one). Precedence:
# 1. explicit --project wins (existing behavior).
# 2. --all forces the whole-team view (auto-scope suppressed).
# 3. otherwise, when `agents projects` binds this cwd to a project,
# auto-scope to it — cwd behaves like an implicit --project.
# This is the fix for "an agent launched in a project subfolder lists every
# project's tasks and picks the wrong one". Opt out with `autoScope: false`
# in config.
#
# The two sources have DIFFERENT failure policies: a mistyped --project must
# abort (RUSH-1496), but a stale/malformed cwd binding — which comes from
# `agents projects`, outside this CLI's control — must fall back to the
# unscoped view, never brick the single most-common command. So the explicit
# path resolves strictly (crash on no-match) and the auto path validates
# fail-open (drift -> None -> unscoped + a one-line stderr note).
explicit_project = getattr(args, "project", None)
pid = None
auto_project_name = None
if explicit_project:
try:
pid = resolve_project_id(api_key, team_id, explicit_project, strict=True)
except LookupError as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
elif not args.all and cfg.get("autoScope", True) is not False:
cwd_pid, cwd_pname = resolve_cwd_project()
if cwd_pid:
pid = resolve_auto_project_id(api_key, team_id, cwd_pid)
if pid:
auto_project_name = cwd_pname or cwd_pid
else:
print(f"linear: the current directory's project "
f"'{cwd_pname or cwd_pid}' (from agents projects) is not a "
f"live project on this team — showing the whole team. Use "
f"--project to scope explicitly.", file=sys.stderr)

filters = [f'team: {{ id: {{ eq: "{team_id}" }} }}']

# Cycle scope. active/next resolve to a concrete cycle id (and a header
# name); `all` drops the cycle filter; `none`/`backlog` lists the backlog;
# any other value is treated as a cycle name or id and resolved.
scope = resolve_task_scope(args.cycle, getattr(args, "project", None),
# any other value is treated as a cycle name or id and resolved. A resolved
# project scope (explicit or auto) widens to all cycles.
scope = resolve_task_scope(args.cycle, pid,
getattr(args, "milestone", None))
fragment, scope_label, cycle_meta = build_cycle_scope(api_key, team_id, scope)
if fragment is None:
Expand Down Expand Up @@ -2089,17 +2201,9 @@ def list_tasks(args, cfg, api_key, team_id):
if args.label:
filters.append(f'labels: {{ name: {{ eq: "{args.label}" }} }}')

# Project scope. Strict resolution: a mistyped project must abort, not
# silently drop the filter and return the whole team queue (unattended
# consumers act on whatever this lists).
project = getattr(args, "project", None)
pid = None
if project:
try:
pid = resolve_project_id(api_key, team_id, project, strict=True)
except LookupError as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
# Project scope filter. `pid` was resolved up front: an explicit --project
# crashed on a typo, an auto-detected cwd project fell back to None on drift.
if pid:
filters.append(f'project: {{ id: {{ eq: "{pid}" }} }}')

# Milestone scope. Strict like --project: a mistyped milestone aborts rather
Expand Down Expand Up @@ -2178,13 +2282,25 @@ def list_tasks(args, cfg, api_key, team_id):
print(json.dumps({
"scope": scope,
"cycle": cycle_meta,
# The effective project scope, so machine consumers can tell an
# auto-scoped queue from the whole workspace. Null when unscoped.
"project": ({"id": pid, "name": auto_project_name or explicit_project,
"auto": bool(auto_project_name)} if pid else None),
"count": len(nodes),
"issues": nodes,
}, indent=2))
return

# When the cwd project auto-scoped the queue, say so — otherwise a short or
# empty list reads as "no work" when it just means "none in THIS project".
if auto_project_name:
print(f"Scope: {auto_project_name} (auto-detected from this directory; "
f"--all for the whole team, --project X to override)")

if not nodes:
print(f"No matching tasks in {scope_label}.")
hint = (f" (scoped to {auto_project_name}; try --all, or --cycle all "
f"for the backlog)") if auto_project_name else ""
print(f"No matching tasks in {scope_label}.{hint}")
return

header_note = ""
Expand All @@ -2197,21 +2313,48 @@ def list_tasks(args, cfg, api_key, team_id):
# Group by milestone when asked (--by-milestone) or when scoped to a project
# (the natural project view). The milestone name is already on each issue via
# ISSUE_FIELDS, so this is a local group-by with no extra API calls.
if getattr(args, "by_milestone", False) or bool(project):
if getattr(args, "by_milestone", False) or bool(pid):
print_by_milestone(nodes)
else:
for n in nodes:
print(format_issue_row(n))


def show_board(args, api_key, team_id, cfg):
# Full parity with list_tasks: an explicit --project resolves strictly and
# aborts on a typo; --all shows every project; otherwise the board
# auto-scopes to the cwd's project, validated fail-open so a stale/malformed
# binding falls back to the whole team instead of rendering a
# misleadingly-empty board. Resolved BEFORE the cycle scope, because — like
# list_tasks — a project scope widens the cycle to the whole deliverable.
explicit_project = getattr(args, "project", None)
auto_project_name = None
pid = None
if explicit_project:
try:
pid = resolve_project_id(api_key, team_id, explicit_project, strict=True)
except LookupError as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
elif not getattr(args, "all", False) and cfg.get("autoScope", True) is not False:
cwd_pid, cwd_pname = resolve_cwd_project()
if cwd_pid:
pid = resolve_auto_project_id(api_key, team_id, cwd_pid)
if pid:
auto_project_name = cwd_pname or cwd_pid
else:
print(f"linear: the current directory's project "
f"'{cwd_pname or cwd_pid}' (from agents projects) is not a "
f"live project on this team — showing the whole team. Use "
f"--project to scope explicitly.", file=sys.stderr)

# Same cycle scoping as list_tasks (active/next/all/none/name/id), so the
# board no longer hits the non-existent team.nextCycle field, and it
# paginates instead of silently capping at 50 open issues.
# --cycle defaults to None at the parser (so list_tasks can widen it); the
# board has no project/milestone scope to widen for, so None means active
# normalize here so the --json "scope" field stays "active", not null.
scope = args.cycle or "active"
# paginates instead of silently capping at 50 open issues. --cycle defaults
# to None at the parser; a project scope widens to all cycles (the whole
# deliverable), otherwise the board's default is the active cycle — and
# normalizing None here keeps the --json "scope" field a string, not null.
scope = args.cycle or ("all" if pid else "active")
fragment, scope_label, cycle_meta = build_cycle_scope(api_key, team_id, scope)
if fragment is None:
print(f"Could not resolve --cycle '{scope}'.")
Expand All @@ -2223,6 +2366,8 @@ def show_board(args, api_key, team_id, cfg):
]
if fragment:
filters.append(fragment)
if pid:
filters.append(f'project: {{ id: {{ eq: "{pid}" }} }}')

nodes = paginate_issues(api_key, ", ".join(filters))
if nodes is None:
Expand All @@ -2234,13 +2379,20 @@ def show_board(args, api_key, team_id, cfg):
print(json.dumps({
"scope": scope,
"cycle": cycle_meta,
"project": ({"id": pid, "name": auto_project_name or explicit_project,
"auto": bool(auto_project_name)} if pid else None),
"count": len(nodes),
"issues": nodes,
}, indent=2))
return

if auto_project_name:
print(f"Scope: {auto_project_name} (auto-detected from this directory; "
f"--all for the whole team, --project X to override)")

if not nodes:
print(f"No open tasks in {cycle_name}.")
hint = f" (scoped to {auto_project_name}; --all for the whole team)" if auto_project_name else ""
print(f"No open tasks in {cycle_name}.{hint}")
return

# Group by the native delegate. An issue has at most one delegate, so a board
Expand Down Expand Up @@ -4571,10 +4723,10 @@ def main():
p_tasks = sub.add_parser("tasks", help="List tasks, view details, or show team board")
p_tasks.add_argument("identifier", nargs="?", help="Issue identifier for detail view (e.g. ANT-42)")
p_tasks.add_argument("--agent", help="Filter to the agent this issue is delegated to (e.g. --agent claude). See: linear agents")
p_tasks.add_argument("--all", action="store_true", help="Ignore default agent filter, show all")
p_tasks.add_argument("--all", action="store_true", help="Ignore default agent filter AND cwd project auto-scope; show the whole team")
p_tasks.add_argument("--board", action="store_true", help="Team board grouped by agent")
p_tasks.add_argument("--label", help="Filter by any label")
p_tasks.add_argument("--project", help="Filter by project (name or UUID)")
p_tasks.add_argument("--project", help="Filter by project (name or UUID). Defaults to the cwd's project when `agents projects` binds one; --all disables that.")
p_tasks.add_argument("--milestone",
help="Filter by milestone (name or UUID). Pair with "
"--project to disambiguate a name across projects.")
Expand Down
5 changes: 3 additions & 2 deletions skill.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@ You have access to the `linear` CLI — a Linear task manager built for AI agent
## Core workflow

```
linear tasks # your assigned queue in the active cycle
linear tasks --board # whole team board, grouped by delegate
linear tasks # your queue; auto-scoped to the cwd's project when one is bound
linear tasks --all # ignore the cwd project auto-scope; whole team
linear tasks --board # team board (also cwd-scoped), grouped by delegate
linear tasks ANT-42 # detail view for one issue
linear update ANT-42 --pickup # move to In Progress
linear update ANT-42 --comment "..." # drop a progress note
Expand Down
Loading
Loading