letsdo 0.6.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 203275d9cd697301e22719a2b0aefa7226109be30e7b6092854f18589b9cb871
4
- data.tar.gz: e667e3dd817fc6e2948e1e59b57a034e007dc4be73997ce37a12e4a44b8c4670
3
+ metadata.gz: dc2229c2f963fc9b534a3905752c851b9ffcbf1fad90fb39d3d5620529099ef0
4
+ data.tar.gz: 31d93968cd32489e09d0f96edeb63fdc8346fb87bcb596ab40a536821163163e
5
5
  SHA512:
6
- metadata.gz: 388f387315f4e806ee61721dda7a71ba771d086ba1bc6a4d8a0e849a8dcb31f16120f1c9a3c7b38784215d43d407e1a9bf77ae33029e6e76482c3d2fedb65579
7
- data.tar.gz: 0fef5205aa7e9f97b61a1f2fe28cb8a785fef771326921dbc1718d0387270fe1b23d1aa69b05a3ed5cc936a8c4163f0b3346dbb5510692d67738b7efeb10144c
6
+ metadata.gz: e5b56197ecfd3dfd010f46d6ba21c5731652aa2a47e4b5a3a7b19079793df6394a652def8920bd4b4444044e31538c473197f1f998f3201ccdd6c4a4b4206e10
7
+ data.tar.gz: 16df1906f7d721e4229c15eb9902c6840b7782b986bbe9cc3caffcfe6b150025531428cf173f46980d27df51d77362d63e6c3e29b86af3a9348331d88c81f2ec
data/CHANGELOG.md CHANGED
@@ -5,6 +5,35 @@ All notable changes to this project are documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.6.1] - 2026-09-18
9
+
10
+ ### Fixed
11
+
12
+ - Assignee identity is now the bare agent name (`developer`), not an
13
+ `@`-prefixed handle. The backlog CLI matches `--assignee` by exact
14
+ string, so the legacy default made letsdo's own view of the queue
15
+ diverge from every exact-match consumer. Canonical tracker value is the
16
+ bare name; `@` stays prose-only notation (TASK-96).
17
+ - `Providers::Backlog` no longer passes `--assignee` on the CLI line and
18
+ matches the resolved assignee against stored task assignees in Ruby,
19
+ tolerating the legacy `@` prefix, surrounding whitespace and case; the
20
+ matched-not-exact values are exposed as `assignee_variants`.
21
+ - `AgentLoop` prints a once-per-run stderr hint when a batch matched only
22
+ after normalization, pointing at the tasks to reassign.
23
+ - `letsdo doctor` gained an assignee check: WARN on a legacy
24
+ `@`-prefixed `AGENT_ASSIGNEE_HANDLE` override and on `@`-prefixed
25
+ assignees stored on open tasks (with the reassignment command).
26
+ - `AGENT_ASSIGNEE_HANDLE` still works verbatim as an escape hatch.
27
+ - Own backlog data migrated: no open task is stored with an `@`-prefixed
28
+ assignee anymore.
29
+
30
+ ### Changed
31
+
32
+ - Docs flipped to bare assignees: AGENTS.md team rules (`-a developer`),
33
+ agent templates (`agents/developer.md`, `agents/analyst.md`), README,
34
+ docs/config.md, docs/usage.md, docs/prompts.md, docs/task-selection.md.
35
+ The TUI header keeps `@name` as display-only notation.
36
+
8
37
  ## [0.6.0] - 2026-09-17
9
38
 
10
39
  ### Added
data/README.md CHANGED
@@ -32,8 +32,9 @@ platform — the backlog folder is the single source of truth.
32
32
  already have everything letsdo needs. The tasks are the instructions;
33
33
  letsdo only executes them.
34
34
  - **Zero-config team.** A new agent is a new file: `agents/<name>.md`
35
- with the agent's instructions. The assignee handle is derived from the
36
- name (`@developer` ↔ `developer`), so the agent automatically works on
35
+ with the agent's instructions. The assignee is derived from the
36
+ name (`developer` the agent ↔ `developer` the assignee; `@developer`
37
+ is prose notation only), so the agent automatically works on
37
38
  the tasks already assigned to it. No code, no schemas, no setup.
38
39
  - **One task per run — honest work.** Each run picks up exactly one open
39
40
  task and completes it before the next. No context-switching, no runaway
@@ -53,7 +54,7 @@ generation, any repeatable task flow you can express as assignee + prompt.
53
54
  ## Features
54
55
 
55
56
  - **One-command agent run** — `letsdo <name>` starts the loop: all open
