open-loops 1.3.0

Recupere o contexto de trabalhos pausados: o que começou, onde parou, qual o próximo passo
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
# ADR 0003: Query engine, contexts e inventory cache

Data: 2026-06-24 (revisado após brainstorm + revisão adversarial) · Status: aceito

## Contexto

Usuários com múltiplas roots (trabalho, pessoal), dezenas de repositórios e
várias branches por repo veem o inventário (`loops`) como ruído: tudo é
escaneado e listado antes de filtrar. O `resume` já aceita query fuzzy, mas
a listagem é all-or-nothing.

O scan atual (`find_repos` → `open_loops` em paralelo) é eager: para cada
branch não mergeada roda `rev-list --left-right --count` (ahead/behind). Com
10 repos × 8 branches ≈ 80 subprocessos git só para montar a tabela.

## Propósito

1. **Escopo** — ver só o que importa agora (`loops api`, `loops @work`).
2. **Vocabulário estável** — separar trabalho/pessoal sem decorar paths
   (`@work`, `@personal`).
3. **Performance** — não varrer nem interrogar git além do que a query exige;
   amortizar a parte cara do scan (ahead/behind) em cache reutilizável. O ganho
   é maior em queries com escopo (`loops api`); o `loops` sem escopo fica mais
   barato no repeat (pula o `rev-list` memoizado) mas ainda paga a fase leve.
4. **Consistência** — o mesmo motor de **parse + filtro** alimenta `loops`,
   `loops worktrees` e `loops resume` (este último exige match único).

