detect-task · git:20260802.d97d7da · 2026-08-02 · sha256 2f653b249172d155
detect-task git:20260802.d97d7daA
Immutable. This exact content is served forever at /api/v1/blob/2f653b249172d155.
---
name: detect-task
description: Internal skill for commands. Detect the active task (any tracker adapter) from the task-state file / git branch name. Do not trigger on user conversation - only when commands need task detection.
user-invocable: false
---
# Detect Active Task
Parse the task-state file / current git branch to find the active task ID (tracker-adapter-aware). Used by commands that need to know which task is in progress.
> **IMPORTANT:** If the spec below invokes any deferred tool (e.g. `AskUserQuestion`), you MUST load and call it as specified. Never skip the call, never substitute a default answer of your own — the tool invocation is part of the contract. This is critical.
## Why This Exists
10+ commands need to detect the active task from branch name. This skill centralizes the logic so branch format changes are updated in one place.
## Detection Flow
### Step 1: Parse Branch Name
```bash
BRANCH=$(git branch --show-current)
```
Extract the task ID from the branch name. Formats:
- `feature/<task-id>-<slug>` - standard LETS branches (main repo)
- `worktree-<task-id>-<slug>` - worktree branches created via `/lets:worktree create` in new-branch mode (the LETS convention)
- `worktree-<custom-name>` - worktree branch without an embedded task ID; use fallback
- any other shape (e.g. `feature/foo`, `bugfix/bar`) - attached existing branch via `/lets:worktree create --attach`; no task ID in the name; use fallback
The `<task-id>` shape is TRACKER-DEPENDENT (the id sits immediately after `feature/` or `worktree-`, up to the first `-<slug>` boundary):
- beads: `<prefix>-<alphanum>[.<number>]` - e.g. `lets-abc`, `lets-abc.1`, `proj-xyz.42`
- a numeric-id tracker: a pure-numeric id - e.g. `48647`, so `feature/48647-<slug>`
- other adapters: the tracker's own id shape
**Do NOT apply the beads `<prefix>-<alphanum>` regex on a non-beads project** - it false-positives on a slug word: `feature/48647-lifecycle-test` makes the beads regex capture `lifecycle-test`, NOT the numeric id `48647`. Match using the ACTIVE tracker's id shape (`{LETS_TRACKER}` from LETS Config). When the branch shape is ambiguous, the `.task-<slug>` file (Step 1.5, authoritative) and the Step 2 search-and-confirm fallback are safer than a branch-name guess.
### Step 1.5: Task-State File (fills the gap when the branch name carries no id)
The `.task-<branch-slug>` file (written by `take-task`) records the CURRENT task - authoritative over the branch name, because a worktree branch is frozen at create time and may host several tasks in sequence (the branch name is only the worktree's home task). Read it after the explicit-arg short-circuit, before the branch-name parse. `{LETS_MERGE_BRANCH}` is from LETS Config:
```bash
LETS_PROJECT_ROOT=$(git rev-parse --show-toplevel)
BRANCH=$(git branch --show-current); BRANCH_SLUG=$(echo "$BRANCH" | tr '/' '-')
TASK_FILE="$LETS_PROJECT_ROOT/.lets/sessions/.task-${BRANCH_SLUG}"
FILE_TASK=$(sed -n 's/^task: //p' "$TASK_FILE" 2>/dev/null | head -1)
[ -n "$FILE_TASK" ] && printf 'task=%s on_merge_branch=%s\n' "$FILE_TASK" \
"$([ "$BRANCH" = "{LETS_MERGE_BRANCH}" ] && echo yes || echo no)"
```
**`on_merge_branch=no`** - trust `task=` directly and stop here. The branch corroborates the file, and a just-closed id in a worktree is low-severity (the next claim overwrites it).
**`on_merge_branch=yes`** - the file alone cannot tell a live trunk claim from a stale `.task-main` left by a closed task or a main-mode session, so verify it:
```lets-tracker
show task=<FILE_TASK> # returns {id,title,status,url}; read status
```
`status` is `in_progress` -> use the id. Any other neutral status -> the claim is stale; fall through to the branch-name parse. `show` unsupported (`LETS_TRACKER=none`) or failing -> trust the file and say in one line that liveness could not be checked.
**Precedence (full):** explicit task-id arg -> `.task-<slug>` `task:` (liveness-validated on the merge-branch via the neutral `show`) -> branch-name id -> a CONFIRMED `search` hit (never an unconfirmed one; skipped entirely when the caller passes `fallback=no` - see Optional arguments). On id-carrying branches `take-task` writes branch + file together so they agree; the file fills the id-less gaps (trunk / custom worktree / attach) and, in a multi-task worktree, reflects the current (switched) task the frozen branch name can't.
**Liveness scope (hot path).** The `show` probe runs ONLY on `{LETS_MERGE_BRANCH}`, the sole place a stale `.task-main` is indistinguishable from a live trunk claim. Off the merge-branch it does NOT run - detect-task is on the hot path of 10+ commands. This IS a cost change for non-beads adapters, which previously skipped the probe entirely: an MCP `show` is a network round-trip, and there is no portable `timeout` on macOS to bound it. We accept that on the merge-branch only, because the alternative is a non-beads project trusting a possibly-stale file forever. An adapter whose `show` is known slow may mark it so in its binding cell; the probe then skips and says liveness was not checked. Trusting the file elsewhere is safe: `feature/<id>` corroborates via the branch, and a just-closed `task:` in a worktree is low-severity (the next claim overwrites).
### Step 2: Fallback (branch name carries no id)
The board cannot answer "which task is this branch about" - only "which tasks are in progress". On a shared board the first of those is a colleague's. So do not list; search, using what this session already knows.
Build a query from the branch slug with the shape words removed (`feature/`, `worktree-`, `bugfix/`). `feature/planfix-adapter-rules-sync` yields `planfix adapter rules sync`.
```lets-tracker
search query="<terms>" # returns candidate tasks; NEVER auto-select one
```
Then judge, and say why: "this branch looks like **{title}** (`{id}`) - the branch slug matches its title". Ask the user to confirm before the id is used for anything.
- `search` absent, or it returns nothing -> fall back to `list-by-status status=in_progress` and present that list to choose from. Do NOT take the first.
- Exactly one candidate -> still confirm. One result is not evidence of ownership; it is one result.
- User declines or does not answer -> return None. None is a correct answer here.
**An id that came from this step is NEVER used silently.** That is the whole rule, and it is about provenance rather than identity: an id from the `.task` file or from the branch name is this branch's by construction, so it needs no confirmation; an id from the board is a guess, so it always does. No tracker needs a notion of "me" for this to hold, and on a branch LETS created this step never runs at all.
### Explicit task-id argument (resolve-and-claim)
When the **calling command was invoked with an explicit `<task-id>` argument** (e.g. a session spawned into a fresh worktree with `/lets:plan-workflow <id>`, where the branch is `worktree-<name>` and carries no id): treat that id as **authoritative** - do NOT parse the branch, skip Steps 1-2. The caller then ensures the task is claimed: if the tracker's `show` reports `<id>` not `in_progress`, invoke `Skill(skill: "lets:take-task", args: "<id>")` (in a worktree `take-task` claims + saves the per-branch session-ref without creating a branch). This is what makes an id-accepting command spawn-able into a fresh worktree.
**AUTO MODE carve-out (entry claim only).** This spawn-time claim runs the `set-status=in_progress` verb - a tracker state change AUTO MODE normally gates. But the claim that *starts* an autonomous spawned session is the authorized **entry action** and is exempt (an unattended session cannot ask, and the claim IS the entry). The exemption covers ONLY this entry claim - every later tracker state change (`close`, status flips, `bd dolt push` on beads) stays gated per AUTO MODE.
### Optional arguments: `branch=<ref>` / `fallback=no`
Passed as space-separated `key=value` via the `Skill` invocation's `args`. Both default OFF - a caller that passes neither gets exactly the flow above, unchanged.
- **`branch=<ref>`** - resolve for THAT ref instead of the checked-out one. It substitutes for `$(git branch --show-current)` in Step 1 and Step 1.5; nothing else changes. **Treat the value as UNTRUSTED**: the caller's ref may be a pull request's head, which its author names, and git permits `` $ ` ; | & ( ) `` in a ref. It reaches a filesystem path here, so derive the slug in-shell and pass only this block's OUTPUT downstream - never substitute the raw value into a path or into a tracker verb:
```bash
LETS_PROJECT_ROOT=$(git rev-parse --show-toplevel)
SLUG=$(printf '%s' "<ref>" | tr '/' '-')
case "$SLUG" in ""|*[!A-Za-z0-9._-]*) echo "UNSAFE REF - no task"; exit 0 ;; esac
sed -n 's/^task: //p' "$LETS_PROJECT_ROOT/.lets/sessions/.task-$SLUG" 2>/dev/null | head -1
```
An id parsed out of the ref NAME (Step 1) must clear the same character class before it crosses a tracker verb - on beads that verb resolves to a `bd` command, i.e. a second shell hop.
- **`fallback=no`** - skip Step 2 entirely and return None rather than a searched-and-confirmed id.
**Why `fallback=no` exists.** The reason is no longer "the fallback returns a colleague's task" - it now asks before it answers - but that a confirmation is a QUESTION, and a question is unacceptable mid-fan-out, in a `--json` run, or on a read-only orient surface. It also keeps provenance unambiguous at the call site *by construction*: fallback off + an id returned = the id came from the state file or the ref name, never from the board.
### Step 3: Confirming a searched id
Step 2 always ends in a confirmation - not only when the result was ambiguous. What the confirmation looks like depends on the caller:
- **commit**: AskUserQuestion to pick task or "None"
- **done**: AskUserQuestion to pick task to close
- **review/check/opinion/ask**: skip the tracker comment if ambiguous. review/check additionally pass `fallback=no` when resolving the SPEC, so a searched id can never reach a reviewer prompt
- **note**: AskUserQuestion to pick task to add note to
- **any other caller** (orient, start, end, plan, execute, team, github-pr): an unconfirmed hit is None. These are read/orient surfaces - they must not open a picker of their own; `orient` in particular declares that it does not select or claim.
**AUTO MODE consequence (a decision, not an accident):** an unattended `/lets:execute --auto` on an attached branch with no id in its name now returns None rather than adopting a board task, and stops saying the task could not be resolved. Adopting a stranger's task unattended is the worse failure.
## Output
Returns one of:
- Task ID (string) - found active task
- None - no task detected, caller decides how to handle
## Integration
Internal skill used by most commands that need task detection.
See: `grep -r "detect-task" commands/` for current usage.