56
- tasks assigned to `@<name>` are done one after another (one agent run =
57
+ tasks assigned to `<name>` are done one after another (one agent run =
57
58
  one task), then the loop waits for new ones until stopped with
58
59
  `SIGINT/SIGTERM` (clean exit, code 0).
59
60
  - **Agents as prompt files** — `agents/<name>.md` is the whole identity of
@@ -94,7 +95,8 @@ generation, any repeatable task flow you can express as assignee + prompt.
94
95
  `PATH` — this is the AI backend that runs the agent (`pi --mode json`).
95
96
  The command is configurable via `LETSDO_PI_COMMAND`.
96
97
  - The Backlog.md CLI (`backlog`) on `PATH` — the task provider reads open
97
- tasks via `backlog task list --assignee <handle> --ready --sort priority`.
98
+ tasks via `backlog task list --exclude-status Done --ready --sort
99
+ priority --json` and matches the agent's assignee on them.
98
100
  Configurable via
99
101
  `LETSDO_BACKLOG_COMMAND`.
100
102
 
@@ -149,7 +151,7 @@ letsdo developer --init # writes agents/developer.md, never runs the agen
149
151
  # Without model:, pi's own default model is used. A --model in
150
152
  # LETSDO_PI_FLAGS overrides the file.
151
153
 
152
- # run the agent: it works through all open tasks assigned to @developer
154
+ # run the agent: it works through all open tasks assigned to developer
153
155
  letsdo developer
154
156
  ```
155
157
 
@@ -221,7 +223,7 @@ All knobs are environment variables:
221
223
  | `LETSDO_PI_FLAGS` | — | Extra pi flags, e.g. `--model anthropic/claude-sonnet-4-5` (split on whitespace). A `--model` here overrides the agent's front-matter `model:`. |
222
224
  | `AGENT_PI_FLAGS` | — | Fallback for `LETSDO_PI_FLAGS` (compatibility with the old `bin/agent`). |
223
225
  | `LETSDO_PI_COMMAND` | `pi` | The pi command used to run agents; overridable for tests / fake pi. |
224
- | `AGENT_ASSIGNEE_HANDLE` | `@<name>` | The agent's backlog assignee handle. The one rule: handle = name. Also the handle injected into the agent's prompt identity. |
226
+ | `AGENT_ASSIGNEE_HANDLE` | `<name>` | The agent's backlog assignee. The one rule: assignee = name, stored bare (`@` is prose-only notation). Also the assignee injected into the agent's prompt identity. |
225
227
  | `LETSDO_WAIT_SECONDS` | 10 | Retry interval when there are no open tasks. |
226
228
  | `AGENT_WAIT_SECONDS` | — | Fallback for `LETSDO_WAIT_SECONDS` (`bin/agent-loop` compatibility). |
227
229
  | `LETSDO_MAX_RETRIES` | 3 | Max consecutive failed runs of the same task before giving up for the session. |
@@ -336,9 +338,10 @@ bin/letsdo ──► Letsdo::CLI ──► Letsdo::Agent ──► Letsdo::PiRun
336
338
  code (including 128+signal).
337
339
  - `Letsdo::OutputStreamer` — routes agent text to stdout and service/tool
338
340
  lines to stderr with `HH:MM:SS` prefixes and durations.
339
- - `Letsdo::BacklogTasks` — the task provider: runnable open tasks for a
340
- handle via `backlog task list --assignee <handle> --exclude-status Done
341
- --ready --sort priority --json`, returned in the authoritative run order
341
+ - `Letsdo::BacklogTasks` — the task provider: runnable open tasks for the
342
+ assignee via `backlog task list --exclude-status Done --ready --sort
343
+ priority --json` with the assignee matched in Ruby (tolerating the
344
+ legacy `@` notation), returned in the authoritative run order
342
345
  (In Progress first, then priority High > Medium > Low, then ordinal, then
343
346
  id); `nil` when the backlog is unreadable (the loop pauses instead of
344
347
  running the agent).
@@ -399,7 +402,7 @@ Ruby 3.3 and 4.0 (satisfies `required_ruby_version: ">= 3.3"`).
399
402
  | [aider](https://github.com/Aider-AI/aider) | Pair-programming CLI | Local AI pair for code changes | Focused on interactive coding pairs, not executing a tracked backlog |
400
403
 
401
404
  What none of them do out of the box: take an existing markdown backlog,
402
- derive the team from the assignee handles, and execute the tasks one per
405
+ derive the team from the assignees, and execute the tasks one per
403
406
  run with an observable loop. That is letsdo's niche — a thin convention
404
407
  layer instead of a framework. If your project is tracked in Backlog.md
405
408
  format and you want a local, observable, multi-agent worker on top of it,
data/bin/letsdo CHANGED
@@ -31,7 +31,9 @@
31
31
  # LETSDO_ROOT project root with agents/ (default — current folder)
32
32
  # LETSDO_PI_FLAGS extra pi flags (e.g. "--model anthropic/claude-sonnet-4-5")
33
33
  # AGENT_PI_FLAGS the same, for bin/agent compatibility if LETSDO_PI_FLAGS is unset
34
- # AGENT_ASSIGNEE_HANDLE the agent's backlog assignee handle (default "@<name>")
34
+ # AGENT_ASSIGNEE_HANDLE the agent's backlog assignee (default <name>,
35
+ # the bare name; a legacy '@<name>' value is warned
36
+ # about by `letsdo doctor`)
35
37
  # LETSDO_WAIT_SECONDS retry interval when no tasks are open (default 10)
36
38
  # LETSDO_BACKLOG_COMMAND the backlog CLI command (default "backlog")
37
39
  # LETSDO_PROVIDER task provider name (default "backlog")
data/docs/config.md CHANGED
@@ -18,8 +18,10 @@ LETSDO_ROOT (default: the current working directory)
18
18
  - `LETSDO_ROOT` — project root: `agents/` lives there, and the `backlog`
19
19
  CLI resolves `backlog/` there. Default: the folder letsdo was started
20
20
  from.
21
- - The agent's assignee handle is `@<name>` by default — the agent works on
22
- tasks assigned to that handle.
21
+ - The agent's assignee is `<name>` by default — the agent works on tasks
22
+ assigned to that name. The tracker stores bare names; `@<name>` is
23
+ prose-only notation (`letsdo doctor` warns about legacy `@`-prefixed
24
+ data).
23
25
 
24
26
  ## Environment variables
25
27
 
@@ -29,7 +31,7 @@ LETSDO_ROOT (default: the current working directory)
29
31
  | `LETSDO_PI_FLAGS` | unset (no flags) | Extra pi flags, split on whitespace, e.g. `--model anthropic/claude-sonnet-4-5`. A `--model` here overrides the agent's front-matter `model:` (see [Per-agent configuration](#per-agent-configuration-yaml-front-matter)). |
30
32
  | `AGENT_PI_FLAGS` | unset | Fallback for `LETSDO_PI_FLAGS` when it is blank (compatibility with the old `bin/agent`). |
31
33
  | `LETSDO_PI_COMMAND` | `pi` | The pi command used to run agents; overridable for tests / fake pi. |
32
- | `AGENT_ASSIGNEE_HANDLE` | `@<name>` | The agent's backlog assignee handle (used verbatim when set). Also injected as the agent's identity in its prompt. |
34
+ | `AGENT_ASSIGNEE_HANDLE` | `<name>` | The agent's backlog assignee (used verbatim when set; the bare name is canonical, a legacy `@`-prefixed value still matches but `letsdo doctor` warns). Also injected as the agent's identity in its prompt. |
33
35
  | `LETSDO_WAIT_SECONDS` | `10` | Retry interval (seconds) when there are no open tasks. |
34
36
  | `AGENT_WAIT_SECONDS` | `10` (via fallback) | Fallback for `LETSDO_WAIT_SECONDS` when it is blank (`bin/agent-loop` compatibility). |
35
37
  | `LETSDO_MAX_RETRIES` | `3` | Max consecutive failed runs of the same task before giving up for the session. |
@@ -54,9 +56,12 @@ LETSDO_ROOT (default: the current working directory)
54
56
  - `LETSDO_MAX_RETRIES`: an invalid (non-integer) value falls back to `3`.
55
57
  - `LETSDO_RETRY_CAP`: an invalid (non-numeric) value falls back to `300`.
56
58
  - `AGENT_ASSIGNEE_HANDLE`: used as-is when set and non-blank; otherwise the
57
- one rule: handle = `@<name>`. The resolved value is both the assignee the
58
- loop queries the backlog for and the handle letsdo injects into the
59
- agent's prompt identity.
59
+ one rule: assignee = `<name>` (the bare name). The resolved value is both
60
+ the assignee the loop matches backlog tasks by and the assignee letsdo
61
+ injects into the agent's prompt identity. The match tolerates the legacy
62
+ `@` prefix, whitespace and case, so old data keeps working — but the
63
+ canonical stored value is the bare name, and deviations trigger a
64
+ once-per-run warning plus a `letsdo doctor` WARN.
60
65
  - Agent `model` → `LETSDO_PI_FLAGS`/`AGENT_PI_FLAGS`: a `--model` in the
61
66
  flags wins; the agent's front-matter `model:` is used only when the flags
62
67
  carry no `--model`. See
data/docs/prompts.md CHANGED
@@ -1,8 +1,9 @@
1
1
  # letsdo — prompt-authoring guide
2
2
 
3
3
  An agent in letsdo is exactly one file: `agents/<name>.md`. The file is the
4
- agent's whole identity — role, rules, workflow. The assignee handle is
5
- derived from the name (`agents/developer.md` ⇄ `@developer`), so the agent
4
+ agent's whole identity — role, rules, workflow. The assignee is
5
+ derived from the name (`agents/developer.md` ⇄ assignee `developer`; in
6
+ prose you write `@developer`), so the agent
6
7
  automatically works on the tasks already assigned to it. Adding a file
7
8
  creates an agent; no code, no schemas, no setup.
8
9
 
@@ -21,9 +22,9 @@ worked examples from this repository's own agents.
21
22
  - The prompt is read on every run from `<LETSDO_ROOT>/agents/<name>.md` and
22
23
  handed to pi as the agent's system prompt (`pi --mode json`).
23
24
  - Before the prompt is handed over, letsdo prepends the agent's identity —
24
- its name and backlog assignee handle (`Config#assignee_handle`, default
25
- `@<name>`) — to it. The injection always happens, whatever the template
26
- contains, so a template never has to state the name or handle.
25
+ its name and backlog assignee (`Config#assignee_handle`, default
26
+ `<name>`, the canonical bare name) — to it. The injection always happens, whatever the template
27
+ contains, so a template never has to state the name or assignee.
27
28
  - When the file is missing, the agent runs on the built-in default prompt