Inspiração: filtros e contexts do [Taskwarrior](https://taskwarrior.org/)
— query declarativa sobre dados já existentes, sem CRUD manual por loop.

## Decisão

Introduzir um módulo `query` que:

1. Parseia a query em um **`ScanPlan`** antes de qualquer I/O pesado.
2. Executa git em **duas fases** (leve sempre → pesada memoizada sob demanda).
3. Persiste um **inventory cache** por repositório em `~/.open-loops/inventory/`
   que memoiza **apenas** a parte cara (ahead/behind), validada por SHA.
4. Suporta **contexts** (escopo persistente) e **reports** (queries salvas).
5. Adota uma **chave canônica sempre prefixada por root** (`root-label/repo/branch`).

### Sintaxe de query

**Termos soltos** — split **só por whitespace** (uma `/` é literal dentro do
termo); AND implícito; cada termo casa por substring (case-insensitive) em
`repo`, `branch` ou na chave canônica (que já é prefixada):

```bash
loops api                  # todas as branches do repo "api"
loops api feat/login       # dois termos: "api" AND "feat/login"
loops work/api             # um termo: substring "work/api" na chave prefixada
```

**Atributos** — estilo Taskwarrior (`attr:valor`):

| Atributo | Tipo | Comparadores | Exemplos |
|---|---|---|---|
| `repo` | substring || `repo:api` |
| `branch` | substring/prefixo || `branch:feat/` |
| `root` | path/prefixo de root || `root:~/work` |
| `key` | substring na chave || `key:work/api/feat/x` |
| `idle` | duração | `>` `<` `>=` `<=` (operador **obrigatório**) | `idle:>7d`, `idle:<2d` |
| `ahead` / `behind` | inteiro | `>` `<` `>=` `<=` ou igualdade nua | `behind:>0`, `ahead:0` |

- **Gramática de duração**: `<N>(m|h|d|w)` — minutos, horas, dias, semanas.
- `idle:7d` sem comparador é erro com dica (igualdade exata sobre duração não
  faz sentido). `ahead`/`behind` aceitam igualdade nua (`ahead:0`).
- **`ahead`/`behind` em qualquer query forçam a fase pesada** (ver
  `need_ahead_behind` abaixo), mesmo em `resume`.
- `root:` faz tilde-expand + canonicalização antes de casar por prefixo contra
  as roots (já canonicalizadas) do config. Se nenhuma root casa, o resultado é
  vazio (com a dica padrão), não erro.

**Tags virtuais** — sem estado extra; modificam o conjunto:

| Tag | Efeito |
|---|---|
| `-ignored` | default: esconde loops ignorados |
| `+ignored` | inclui ignorados (auditoria) |
| `+stale` | atalho para `idle:>{stale_threshold}` (default 14d, configurável) |

**Contexts** — escopo persistente no `config.toml`:

```toml
[contexts.work]
filter = "root:~/work"

[contexts.personal]
filter = "root:~/personal"
```

Uso: `loops @work`, `loops @work api`. O prefixo `@` distingue contexto de
repo chamado `work`. **`@none`** (ou `@all`) limpa o contexto default para uma
visão completa pontual.

**Reports** — queries salvas, invocadas com `:`:

```toml
[reports.stale-work]
filter = "@work idle:>14d"

[reports.hot]
filter = "idle:<3d"
```

Uso: `loops :stale-work`. O filtro de um report é parseado como uma sub-query:
pode embutir **um** `@context`, mas **não** pode referenciar outro `:report`
(guard de profundidade = 1; violação é erro). Contexts e reports diferem por
**intenção**, não por sintaxe:

| | Context (`@nome`) | Report (`:nome`) |
|---|---|---|
| Pergunta | *Em qual universo estou?* | *Qual recorte quero ver agora?* |
| Uso | Hábito diário (trabalho vs pessoal) | Auditoria periódica (stale, hot) |
| Ativação | Escopo implícito na query | Invocação explícita |

**Composição e precedência** — tudo é AND:

```
efetivo = (default_context, salvo se @ explícito ou @none)
          ∧ @context explícito
          ∧ expand(:report)
          ∧ termos/atributos/tags ad-hoc
```

- `@context` explícito **substitui** o `default_context` (comportamento
  Taskwarrior). `default_context` (config) e `LOOPS_CONTEXT` (env) só valem
  quando não há `@` na query; `@none`/`@all` os ignora.
- `OR` e parênteses ficam fora da v1 (só AND); suficiente para os casos
  conhecidos.

### Chave canônica

A chave é **sempre** `root-label/repo/branch` (ex.: `work/api/feat/billing`),
exibida na tabela e usada em `ignore`/`resume`/cache. Prefixo sempre presente,
**estável**: adicionar um segundo repo chamado `api` sob outra root nunca muda
a chave do primeiro.

- `root-label` = alias configurado para a root, senão o basename dela.
- **Colisão de label** (duas roots com mesmo basename e sem alias) é **erro
  acionável** em qualquer comando que escaneia (`loops`, `resume`, `worktrees`):
  `roots A and B share label 'repos'; set an alias in config.toml`.

**Contrato de implementação (consumidores da chave):**

```rust
struct OpenLoop {
    root_label: String,          // novo: resolvido da root dona no scan
    repo_name: String,
    repo_path: PathBuf,
    branch: String,
    head_sha: String,
    last_commit: DateTime<Utc>,
    ahead: Option<u32>,          // None quando a fase pesada não rodou (ex.: resume sem ahead/behind)
    behind: Option<u32>,
}

impl OpenLoop {
    fn key(&self) -> String {
        format!("{}/{}/{}", self.root_label, self.repo_name, self.branch)
    }
}
```

- `OpenLoop::key()` passa de `repo_name/branch` para `root_label/repo_name/branch`.
- **Distill cache** (`cache.rs`): o path passa de `cache/<repo>/<branch>@<sha>.md`
  para `cache/<root_label>/<repo>/<branch_escapado>@<head_sha>.md` (a `/` na
  branch continua escapada para `__`). É essa mudança — não só o `key()` — que
  corrige a colisão latente entre repos de mesmo nome. Cache é descartável; sem
  migração.
- `resolve_loop` (`cli.rs`) passa a casar a substring contra a chave de 3
  segmentos.
- `render_table` (`output.rs`) imprime `-` quando `ahead`/`behind` são `None`.
  No caminho da tabela `need_ahead_behind` é sempre true, então na prática são
  sempre `Some`; o `-` é defensivo.

**Migração (breaking change, pré-1.0, documentada):**

- `ignores.toml`: as chaves agora são prefixadas; entradas antigas não casam.
  Sem shim de compatibilidade — o break é documentado no CHANGELOG e o usuário
  re-adiciona os ignores.
- `resume` resolve por chave **inclusive** loops ignorados (você pode retomar
  algo que ignorou); a listagem é que esconde ignorados por default.

## Como funciona

### Pipeline

```
query string
    → parse (query.rs)
    → resolve contexts/reports → ScanPlan { roots, repo_filter, branch_filter,
                                            attr_filters, include_ignored,
                                            need_ahead_behind }
    → find_repos(roots)                    # só roots do plano
    → filter repos by repo_filter          # antes de git pesado
    → per repo: fase leve (sempre) — usa inventory para memoizar a fase pesada
    → eval attr_filters em memória
    → render tabela / pick único (resume)
```

### ScanPlan

Estrutura derivada da query **antes** do scan:

```rust
struct ScanPlan {
    roots: Vec<PathBuf>,              // subset de cfg.roots (@work, root:...)
    repo_filter: Option<Pattern>,
    branch_filter: Option<Pattern>,
    attr_filters: Vec<AttrFilter>,    // idle:>7d, behind:>0, ...
    include_ignored: bool,            // +ignored
    need_ahead_behind: bool,
}
```

**Derivação de `need_ahead_behind`** (corrigida): true se a saída renderiza as
colunas AHEAD/BEHIND **ou** se a query contém um atributo `ahead`/`behind`:

```
need_ahead_behind = renders_ab_columns || query_tem_attr_ahead_ou_behind
```

Assim `loops` (tabela) → true; `loops resume api` → false; `loops resume api
behind:>0` → true (senão o filtro não teria o que avaliar).

Push-down de escopo:

| Query | Efeito |
|---|---|
| `loops @work` | `find_repos` só em roots de trabalho |
| `loops api` | filtra repos cujo nome casa antes de qualquer git |
| `loops @work api` | 1 root × 1 repo |

### Superfície de CLI (clap)

Hoje `loops` não tem positional de query; só subcommands. A mudança adiciona um
positional variádico de topo que coexiste com os subcommands:

```rust
struct Cli {
    #[command(subcommand)]
    command: Option<Command>,   // resume, ignore, worktrees, init, completions, refresh
    query: Vec<String>,         // ação default: listar com a query
}
```

- Se o primeiro token nomeia um subcommand conhecido → dispatch para ele.
- Senão, todos os positionals viram a query da **ação default** (`run_list`).
- **Shadow rule**: um repo chamado igual a um subcommand (ex.: `resume`) fica
  sombreado; para filtrá-lo use `repo:resume`. Documentar em `loops help query`.

### Git em duas fases

**Fase leve** (por repo, **sempre roda**, ~4-6 git calls — `default_branch` já
custa 1-2 chamadas):

- `default_branch` + `rev-parse` do SHA da default
- `for-each-ref refs/heads --format='%(refname:short)%09%(objectname)%09%(committerdate:iso8601-strict)'`
  → branch, `head_sha`, `last_commit`
- `branch --merged` (para excluir branches mergeadas do conjunto de loops)

Como a fase leve sempre roda, `last_commit`, `head_sha` e o conjunto de loops
(mergeadas excluídas) são **sempre atuais** — nunca servidos stale do cache.
Basta para: listagem, `idle:`, ordenação por staleness, `repo:`, `branch:`.

**Fase pesada** (por branch, sob demanda + memoizada):

- `rev-list --left-right --count {default}...{branch}` → ahead/behind

Só roda quando `need_ahead_behind` é true **e** o valor não está memoizado para
o par `(head_sha, default_sha)`. Quando não roda, `ahead`/`behind` ficam `None`.

### Inventory cache

Arquivo por repo em `~/.open-loops/inventory/<hash-do-path-canônico>.json`.
O cache **não** evita a fase leve; ele memoiza **apenas** o `rev-list` caro.
Por isso guarda só o que a validação lê — `last_commit`, `merged` e `root_label`
**não** entram (são sempre recomputados/derivados, nunca lidos do cache):

```json
{
  "repo_path": "/home/you/work/api",
  "indexed_at": "2026-06-24T10:00:00Z",
  "loops": [
    {
      "branch": "feat/billing",
      "head_sha": "def456",
      "ab_base_sha": "abc123",
      "ahead": 5,
      "behind": 0
    }
  ]
}
```

- `repo_path` é lido para confirmar identidade / detectar colisão de hash.
- `indexed_at` é lido só para TTL (`inventory_ttl_secs`).
- Cada entrada de `loops` é um memo de ahead/behind com suas chaves de validação
  (`head_sha` da branch e `ab_base_sha` = SHA da default no momento do cálculo).

**Validação (por branch, correta para movimento de qualquer branch):**

1. A fase leve roda e produz o `head_sha` atual de cada branch e o
   `default_sha` atual.
2. `ahead`/`behind` são reaproveitados do cache **só se** existir entrada com
   `head_sha` igual ao atual **e** `ab_base_sha` igual ao `default_sha` atual.
3. Caso contrário, recomputa `rev-list` e atualiza a entrada.

Isso resolve o caso dominante que a invalidação por `default_head` deixava
escapar: um commit novo na própria branch muda seu `head_sha`, invalidando o
memo — sem depender de movimento da default.

**Write-through e atomicidade:**

- Todo scan (inclusive `loops api`) persiste o inventory atualizado dos repos
  que tocou — então queries filtradas também aquecem o cache.
- Escrita atômica (tmp + rename) evita corrupção quando dois terminais rodam
  `loops` ao mesmo tempo. Em concorrência o último a escrever vence (lost-update
  benigno: o próximo scan recomputa o que faltar).
- Como `root_label` **não** é persistido, renomear um alias no config nunca
  dessincroniza o cache: a chave de distill é derivada de config + path no
  momento do uso, sempre com o label atual.

**Política de uso:**

| Comando | Cache |
|---|---|
| `loops` (sem query) | fase leve em tudo; memoiza/atualiza ahead/behind |
| `loops api` | fase leve só nos repos casados; memoiza + write-through |
| `loops resume api/x` | fase leve resolve o loop (SHA vivo); pula fase pesada (salvo se a query tem `ahead`/`behind`); git pesado de destilação (log, diffstat, sessões, LLM) só no resume |

`--fresh` (flag de `loops [query]` e `loops resume`) ignora o memo e recomputa
ahead/behind no escopo. `loops refresh [@ctx]` recomputa a fase pesada de
**todas** as branches do escopo (full reindex). TTL configurável
(`inventory_ttl_secs`, default 0 = só validação por SHA). Arquivos órfãos (repo
movido/apagado) são limpos preguiçosamente no `refresh` (cf. ADR 0004).

**resume e o cache de destilação:** o resume roda a fase leve (→ `head_sha`
vivo) para estreitar a 1 match, logo a chave do cache de destilação
(`root_label/repo/branch@head_sha`, ver "Chave canônica") é correta por
construção — sem recomputo especial.

### Comportamento por comando

| Comando | Query | Resultado |
|---|---|---|
| `loops [query]` | opcional | 0..N loops → tabela |
| `loops worktrees [query]` | opcional | mesmos filtros, domínio worktree |
| `loops resume <query>` | obrigatória | filtra → **exige 1 match** → destila |
| `loops refresh [@ctx]` | opcional | reindexa inventory de tudo ou de um context |

**Motor unificado — escopo exato:** o que `loops`, `worktrees` e `resume`
compartilham é **parse → ScanPlan → filtro** (roots, `repo`/`branch`, `idle`,
ignored). A **coleta** difere: `worktrees` usa `git worktree list --porcelain`
+ status/log por worktree, **não** tem ahead/behind nem inventory memo —
`need_ahead_behind` é ignorado nesse domínio. "Mesmo engine" = mesma camada de
filtro, não a mesma coleta.

Query sem resultados:

```
No loops match: @work api idle:>30d
(hint: run `loops` to list all, or `loops help query`)
```

### Config (campos novos)

Além de `roots`/`llm_command`/`sessions_dir`/`max_sessions`/`max_session_kb`
(ver `docs/configuration.md`):

```toml
roots = ["/home/you/work", "/home/you/personal"]

# alias por root, chaveado pelo path canônico (resolve colisão de label)
[aliases]
"/home/you/work" = "w"

default_context = "work"        # opcional; sobreposto por @ctx explícito / @none
stale_threshold = "14d"         # +stale = idle:>{stale_threshold}
inventory_ttl_secs = 0          # 0 = validação só por SHA

[contexts.work]
filter = "root:~/work"

[reports.stale-work]
filter = "@work idle:>14d"
```

Env: `LOOPS_CONTEXT=work` equivale a `default_context` (vale só sem `@` na query).

## Por quê

1. **Taskwarrior-like sem CRUD** — o inventário vem do git; query filtra, não
   mantém estado por loop. Tags são virtuais; contexts/reports vivem no config.
2. **Push-down antes de git** — o ganho real está em não spawnar subprocessos
   para repos/branches que a query já exclui, não em filtrar um `Vec` em RAM.
3. **Fase leve sempre + pesada memoizada** — a fase leve (~4-6 calls/repo) é
   barata e mantém `last_commit`/`head_sha`/conjunto-de-loops sempre corretos;
   só o `rev-list` O(branches) é memoizado. Trocamos "repeat instantâneo só com
   JSON" por "sempre correto + leve barato + pesado memoizado" — escolha
   consciente: nunca exibir estado de branch desatualizado.
4. **Inventory cache separado do cache de destilação** — o inventory memoiza
   ahead/behind (validado por `head_sha`/`default_sha`); a destilação é chaveada
   por `head_sha` da branch. Misturar invalidaria errado.
5. **Motor unificado na camada de filtro** — resume, list e worktrees
   compartilham parse + ScanPlan + filtro; coleta e pós-processamento diferem.
6. **Chave sempre prefixada** — estabilidade vence verbosidade: a chave de um
   repo nunca muda ao adicionar outro de mesmo nome. Como é breaking de qualquer
   forma, quebra-se uma vez, limpo e documentado.

## Consequências

**Positivas**

- `loops api` e `loops @work` escalam com o número de repos **no escopo**,
  não com o total configurado.
- Repeats reaproveitam ahead/behind do memo e nunca exibem estado stale.
- Separação trabalho/pessoal sem múltiplos binários ou configs.
- Chave canônica corrige a colisão latente de repos de mesmo nome no cache de
  destilação.

**Negativas / riscos**

- **`loops` sem escopo não fica instantâneo no repeat**: ainda paga a fase leve
  (~4-6 calls × N repos); só economiza o `rev-list` memoizado. O ganho grande é
  para queries com escopo, que pulam repos fora do filtro. Ex.: 50 repos sem
  escopo ≈ 200-300 chamadas leves por execução (heavy memoizado); `loops api`
  toca só os repos que casam.
- Chave sempre prefixada é breaking change para `ignores` e paths de cache
  existentes — mitigado por ser pré-1.0, com nota de migração no CHANGELOG.
- Parser de query adiciona superfície de testes e docs (`loops help query`).
- Colisão de root-label exige alias manual (erro acionável, não silencioso).
- OR e parênteses ficam fora da v1 (só AND); suficiente para os casos
  conhecidos.

## Fases de implementação

| Fase | Entrega | Dependências |
|---|---|---|
| 1a | `query.rs`: parse → `ScanPlan` (termos, atributos, tags) ||
| 1b | Chave sempre prefixada: `OpenLoop.root_label` + `key()` de 3 segmentos + `Cache::path` + `resolve_loop` + `ignores`; alias/colisão; superfície clap `loops [query]`; documenta o break | 1a |
| 2 | Wire do `ScanPlan` no scan: push-down + split fase leve/pesada + `need_ahead_behind` | 1a, 1b |
| 3 | `inventory.rs`: memo por SHA (validação via for-each-ref, write-through, atômico) + `refresh`/`--fresh` | 2 |
| 4 | Contexts `@nome`/`@none`/`@all` + config + `default_context`/`LOOPS_CONTEXT` (precisa do push-down da fase 2 para escopar de fato) | 1a, 2 |
| 5 | Reports `:nome` + `+stale` + `loops help query` | 2, 4 |
| 6 | Mesma camada de filtro em `worktrees [query]`; resume já é engine-based após 1b | 1b, 2 |

A Fase 1b (chave) é antecipada porque é breaking e toca resume/ignore/cache —
landar uma vez, cedo, evita migração dupla. Fase 1 entrega o valor principal
(escopo + `loops api`); fase 3 entrega a economia de processamento. A dica
`loops help query` na mensagem de "sem resultados" só vira comando real na fase
5; até lá a dica de help fica latente.

## Fora de escopo (v1)

- Sintaxe SQL ou expressões com OR/parênteses.
- Tags manuais por loop (`loops tag work …`).
- Daemon em background; warm via cron/`loops refresh` é suficiente.
- Filtro que evita `find_repos` walk além de restringir roots — o walk é
  barato vs git; otimização prematura.
- Inventory memo para worktrees — coleta diferente; pode vir depois se medível.