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
9 changes: 9 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,15 @@ cheap message classification and is wired into a `tg-cli` inbound hook.
- **`task list` defaults to THIS session.** Session id = `env:TASK_SESSION` → tmux pane → git
branch. Tickets are labelled `session:<id>` (durable) AND recorded in a local sidecar
(`~/.local/state/task-cli/sessions/<id>.jsonl`, a cache). The label is the source of truth.
It **falls back** to all of the repo's tickets — and says so — when there's no agent session
or the session has none.
- **Read/global ops work OUTSIDE a git repo; only `create` is repo-bound.** `list` outside a
repo (or `--all` anywhere) aggregates the `projects:` registry and groups by project;
`read`/`status`/`find` route an id to a registered project; a degraded project never aborts
the aggregation. `create` writes into ONE project, so outside a repo it fails with a 3-part
WHAT/WHY/HOW error. The registry parsing/overlay shaping is pure (`tasklib/projects.py`); the
multi-backend orchestration is an effect and lives in `cli.py`. Add a project backend
coordinate, not a special case.

## Backend seam

Expand Down
50 changes: 45 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,12 @@ linear auth # Linear (per-repo, via task.yaml)

```
task create --title "..." --why "..." --impact "..." --if-not-done "..." --acceptance "..."
task list # THIS session's tickets (status + first paragraph)
task list --all # all tickets
task read <id> (alias: view) # the full ticket — every section
task find "<query>" # search title+body via the backend
task list # THIS session's tickets (falls back to all when empty)
task list --all # every known project's tickets, grouped by project
task read <id> (alias: view) # the full ticket — every section (works outside a repo)
task find "<query>" # search title+body (cross-project when outside a repo)
task change <id> [--done] # update; --done runs the on-done gates (close)
task status <id> [<new-state>] # read or transition state
task status <id> [<new-state>] # read or transition state (works outside a repo)
task classify "<text>" [--create] # change|justAsk via review; --create makes/dedups a ticket
task session [show|bind <id>] # show/bind the current session and its tickets
```
Expand Down Expand Up @@ -123,6 +123,43 @@ Every ticket created/touched in a session is labelled `session:<id>` (portable)
recorded in a local sidecar (`~/.local/state/task-cli/sessions/<id>.jsonl`, fast/offline).
`task list` defaults to the current session's tickets.

## Working outside a repo / across projects

A tool's *read* and *global* operations should not demand you stand inside a git repo. So:

- **`task list` outside any repo** → shows **all** tickets across the projects you've
registered, **grouped by project** (a heading per project, tickets beneath). The output
says `showing all project tasks` so it's clear why you see everything.
- **`task list` inside a repo** → scopes to that repo's current session. With **no agent
session**, or a session with **no tickets**, it falls back to *all* of that repo's tickets
and says so. **`task list --all`** gives the cross-project grouped view from anywhere.
- **`task read` / `task status` / `task find`** work outside a repo too. An id is routed to a
registered project (a Linear `HYP-3` by its team; a `#123` when exactly one GitHub project
is registered); an ambiguous id fails with a clear, actionable error.
- **Only `task create` is repo-bound** — it writes a ticket into one specific project, so it
needs a repo (or `--repo owner/name`). Outside one it fails with a 3-part WHAT/WHY/HOW error.
- A project whose backend errors (auth, offline, unknown team) is shown as a **degraded
group** — it never aborts the whole cross-project listing.

`--json` follows the view: the session/single-repo list is a flat `[ticket]`, while the
grouped cross-project view (outside a repo, or `--all`) is `[{project, backend, current, error,
tickets}]` — one object per project group, so a degraded project is visible to scripts too. The
in-repo fallback (session empty → all of *this* repo's tickets) stays the flat `[ticket]` shape,
scoped to the current repo, even though the text output prints the `showing all project tasks`
line. Only the cross-project view is grouped.

The cross-project view reads a **`projects:`** registry from the config cascade — usually the
**global** `~/.config/task-cli/config.yaml`, since it spans repos:

```yaml
projects:
- { repo: acme/frontend } # GitHub shorthand → group "acme/frontend"
- { name: Backend, github: { repo: acme/api } } # explicit block + display name
- { name: HYP, backend: linear, team: HYP } # a Linear team/project
```

The repo you're currently inside is always one of the groups, even if it isn't (yet) listed.

## Classification

`task classify "<text>"` decides `change` (→ a ticket) vs `justAsk` (a pure question) by
Expand All @@ -140,6 +177,9 @@ version: 1
backend: github-issues # or: linear
github: { repo: auto, default_labels: [agent] } # repo: auto = origin owner/name
linear: { team: HYP, project: "" }
projects: # cross-project registry (mostly in the GLOBAL config)
- { repo: acme/frontend } # `task list` outside a repo / `--all` aggregates these
- { name: HYP, backend: linear, team: HYP }
enforce:
acceptance_criteria: required
motivation: required
Expand Down
17 changes: 15 additions & 2 deletions tasklib/backends/linear.py
Original file line number Diff line number Diff line change
Expand Up @@ -219,9 +219,14 @@ def list(self, *, labels=None, state=None, limit=30) -> list[Ticket]:
flt: dict = {"team": {"key": {"eq": self.team_key}}}
if labels:
flt["labels"] = {"some": {"name": {"in": labels}}}
if self.project:
# When a project id is pinned, scope the list to it — otherwise two registry entries
# for the same team but different projects would list the SAME team-wide issues, so
# the cross-project grouped view would double-count them under each group.
flt["project"] = {"id": {"eq": self.project}}
data = self._gql(
"query($filter:IssueFilter,$n:Int){issues(filter:$filter,first:$n,"
"orderBy:updatedAt){nodes{" + self._ISSUE_FIELDS + "}}}",
"orderBy:updatedAt){nodes{" + self._ISSUE_FIELDS + " team{key} project{id}}}}",
{"filter": flt, "n": min(limit, 100)},
)
tickets = [self._node_to_ticket(n) for n in data.get("issues", {}).get("nodes", [])]
Expand All @@ -230,13 +235,21 @@ def list(self, *, labels=None, state=None, limit=30) -> list[Ticket]:
return tickets[:limit]

def search(self, query: str, *, state=None, limit=30) -> list[Ticket]:
# searchIssues has no team/project filter argument, so scope the results client-side to
# THIS backend's team (and project, if pinned). Without this a cross-project `find` would
# return workspace-wide hits and attribute the whole workspace to each Linear group.
# NOTE: ``limit`` bounds the rows FETCHED from Linear; the team/project filter then runs
# client-side, so the returned count can be < limit (matches are a subset of the fetch).
data = self._gql(
"query($q:String!,$n:Int){searchIssues(term:$q,first:$n){nodes{"
+ self._ISSUE_FIELDS
+ "}}}",
+ " team{key} project{id}}}}",
{"q": query, "n": min(limit, 100)},
)
nodes = data.get("searchIssues", {}).get("nodes", [])
nodes = [n for n in nodes if (n.get("team") or {}).get("key") == self.team_key]
if self.project:
nodes = [n for n in nodes if (n.get("project") or {}).get("id") == self.project]
tickets = [self._node_to_ticket(n) for n in nodes]
if state is not None:
tickets = [t for t in tickets if t.state == state]
Expand Down
Loading
Loading