28
29
  (process-only instructions); letsdo announces where it looked and how to
29
30
  create a prompt (`letsdo <name> --init`).
@@ -68,9 +69,9 @@ an environment variable (see the [configuration reference](config.md)).
68
69
 
69
70
  A robust agent prompt has these parts:
70
71
 
71
- 1. **Identity.** The agent's identity — its name and backlog assignee
72
- handle — is injected by letsdo at the top of every prompt, so the
73
- template does not have to state it. A hardcoded handle is a bad idea:
72
+ 1. **Identity.** The agent's identity — its name and backlog assignee —
73
+ is injected by letsdo at the top of every prompt, so the
74
+ template does not have to state it. A hardcoded assignee is a bad idea:
74
75
  it drifts from `AGENT_ASSIGNEE_HANDLE` and the backlog assignment. You
75
76
  can still describe the role in prose; keep the name in the prompt
76
77
  matching the file name.
@@ -143,7 +144,7 @@ A robust agent prompt has these parts:
143
144
  (task notes, comments) as it goes.
144
145
  - **Untestable acceptance criteria.** "Works well" cannot be verified.
145
146
  Prefer testable, objective criteria ("`rake test` green, 0 failures",
146
- "the created task is assigned to @developer").
147
+ "the created task is assigned to developer").
147
148
  - **Missing identity/role.** Without a role the agent cannot tell what it
148
149
  owns. letsdo injects the name and handle, but the role and rules are the
149
150
  template's job.
@@ -204,6 +205,6 @@ Before writing `agents/<name>.md`, check:
204
205
  - [ ] Deliverables: concrete artifacts, paths, acceptance criteria.
205
206
  - [ ] Style/language conventions for tracked artifacts.
206
207
  - [ ] Prohibitions (unassigned work, several tasks per run, ...).
207
- - [ ] Prompt matches the file name (`<name>.md` ⇄ `@<name>`).
208
+ - [ ] Prompt matches the file name (`<name>.md` ⇄ assignee `<name>`).
208
209
  - [ ] (Optional) Front-matter `model:` is set only when you want a
209
210
  per-agent model — a global `--model` flag overrides it.
@@ -43,9 +43,13 @@ record; scopes B and C remain out of scope.
43
43
  End-to-end, one `letsdo <name>` session:
44
44
 
45
45
  1. `Letsdo::Providers::Backlog` runs
46
- `backlog task list --assignee <handle> --exclude-status Done --ready
46
+ `backlog task list --exclude-status Done --ready
47
47
  --sort priority --json` (`lib/letsdo/providers/backlog.rb`,
48
- `#command_line`). `--ready` drops tasks whose dependencies are not all
48
+ `#command_line`) — the CLI line carries no `--assignee` (backlog CLI
49
+ matches it by exact string, which made notation drift invisible) — and
50
+ matches the agent's assignee on the returned tasks in Ruby
51
+ (`#normalized` comparison of the stored values against the resolved
52
+ handle). `--ready` drops tasks whose dependencies are not all
49
53
  done; `--sort priority` orders by priority then ordinal as a first pass.
50
54
  2. The adapter projects each raw task onto `TASK_FIELDS =
51
55
  %w[id title status priority assignees ordinal type labels milestone]` and
@@ -65,7 +69,7 @@ End-to-end, one `letsdo <name>` session:
65
69
  prepends the injected identity and hands the prompt to pi. The prompt is
66
70
  what chooses the task: `agents/analyst.md` and `agents/developer.md`
67
71
  instruct the agent to run
68
- `backlog task list --assignee @<name> --exclude-status Done --sort priority --plain`
72
+ `backlog task list --assignee <name> --exclude-status Done --sort priority --plain`
69
73
  and take the first task, with the "already In Progress first" and "if
70
74
  blocked, take the blocker" exceptions.
71
75
  6. Retry state lives in `Letsdo::RetryPolicy` and is keyed on the **batch
@@ -81,8 +85,9 @@ batch is only a run counter. The order letsdo read does not govern the work.
81
85
  A deterministic selector needs these inputs, in this order:
82
86
 
83
87
  1. **Eligibility**
84
- - assignee contains the agent's handle (`Config#assignee_handle`, default
85
- `@<name>`); other agents' and `@human` tasks are never auto-run.
88
+ - assignee contains the agent's assignee (`Config#assignee_handle`,
89
+ default `<name>`, the canonical bare name); other agents' and `human`
90
+ tasks are never auto-run.
86
91
  - `status != Done`.
87
92
  - not in retry cooldown and not given up for this session
88
93
  (`RetryPolicy#cooldown?` / `#gave_up?`).
@@ -149,7 +154,7 @@ A deterministic selector needs these inputs, in this order:
149
154
  current batch drains. Acceptable for short runs; note it if runs get long.
150
155
  - **Race conditions.** Unassigned tasks are a coordination gap: two agents can
151
156
  pick the same task. The design's answer is assignment by a human
152
- (`@developer`, `@analyst`, `@human`), not a lock. A lock/claim mechanism
157
+ (`developer`, `analyst`, `human`), not a lock. A lock/claim mechanism
153
158
  would require shared mutable state and belongs to scope C, not here.
154
159
  - **Retry interaction.** Any selector must compose with `RetryPolicy`: a task
155
160
  in cooldown or given up must not be re-offered, and the outcome must be
data/docs/usage.md CHANGED
@@ -24,7 +24,8 @@ orchestrator loop semantics, and how to run several agents at once.
24
24
  (see the [configuration reference](config.md)).
25
25
  - The **Backlog.md CLI** (`backlog`) on `PATH` — the task provider reads the
26
26
  runnable open tasks assigned to an agent via
27
- `backlog task list --assignee <handle> --ready --sort priority`. Override
27
+ `backlog task list --exclude-status Done --ready --sort priority` (the
28
+ assignee is matched on the returned tasks; the CLI line stays bare). Override
28
29
  with `LETSDO_BACKLOG_COMMAND`.
29
30
 
30
31
  Tests and the gem build use only Ruby's bundled default gems (Minitest,
@@ -84,7 +85,7 @@ letsdo developer --init # writes agents/developer.md, never runs the a
84
85
  # Option 2: write agents/developer.md by hand
85
86
  # (see the prompt-authoring guide for what a good prompt contains)
86
87
 
87
- # Run the agent: it works through all open tasks assigned to @developer
88
+ # Run the agent: it works through all open tasks assigned to developer
88
89
  letsdo developer
89
90
  ```
90
91
 
@@ -207,9 +208,9 @@ is what keeps CI and pipes deterministic.
207
208
 
208
209
  `letsdo <name>` runs an orchestrator loop:
209
210
 
210
- 1. **Query** — fetch all open tasks assigned to `@<name>` via the backlog
211
- CLI (the assignee handle comes from `AGENT_ASSIGNEE_HANDLE`, default
212
- `@<name>`).
211
+ 1. **Query** — fetch all open tasks assigned to `<name>` via the backlog
212
+ CLI (the assignee comes from `AGENT_ASSIGNEE_HANDLE`, default `<name>`
213
+ — the canonical bare name; `@<name>` is prose notation only).
213
214
  2. **Run** — take the next task and run the agent on it. **One run = one
214
215
  task**; the agent must not pick up more than one task per run.
215
216
  3. **Repeat** — when a run finishes, query again.
@@ -234,11 +235,11 @@ queued task run.
234
235
  ## Running several agents
235
236
 
236
237
  Agents run as separate processes, each with its own loop and its own
237
- assignee handle:
238
+ assignee:
238
239
 
239
240
  ```sh
240
- letsdo developer & # works on @developer tasks
241
- letsdo analyst & # works on @analyst tasks
241
+ letsdo developer & # works on developer tasks
242
+ letsdo analyst & # works on analyst tasks
242
243
  ```
243
244
 
244
245
  They coordinate through the shared backlog — nothing else in common. Any
data/lib/letsdo/agent.rb CHANGED
@@ -22,16 +22,17 @@ module Letsdo
22
22
  # vocabulary.
23
23
  # @param streamer [OutputStreamer] where to print output (by default
24
24
  # the real stdout/stderr)
25
- # @param handle [String, nil] the agent's backlog assignee handle
26
- # (Config#assignee_handle). Defaults to @<name>; the launcher
27
- # passes the resolved handle so the injected identity always
28
- # matches the handle the backlog tasks are assigned to.
25
+ # @param handle [String, nil] the agent's backlog assignee
26
+ # (Config#assignee_handle). Defaults to the bare <name>
27
+ # (TASK-96); the launcher passes the resolved assignee so the
28
+ # injected identity always matches what the backlog tasks are
29
+ # assigned to.
29
30
  def initialize(name:, root:, backend_factory:, streamer: nil, handle: nil)
30
31
  @name = name
31
32
  @root = root
32
33
  @backend_factory = backend_factory
33
34
  @streamer = streamer || OutputStreamer.new
34
- @handle = handle || "@#{name}"
35
+ @handle = handle || name.to_s
35
36
  end
36
37
 
37
38
  # The backend of the last/current run -- lets the orchestrator
@@ -3,27 +3,31 @@
3
3
  module Letsdo
4
4
  # The identity preamble letsdo injects into every agent's system prompt on
5
5
  # launch (TASK-85). The launcher already knows the agent name and its
6
- # backlog assignee handle; the prompt template may not. Injecting the two
7
- # facts unconditionally keeps every agent aware of who it is and which
8
- # tasks are its own — independent of the template content, for the
9
- # built-in default prompt and for every custom agents/<name>.md alike.
6
+ # backlog assignee; the prompt template may not. Injecting the two facts
7
+ # unconditionally keeps every agent aware of who it is and which tasks
8
+ # are its own — independent of the template content, for the built-in
9
+ # default prompt and for every custom agents/<name>.md alike.
10
10
  #
11
- # The handle is Config#assignee_handle, so the identity an agent reads
12
- # matches the handle its backlog tasks are assigned to.
11
+ # The assignee is Config#assignee_handle — the bare name (TASK-96); the
12
+ # '@' prefix is prose notation only, so the identity an agent reads both
13
+ # presents and instructs the bare tracker value.
13
14
  module AgentIdentity
14
15
  # The identity block prepended to a prompt. It ends with a blank line so
15
16
  # the original prompt keeps its own heading structure.
16
17
  #
17
18
  # @param name [String] agent name (also the backlog assignee name)
18
- # @param handle [String] backlog assignee handle (Config#assignee_handle)
19
+ # @param handle [String] backlog assignee (Config#assignee_handle:
20
+ # the bare name)
19
21
  # @return [String]
20
22
  def self.preamble(name:, handle:)
21
23
  <<~TEXT
22
24
  # Your identity
23
25
 
24
- You are the agent `#{name}`. Your backlog assignee handle is `#{handle}`:
25
- the tasks assigned to `#{handle}` are yours to work. Identify yourself
26
- as this agent and use this handle in every tracked artifact you write.
26
+ You are the agent `#{name}`. Your backlog assignee is `#{handle}`:
27
+ the tasks assigned to `#{handle}` are yours to work. Identify
28
+ yourself as this agent and store `#{handle}` — the bare name, the
29
+ '@' prefix ('@#{handle}') is prose notation only — as the assignee
30
+ in every tracked artifact you write.
27
31
 
28
32
  TEXT
29
33
  end
@@ -33,7 +37,7 @@ module Letsdo
33
37
  #
34
38
  # @param prompt [String] the agent's system prompt (file or default)
35
39
  # @param name [String] agent name
36
- # @param handle [String] backlog assignee handle
40
+ # @param handle [String] backlog assignee (bare name)
37
41
  # @return [String] prompt with the identity block at the very top
38
42
  def self.inject(prompt, name:, handle:)
39
43
  "#{preamble(name: name, handle: handle)}#{prompt}"
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Letsdo
4
+ # Assignee mismatch reporting for AgentLoop (TASK-96).
5
+ #
6
+ # The provider matches the configured assignee after normalization
7
+ # (leading '@', whitespace, case), so legacy-stored values keep the loop
8
+ # running — but every exact-match consumer (backlog --assignee, the
9
+ # agent's own queries) misses them. This concern surfaces that deviation
10
+ # as a once-per-run stderr hint instead of letting it pass silently.
11
+ module AgentLoopAssigneeHints
12
+ private
13
+
14
+ # Warns once per run about assignees that matched only after
15
+ # normalization. Providers without #assignee_variants (plain lambdas
16
+ # in tests) are silently skipped.
17
+ def report_assignee_variants
18
+ variants = provider_assignee_variants
19
+ return if @variants_hinted || variants.empty?
20
+
21
+ @variants_hinted = true
22
+ quoted = variants.map { |value| "'#{value}'" }.join(', ')
23
+ @stderr.puts("letsdo: warning: tasks store assignee(s) #{quoted} instead of the handle " \
24
+ "'#{@handle}' - matched after normalization; store '#{@handle}' on the tasks " \
25
+ '(or set AGENT_ASSIGNEE_HANDLE) so exact-match filters find them')
26
+ end
27
+
28
+ def provider_assignee_variants
29
+ return [] unless @task_provider.respond_to?(:assignee_variants)
30
+
31
+ @task_provider.assignee_variants.to_a
32
+ end
33
+ end
34
+ end
@@ -15,6 +15,7 @@ module Letsdo
15
15
  tasks = @task_provider.call
16
16
  return provider_unavailable if tasks.nil?
17
17
 
18
+ report_assignee_variants
18
19
  reconcile_attempts(tasks)
19
20
  filtered = reject_cooled_down(tasks)
20
21
  @metrics&.provider_result(filtered.length)
@@ -31,7 +32,9 @@ module Letsdo
31
32
 
32
33
  debug("provider: #{tasks.length} open task(s)")
33
34
  @stderr.puts("letsdo: #{@name} has #{tasks.length} open task(s)")
34
- report_backoff(raw - tasks.length) if raw && raw > tasks.length
35
+ return unless raw && raw > tasks.length
36
+
37
+ @stderr.puts("letsdo: #{raw - tasks.length} open task(s) in retry backoff")
35
38
  end
36
39
 
37
40
  def report_empty(raw)
@@ -45,12 +48,6 @@ module Letsdo
45
48
  end
46
49
  end
47
50
 
48
- def report_backoff(skipped)
49
- return unless skipped.positive?
50
-
51
- @stderr.puts("letsdo: #{skipped} open task(s) in retry backoff")
52
- end
53
-
54
51
  def provider_unavailable
55
52
  debug('provider: backlog unavailable')
56
53
  @stderr.puts(unavailable_message)
@@ -1,11 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative 'agent_loop/tasks'
4
+ require_relative 'agent_loop/assignee_hints'
4
5
 
5
6
  module Letsdo
6
7
  # The orchestrator loop wired to the real environment for `letsdo <name>`.
7
8
  class AgentLoop
8
9
  include AgentLoopTasks
10
+ include AgentLoopAssigneeHints
9
11
 
10
12
  STOP = :letsdo_stop
11
13
  PAUSE_POLL_SECONDS = 0.05
@@ -73,6 +75,7 @@ module Letsdo
73
75
  @loop = build_loop
74
76
  install_signal_handlers
75
77
  @unavailable_hinted = false
78
+ @variants_hinted = false
76
79
  end
77
80
 
78
81
  def build_retry_policy(opts)
@@ -108,8 +108,11 @@ module Letsdo
108
108
  handle = opts[:handle] || assignee_handle(name)
109
109
  agent = opts[:agent] || agent_for(name, streamer)
110
110
  provider = opts[:provider] || provider_for(handle)
111
+ # The provider object itself (not a wrapping lambda) reaches the loop
112
+ # so the once-per-run assignee mismatch hint can read its
113
+ # #assignee_variants (TASK-96).
111
114
  AgentLoop.new(name: name, handle: handle, agent: agent,
112
- task_provider: -> { provider.call },
115
+ task_provider: provider,
113
116
  wait_seconds: wait_seconds, sleeper: @sleeper, stderr: opts[:stderr],
114
117
  metrics: opts[:metrics], pause_gate: opts[:pause_gate],
115
118
  watcher: backlog_watcher, **retry_options)
data/lib/letsdo/config.rb CHANGED
@@ -38,11 +38,22 @@ module Letsdo
38
38
  Shellwords.split(value)
39
39
  end
40
40
 
41
- # The agent's backlog assignee handle. Default: @<name> (handle = name);
42
- # a blank override also falls back to the derived handle.
41
+ # The agent's backlog assignee name. Default: the bare agent name —
42
+ # the tracker stores bare names ('developer'); the '@' prefix is
43
+ # prompt-only notation (TASK-96). AGENT_ASSIGNEE_HANDLE overrides for
44
+ # legacy setups (used verbatim; a @-prefixed value still matches after
45
+ # normalization in the provider, but `letsdo doctor` warns about it);
46
+ # a blank override also falls back to the derived bare name.
43
47
  def assignee_handle(name)
44
- handle = @env['AGENT_ASSIGNEE_HANDLE']
45
- handle && !handle.strip.empty? ? handle : "@#{name}"
48
+ assignee_handle_override || name.to_s
49
+ end
50
+
51
+ # Raw AGENT_ASSIGNEE_HANDLE (nil when unset or blank) — `letsdo
52
+ # doctor` uses it to warn about a legacy @-prefixed override instead
53
+ # of it silently missing every bare-named task.
54
+ def assignee_handle_override
55
+ value = @env['AGENT_ASSIGNEE_HANDLE']
56
+ value && !value.strip.empty? ? value : nil
46
57
  end
47
58
 
48
59
  # Retry interval when no tasks are open. LETSDO_WAIT_SECONDS wins, then
@@ -74,6 +85,14 @@ module Letsdo
74
85
  @env.fetch('PATH', '')
75
86
  end
76
87
 
88
+ # Environment for CLI child processes spawned outside the builder
89
+ # (the doctor's handle-less provider): the process environment
90
+ # overlaid with the injected env, so shebang PATH resolution and test
91
+ # scenario variables both keep working (TASK-96).
92
+ def child_env
93
+ ENV.to_h.merge(@env)
94
+ end
95
+
77
96
  # The task provider name. Reads LETSDO_PROVIDER with default 'backlog'.
78
97
  # An empty value falls back to 'backlog'.
79
98
  def provider
@@ -12,8 +12,8 @@ module Letsdo
12
12
  private
13
13
 
14
14
  def checks
15
- [ruby_check, pi_check, backlog_check, root_check,
16
- agents_md_check, agents_dir_check, tty_check]
15
+ [ruby_check, pi_check, backlog_check, assignee_check,
16
+ root_check, agents_md_check, agents_dir_check, tty_check]
17
17
  end
18
18
 
19
19
  # ruby >= 3.3 is the gemspec floor: below it letsdo still reports the
@@ -37,6 +37,52 @@ module Letsdo
37
37
  command_check('backlog', @config.backlog_command, 'LETSDO_BACKLOG_COMMAND')
38
38
  end
39
39
 
40
+ # Assignee-name convention (TASK-96): the tracker stores bare names
41
+ # ('developer'); '@' is prompt-only notation. The backlog CLI matches
42
+ # --assignee by exact string, so a legacy @-prefixed stored assignee
43
+ # (or handle override) is invisible to every exact-match query.
44
+ # letsdo still matches such tasks after normalization, but the
45
+ # deviation deserves a WARN so the data gets fixed. Uses a handle-less
46
+ # provider (no filtering) and reads the raw assignees; an unreadable
47
+ # backlog is reported as INFO because backlog_check already flags the
48
+ # cause.
49
+ def assignee_check
50
+ override = @config.assignee_handle_override
51
+ return warn_at_prefixed_override(override) if at_prefixed?(override)
52
+
53
+ tasks = provider_without_handle.call
54
+ return result('INFO', 'assignee names not checked - backlog task list failed') if tasks.nil?
55
+
56
+ warn_legacy_assignees(tasks)
57
+ end
58
+
59
+ # A handle-less provider sees every open task, so the check reads the
60
+ # raw stored assignees (no filtering, no variants recorded).
61
+ def provider_without_handle
62
+ Letsdo::Providers::Backlog.new(handle: nil, command: @config.backlog_command,
63
+ cwd: @config.root, env: @config.child_env)
64
+ end
65
+
66
+ def warn_legacy_assignees(tasks)
67
+ legacy = tasks.flat_map(&:assignees).select { |value| at_prefixed?(value) }.uniq.sort
68
+ return result('OK', 'assignee names are stored bare (canonical)') if legacy.empty?
69
+
70
+ quoted = legacy.map { |value| "'#{value}'" }.join(', ')
71
+ result('WARN', "tasks store legacy @-prefixed assignee(s): #{quoted}",
72
+ 'reassign them to bare names: backlog task edit <ID> -a <name>')
73
+ end
74
+
75
+ def warn_at_prefixed_override(override)
76
+ result('WARN', "AGENT_ASSIGNEE_HANDLE '#{override}' carries the legacy '@' prefix",
77
+ "set it to the bare name: AGENT_ASSIGNEE_HANDLE=#{override.sub(/\A@+/, '')}")
78
+ end
79
+
80
+ # '@developer' is prose notation; the canonical tracker value is the
81
+ # bare name, so any leading '@' is a legacy deviation worth a WARN.
82
+ def at_prefixed?(value)
83
+ value.to_s.strip.start_with?('@')
84
+ end
85
+
40
86
  def command_check(label, command, env_var)
41
87
  return result('OK', "#{label} command found: #{command}") if command_available?(command)
42
88
 
@@ -8,8 +8,7 @@ module Letsdo
8
8
  module Providers
9
9
  # Task provider for Letsdo::Loop backed by the real backlog CLI:
10
10
  #
11
- # backlog task list --assignee <handle> --exclude-status Done \
12
- # --ready --sort priority --json
11
+ # backlog task list --exclude-status Done --ready --sort priority --json
13
12
  #
14
13
  # Returns the runnable tasks assigned to the handle in the
15
14
  # authoritative run order (an Array of Letsdo::Providers::Task), or nil
@@ -17,6 +16,15 @@ module Letsdo
17
16
  # or its output is not the expected JSON. The loop treats nil as "pause
18
17
  # and retry, do not run the agent".
19
18
  #
19
+ # The assignee filter is applied in Ruby, not via the CLI's --assignee:
20
+ # the CLI matches the assignee by exact string, so a task stored as
21
+ # '@developer' (legacy notation) would never reach a '--assignee
22
+ # developer' query and the loop would silently starve (TASK-96). The
23
+ # handle match here tolerates the leading '@', surrounding whitespace
24
+ # and case, so both notations keep working; the literal values that
25
+ # matched only after normalization are exposed by #assignee_variants
26
+ # for the doctor WARN and the once-per-run loop hint.
27
+ #
20
28
  # The command runs in the project root (cwd), where the backlog CLI finds
21
29
  # the backlog/ folder — the same context as a single agent run.
22
30
  class Backlog
@@ -34,7 +42,10 @@ module Letsdo
34
42
  PRIORITY_RANKS = { 'high' => 0, 'medium' => 1, 'low' => 2 }.freeze
35
43
  UNKNOWN_RANK = PRIORITY_RANKS.size
36
44
 
37
- # @param handle [String] assignee handle to filter by (e.g. "@developer")
45
+ # @param handle [String, nil] assignee name to filter by (canonical:
46
+ # 'developer'; a legacy '@developer' still matches the same
47
+ # tasks); nil disables the filter (doctor inspects all open
48
+ # tasks)
38
49
  # @param command [String] backlog CLI command (overridable for tests)
39
50
  # @param cwd [String, nil] project root for the CLI; nil = inherit cwd
40
51
  # @param env [Hash, nil] environment for the CLI child (nil = inherit
@@ -45,6 +56,16 @@ module Letsdo
45
56
  @command = command
46
57
  @cwd = cwd
47
58
  @env = env
59
+ @assignee_variants = []
60
+ end
61
+
62
+ # Literal assignee values from the last successful batch that matched
63
+ # the handle only after normalization (e.g. '@developer' for the
64
+ # handle 'developer') — the mismatch signature the doctor check and
65
+ # the loop hint surface. Empty when the handle matched exactly, no
66
+ # handle is configured, or the last call failed.
67
+ def assignee_variants
68
+ @assignee_variants.dup
48
69
  end
49
70
 
50
71
  # Reads the runnable open tasks once, in deterministic run order.
@@ -52,12 +73,15 @@ module Letsdo
52
73
  # @return [Array<Task>, nil] runnable open tasks; nil when the backlog is
53
74
  # unreadable; empty array when there are no open tasks
54
75
  def call
76
+ @assignee_variants = []
55
77
  args = @env ? [@env, *command_line] : command_line
56
78
  out, _err, status = Letsdo::Capture.new(*args, chdir: @cwd).run
57
79
  return nil unless status.success?
58
80
 
59
81
  tasks = JSON.parse(out)['tasks']
60
- tasks.is_a?(Array) ? sort(tasks.map { |raw| normalize(raw) }) : nil
82
+ return nil unless tasks.is_a?(Array)
83
+
84
+ sort(select_by_assignee(tasks.map { |raw| normalize(raw) }))
61
85
  rescue Errno::ENOENT, JSON::ParserError, TypeError
62
86
  nil
63
87
  end
@@ -77,6 +101,37 @@ module Letsdo
77
101
  end.map(&:first)
78
102
  end
79
103
 
104
+ # Assignee filtering happens here, not in the CLI's --assignee: the
105
+ # CLI matches by exact string (TASK-96). The stored value may be the
106
+ # bare canonical name or the legacy '@'-prefixed one — both match.
107
+ # A nil handle (doctor) keeps every task and never records variants.
108
+ def select_by_assignee(tasks)
109
+ return tasks unless @handle
110
+
111
+ selected = tasks.select { |task| match_assignees(task) }
112
+ @assignee_variants = @assignee_variants.uniq.sort
113
+ selected
114
+ end
115
+
116
+ def match_assignees(task)
117
+ task.assignees.any? do |value|
118
+ next false if normalize_assignee(value) != normalized_handle
119
+
120
+ @assignee_variants << value unless value == @handle
121
+ true
122
+ end
123
+ end
124
+
125
+ def normalized_handle
126
+ normalize_assignee(@handle)
127
+ end
128
+
129
+ # '@Dev', ' dev ' and 'dev' are the same handle; only the leading '@'
130
+ # is stripped, so '@dev team' stays distinct from 'devteam'.
131
+ def normalize_assignee(value)
132
+ value.to_s.strip.sub(/\A@/, '').downcase
133
+ end
134
+
80
135
  def in_progress_rank(task)
81
136
  task.status.to_s.casecmp('In Progress').zero? ? 0 : 1
82
137
  end
@@ -106,13 +161,14 @@ module Letsdo
106
161
  Task.new(**TASK_FIELDS.to_h { |field| [field.to_sym, raw[field]] })
107
162
  end
108
163
 
109
- # [command..., task, list, --assignee <handle>, --exclude-status Done,
110
- # --ready, --sort priority, --json]
164
+ # [command..., task, list, --exclude-status Done, --ready,
165
+ # --sort priority, --json] — no --assignee: the filter runs in Ruby
166
+ # (select_by_assignee), so exact-match deviations in the stored
167
+ # assignees cannot starve the loop (TASK-96).
111
168
  def command_line
112
169
  [
113
170
  *Shellwords.split(@command),
114
171
  'task', 'list',
115
- '--assignee', @handle,
116
172
  '--exclude-status', 'Done',
117
173
  '--ready',
118
174
  '--sort', 'priority',
@@ -29,7 +29,9 @@ module Letsdo
29
29
  :current_task, :current_task_seconds, keyword_init: true)
30
30
 
31
31
  # @param name [String] agent name (CLI argument)
32
- # @param handle [String] assignee handle (e.g. "@developer")
32
+ # @param handle [String] assignee (the bare tracker name, e.g.
33
+ # "developer"; the header renders it with the display-only
34
+ # '@' prefix)
33
35
  # @param clock [Proc] monotonic clock, callable → seconds; injected
34
36
  # in tests
35
37
  # @param on_run_start [Proc, nil] called with the task label when a
@@ -66,11 +66,19 @@ module Letsdo
66
66
  end
67
67
 
68
68
  def self.header_line(metrics, width)
69
- title = "letsdo · #{metrics.name} (#{metrics.handle})"
69
+ title = "letsdo · #{metrics.name} (#{display_handle(metrics)})"
70
70
  timer = "session #{Text.format_duration(metrics.session_seconds)}"
71
71
  Text.fit_line_with_right(title, timer, width)
72
72
  end
73
73
 
74
+ # The header keeps the historical "name (@assignee)" look: the '@'
75
+ # is display notation only (TASK-96) — the tracker value is the bare
76
+ # name, and a legacy @-prefixed handle is shown as stored.
77
+ def self.display_handle(metrics)
78
+ handle = metrics.handle.to_s
79
+ handle.start_with?('@') ? handle : "@#{handle}"
80
+ end
81
+
74
82
  # The state line: done/left plus either the running task with its
75
83
  # elapsed time, a waiting reason, or the PAUSED overlay.
76
84
  def self.state_line(metrics, width, paused, wait_seconds)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Letsdo
4
- VERSION = '0.6.0'
4
+ VERSION = '0.6.1'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: letsdo
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.0
4
+ version: 0.6.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sergei O. Udalov
@@ -104,6 +104,7 @@ files:
104
104
  - lib/letsdo/agent.rb
105
105
  - lib/letsdo/agent_identity.rb
106
106
  - lib/letsdo/agent_loop.rb
107
+ - lib/letsdo/agent_loop/assignee_hints.rb
107
108
  - lib/letsdo/agent_loop/tasks.rb
108
109
  - lib/letsdo/backends/backend.rb
109
110
  - lib/letsdo/backends/pi.rb
@@ -171,7 +172,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
171
172
  - !ruby/object:Gem::Version
172
173
  version: '0'
173
174
  requirements: []
174
- rubygems_version: 4.0.6
175
+ rubygems_version: 4.0.21
175
176
  specification_version: 4
176
177
  summary: A local agent worker for Backlog.md/markdown tasks
177
178
  test_files: []