spec-first-copilot 0.5.0-beta.2 → 0.5.0-beta.4
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.
- package/lib/init.js +2 -2
- package/package.json +1 -1
- package/templates/.github/adapters/confluence.md +273 -0
- package/templates/.github/adapters/errors.md +234 -0
- package/templates/.github/adapters/filesystem.md +353 -0
- package/templates/.github/adapters/interface.md +301 -0
- package/templates/.github/adapters/naming.md +241 -0
- package/templates/.github/adapters/registry.md +244 -0
- package/templates/.github/copilot-instructions.md +4 -5
- package/templates/.github/skills/sf-new-project/SKILL.md +128 -0
- package/templates/sfw.config.yml.example +131 -0
- package/templates/.github/skills/sf-setup-projeto/SKILL.md +0 -123
- package/templates/workspace/Input/setup_projeto/.gitkeep +0 -0
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
# FilesystemAdapter — Runbook
|
|
2
|
+
|
|
3
|
+
> Implementação do `SourceAdapter` para diretórios em disco.
|
|
4
|
+
>
|
|
5
|
+
> Serve **dois propósitos**:
|
|
6
|
+
>
|
|
7
|
+
> 1. **Backend real** — times offline, diretório compartilhado em NAS,
|
|
8
|
+
> workflow 100% local sem Confluence/Notion.
|
|
9
|
+
> 2. **Mock natural pra testes E2E** — configurar `sfw.config.yml` apontando
|
|
10
|
+
> pra pasta de fixtures e o pipeline rodar inteiro sem mocks manuais.
|
|
11
|
+
> Ganho colateral citado em §9.9 (napkin): "Zero lib de mock, zero stub".
|
|
12
|
+
>
|
|
13
|
+
> Contrato conceitual: `.github/adapters/interface.md`.
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## Meta
|
|
18
|
+
|
|
19
|
+
| Campo | Valor |
|
|
20
|
+
|-------|-------|
|
|
21
|
+
| `name` | `filesystem` |
|
|
22
|
+
| Transporte | Node.js `fs` (ou equivalente na runtime) |
|
|
23
|
+
| Credenciais | Nenhuma — permissões do processo |
|
|
24
|
+
| Conflict detection | Hash-based (sha256 do conteúdo) |
|
|
25
|
+
| Suporta attachments? | ✅ via subpasta `attachments/` por convenção |
|
|
26
|
+
| Suporta busca? | Fora da interface |
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## Config
|
|
31
|
+
|
|
32
|
+
Passada via `sfw.config.yml > input.config` ou `output.targets[].config`.
|
|
33
|
+
|
|
34
|
+
| Campo | Obrig. | Tipo | Descrição |
|
|
35
|
+
|-------|:---:|------|-----------|
|
|
36
|
+
| `root_path` | ✅ | string | Path absoluto ou relativo à raiz do projeto. Ex: `"./mirror"`, `"/srv/shared/sfw"`. |
|
|
37
|
+
| `glob` | — | string | Pattern de arquivos que contam como "content". Default `"**/*.md"`. |
|
|
38
|
+
| `create_if_missing` | — | boolean | Default `true`. Se `false` e `root_path` não existe → `ValidationError`. |
|
|
39
|
+
| `follow_symlinks` | — | boolean | Default `false`. Segurança: evita escape da root via symlinks. |
|
|
40
|
+
|
|
41
|
+
### `validateConfig`
|
|
42
|
+
|
|
43
|
+
Lança `ValidationError` se:
|
|
44
|
+
- `root_path` ausente, vazio ou não-string
|
|
45
|
+
- `root_path` não existe e `create_if_missing === false`
|
|
46
|
+
- `glob` presente mas não-string
|
|
47
|
+
- `follow_symlinks` presente mas não-boolean
|
|
48
|
+
|
|
49
|
+
Resolve `root_path` pra absoluto e armazena (evita ambiguidade entre
|
|
50
|
+
operações subsequentes).
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
## Conceitos
|
|
55
|
+
|
|
56
|
+
### IDs
|
|
57
|
+
|
|
58
|
+
`id` de qualquer item é o **path relativo a `root_path`**, sempre com
|
|
59
|
+
separador `/` normalizado. Ex:
|
|
60
|
+
- `root_path = "./mirror"`
|
|
61
|
+
- Arquivo em `./mirror/workspace/Input/app_barbearia/brief.md`
|
|
62
|
+
- `id = "workspace/Input/app_barbearia/brief.md"`
|
|
63
|
+
|
|
64
|
+
**Por que relativo**: logs ficam portáveis entre máquinas. Dev em Mac e
|
|
65
|
+
CI em Linux podem compartilhar `.ai/publish-log.md` sem precisar reescrever.
|
|
66
|
+
|
|
67
|
+
### Version = sha256
|
|
68
|
+
|
|
69
|
+
`getVersion` retorna `sha256(content)` hexadecimal, sem prefixo. Sempre
|
|
70
|
+
calculado sob demanda — nunca cacheado.
|
|
71
|
+
|
|
72
|
+
**Por que sha256**: detecta qualquer mudança de byte, inclusive alterações
|
|
73
|
+
de whitespace. É o equivalente honesto do `version.number` do Confluence
|
|
74
|
+
quando não há um contador monotônico disponível.
|
|
75
|
+
|
|
76
|
+
**Trade-off aceito**: mesmo conteúdo em dois arquivos tem a mesma version —
|
|
77
|
+
isso é OK porque `id` (path) é que identifica o item, não version.
|
|
78
|
+
|
|
79
|
+
### Container vs item
|
|
80
|
+
|
|
81
|
+
| Estrutura filesystem | `type` do Item |
|
|
82
|
+
|---|---|
|
|
83
|
+
| Diretório qualquer | `folder` |
|
|
84
|
+
| Arquivo que bate com `glob` | `file` |
|
|
85
|
+
| Arquivo que NÃO bate com `glob` | ignorado (não aparece em `listChildren`) |
|
|
86
|
+
| Symlink (se `follow_symlinks: false`) | ignorado |
|
|
87
|
+
|
|
88
|
+
Não há `"page"` no filesystem — só `folder` e `file`.
|
|
89
|
+
|
|
90
|
+
### Attachments por convenção
|
|
91
|
+
|
|
92
|
+
`fetchAttachments(itemId)` procura uma subpasta `attachments/` **irmã** do
|
|
93
|
+
item. Exemplo:
|
|
94
|
+
|
|
95
|
+
```
|
|
96
|
+
workspace/Input/app_barbearia/
|
|
97
|
+
├── brief.md ← item
|
|
98
|
+
└── attachments/ ← lidos como attachments de brief.md
|
|
99
|
+
├── diagrama.png
|
|
100
|
+
└── modelagem.sql
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
**Regra**: attachments são compartilhados por todos os itens do mesmo
|
|
104
|
+
diretório. Se dois arquivos estão na mesma pasta, ambos "enxergam" os mesmos
|
|
105
|
+
attachments. Simplifica o mental model ("tudo junto na pasta").
|
|
106
|
+
|
|
107
|
+
---
|
|
108
|
+
|
|
109
|
+
## Mapeamento da interface → filesystem
|
|
110
|
+
|
|
111
|
+
### `listChildren(containerId) → Item[]`
|
|
112
|
+
|
|
113
|
+
**Passos**:
|
|
114
|
+
1. Resolver path: `fullPath = path.join(root_path, containerId)`
|
|
115
|
+
2. Validar que `fullPath` está dentro de `root_path` (prevenção de traversal):
|
|
116
|
+
- Se `path.relative(root_path, fullPath)` começa com `..` → `ValidationError`
|
|
117
|
+
3. Chamar `fs.readdir(fullPath, { withFileTypes: true })`
|
|
118
|
+
4. Para cada entry:
|
|
119
|
+
- Ignorar nomes iniciando com `.` (`.git`, `.DS_Store`, etc.)
|
|
120
|
+
- Ignorar `attachments/` (reservado)
|
|
121
|
+
- Ignorar symlinks se `follow_symlinks === false`
|
|
122
|
+
- Ignorar arquivos que não batem com `glob` E que não são diretórios
|
|
123
|
+
5. Montar `Item` pra cada entry válida:
|
|
124
|
+
- `id` = path relativo normalizado (`/` como separador)
|
|
125
|
+
- `title` = `entry.name`
|
|
126
|
+
- `version` = sha256 do conteúdo (arquivo) ou `"dir"` (diretório)
|
|
127
|
+
- `type` = `"folder"` se dir, `"file"` se arquivo
|
|
128
|
+
- `hasChildren` = `true` se dir, `false` se file
|
|
129
|
+
6. Retornar array ordenado alfabeticamente por `title`
|
|
130
|
+
|
|
131
|
+
**Erros**:
|
|
132
|
+
- Path fora da root → `ValidationError`
|
|
133
|
+
- Diretório não existe → `NotFoundError { kind: "container", itemId: containerId }`
|
|
134
|
+
- Sem permissão de leitura → `AuthError { hint: "sem permissão de leitura em {path}" }`
|
|
135
|
+
- EIO/ENOSPC/etc → `TransportError { retryable: false }`
|
|
136
|
+
|
|
137
|
+
---
|
|
138
|
+
|
|
139
|
+
### `fetchContent(itemId) → { content, metadata }`
|
|
140
|
+
|
|
141
|
+
**Passos**:
|
|
142
|
+
1. Resolver path (mesma validação anti-traversal)
|
|
143
|
+
2. `stat` no path:
|
|
144
|
+
- Se diretório → `ValidationError` (`fetchContent` só funciona em file)
|
|
145
|
+
- Se não existe → `NotFoundError { kind: "item" }`
|
|
146
|
+
3. `fs.readFile(fullPath, "utf-8")` → `content`
|
|
147
|
+
4. Montar `metadata`:
|
|
148
|
+
- `version` = sha256(content) hex
|
|
149
|
+
- `size` = bytes
|
|
150
|
+
- `mtime` = ISO string do `stat.mtime`
|
|
151
|
+
- `ctime` = ISO string do `stat.ctime`
|
|
152
|
+
- `absolute_path` = `fullPath` (útil pra debug)
|
|
153
|
+
5. Retornar `{ content, metadata }`
|
|
154
|
+
|
|
155
|
+
**Nota**: arquivos não-UTF-8 (binários) deveriam estar em `attachments/`,
|
|
156
|
+
não em `content`. Se o glob pegou um binário por engano, aceitar e deixar o
|
|
157
|
+
caller lidar — não é responsabilidade do adapter filtrar.
|
|
158
|
+
|
|
159
|
+
---
|
|
160
|
+
|
|
161
|
+
### `fetchAttachments(itemId) → Attachment[]`
|
|
162
|
+
|
|
163
|
+
**Passos**:
|
|
164
|
+
1. Resolver path do item
|
|
165
|
+
2. Calcular path da subpasta attachments:
|
|
166
|
+
- `attachDir = path.join(path.dirname(fullPath), "attachments")`
|
|
167
|
+
3. Se `attachDir` não existe → retornar `[]` (não é erro)
|
|
168
|
+
4. `fs.readdir(attachDir, { withFileTypes: true })`
|
|
169
|
+
5. Pra cada arquivo (ignorar subdirs e hidden):
|
|
170
|
+
- `stat` pra pegar `size`
|
|
171
|
+
- Detectar `mimeType` por extensão (mapa simples: `.png → image/png`,
|
|
172
|
+
`.pdf → application/pdf`, `.sql → text/x-sql`, fallback `application/octet-stream`)
|
|
173
|
+
- `download()` = closure que chama `fs.readFile(path, null)` → `Buffer`
|
|
174
|
+
6. Retornar array
|
|
175
|
+
|
|
176
|
+
---
|
|
177
|
+
|
|
178
|
+
### `getVersion(itemId) → string`
|
|
179
|
+
|
|
180
|
+
**Passos**:
|
|
181
|
+
1. Resolver path, validar existe e é arquivo
|
|
182
|
+
2. Ler conteúdo (`fs.readFile`)
|
|
183
|
+
3. Retornar `sha256(content)` hex lowercase
|
|
184
|
+
|
|
185
|
+
**Trade-off**: lê o arquivo inteiro pra calcular hash. Pra MVP é aceitável —
|
|
186
|
+
arquivos de spec/doc são pequenos (KB). Se precisar otimizar, substituir por
|
|
187
|
+
`mtime` em nanosegundos (menos honesto mas mais barato).
|
|
188
|
+
|
|
189
|
+
---
|
|
190
|
+
|
|
191
|
+
### `create(parentId, title, content) → { itemId, version }`
|
|
192
|
+
|
|
193
|
+
**Passos**:
|
|
194
|
+
1. Resolver `parentDir = path.join(root_path, parentId)`, validar anti-traversal
|
|
195
|
+
2. Garantir que `parentDir` existe — se não e `create_if_missing`, criar via `mkdirp`
|
|
196
|
+
3. `fullPath = path.join(parentDir, title)`
|
|
197
|
+
4. Se arquivo já existe → `ConflictError { kind: "duplicate_title", itemId: <novo id relativo> }`
|
|
198
|
+
5. `fs.writeFile(fullPath, content, "utf-8")`
|
|
199
|
+
6. Calcular `version = sha256(content)`
|
|
200
|
+
7. Retornar `{ itemId: <relative path>, version }`
|
|
201
|
+
|
|
202
|
+
**Convenção sobre `title`**: o caller já aplica `naming.output_artifact`
|
|
203
|
+
(ex: `"app_barbearia - TRD"`). O adapter **adiciona extensão `.md`** se o
|
|
204
|
+
title não já tiver uma — filesystem exige extensão, Confluence não. Esta é a
|
|
205
|
+
assimetria principal entre os 2 adapters.
|
|
206
|
+
|
|
207
|
+
Exemplo: `title = "app_barbearia - TRD"` → arquivo `app_barbearia - TRD.md`.
|
|
208
|
+
|
|
209
|
+
---
|
|
210
|
+
|
|
211
|
+
### `update(itemId, content, expectedVersion) → { version, ok, conflict? }`
|
|
212
|
+
|
|
213
|
+
**Passos**:
|
|
214
|
+
1. Resolver path, validar existe
|
|
215
|
+
2. Chamar `getVersion(itemId)` → `currentVersion` (sha256 do conteúdo atual)
|
|
216
|
+
3. Se `currentVersion !== expectedVersion`:
|
|
217
|
+
- Retornar `{ version: currentVersion, ok: false, conflict: true }`
|
|
218
|
+
4. `fs.writeFile(fullPath, content, "utf-8")`
|
|
219
|
+
5. `newVersion = sha256(content)`
|
|
220
|
+
6. Retornar `{ version: newVersion, ok: true }`
|
|
221
|
+
|
|
222
|
+
**Race window**: igual ao Confluence — pequena janela entre `getVersion` e
|
|
223
|
+
`writeFile`. MVP aceita. Se virar dor, usar `proper-lockfile` ou flock.
|
|
224
|
+
|
|
225
|
+
---
|
|
226
|
+
|
|
227
|
+
### `attachFile(itemId, filename, content, mimeType)`
|
|
228
|
+
|
|
229
|
+
**Passos**:
|
|
230
|
+
1. Resolver path do item, achar diretório pai
|
|
231
|
+
2. `attachDir = path.join(dirname, "attachments")`
|
|
232
|
+
3. `mkdirp(attachDir)` se não existe
|
|
233
|
+
4. `fs.writeFile(path.join(attachDir, filename), content)` (bytes)
|
|
234
|
+
5. `mimeType` é ignorado (filesystem deriva da extensão; metadata não persiste)
|
|
235
|
+
|
|
236
|
+
**Implementação opcional no MVP** — FilesystemAdapter pode deixar como
|
|
237
|
+
`NotImplementedError` se o caller nunca chamar. Facilita ter-a versão
|
|
238
|
+
inicial.
|
|
239
|
+
|
|
240
|
+
---
|
|
241
|
+
|
|
242
|
+
## Casos de uso
|
|
243
|
+
|
|
244
|
+
### 1. Backend real — time offline
|
|
245
|
+
|
|
246
|
+
```yaml
|
|
247
|
+
input:
|
|
248
|
+
adapter: filesystem
|
|
249
|
+
config:
|
|
250
|
+
root_path: "./workspace/Input"
|
|
251
|
+
cache:
|
|
252
|
+
local_dir: "./workspace/Input" # input já está local — cache é no-op
|
|
253
|
+
log: ".ai/load-log.md"
|
|
254
|
+
incremental: true
|
|
255
|
+
|
|
256
|
+
output:
|
|
257
|
+
targets:
|
|
258
|
+
- name: local
|
|
259
|
+
adapter: filesystem
|
|
260
|
+
config:
|
|
261
|
+
root_path: "./workspace/Output"
|
|
262
|
+
publishes: [PRD, TRD, SDD, Progresso]
|
|
263
|
+
mode: auto
|
|
264
|
+
conflict_detection: hash
|
|
265
|
+
approval_mechanism: none
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
PM edita arquivos direto em `workspace/Input/`. `/load` vira basicamente
|
|
269
|
+
no-op (ou apenas copia pra pasta canônica se `root_path` for diferente).
|
|
270
|
+
`/publish` escreve em `workspace/Output/`. Aprovação é off-band (Git PR,
|
|
271
|
+
review manual no arquivo, etc.).
|
|
272
|
+
|
|
273
|
+
### 2. Mock natural pra testes E2E
|
|
274
|
+
|
|
275
|
+
```yaml
|
|
276
|
+
input:
|
|
277
|
+
adapter: filesystem
|
|
278
|
+
config:
|
|
279
|
+
root_path: "./tests/fixtures/barbearia/Input"
|
|
280
|
+
|
|
281
|
+
output:
|
|
282
|
+
targets:
|
|
283
|
+
- name: assert
|
|
284
|
+
adapter: filesystem
|
|
285
|
+
config:
|
|
286
|
+
root_path: "./tests/fixtures/barbearia/actual_output"
|
|
287
|
+
publishes: [PRD, TRD, SDD, Progresso]
|
|
288
|
+
mode: auto
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
Teste:
|
|
292
|
+
1. Copia `fixtures/expected_output` pra local
|
|
293
|
+
2. Roda pipeline com este `sfw.config.yml`
|
|
294
|
+
3. `diff fixtures/expected_output fixtures/actual_output` — qualquer
|
|
295
|
+
divergência = teste falhou
|
|
296
|
+
4. Nenhuma lib de mock envolvida
|
|
297
|
+
|
|
298
|
+
---
|
|
299
|
+
|
|
300
|
+
## Segurança — path traversal
|
|
301
|
+
|
|
302
|
+
**Toda** operação que recebe `id` ou `parentId` do caller DEVE validar:
|
|
303
|
+
|
|
304
|
+
```typescript
|
|
305
|
+
const resolved = path.resolve(root_path, untrustedId);
|
|
306
|
+
const relative = path.relative(root_path, resolved);
|
|
307
|
+
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
|
308
|
+
throw new ValidationError({
|
|
309
|
+
field: "itemId",
|
|
310
|
+
message: `path escapa root_path: ${untrustedId}`,
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
Sem isso, um adapter malicioso ou config errada pode ler/escrever em
|
|
316
|
+
`/etc/passwd`, `~/.ssh/`, etc.
|
|
317
|
+
|
|
318
|
+
---
|
|
319
|
+
|
|
320
|
+
## Matriz de erros fs → tipos do SFW
|
|
321
|
+
|
|
322
|
+
| errno Node.js | Tipo lançado | Detalhes |
|
|
323
|
+
|---|---|---|
|
|
324
|
+
| `ENOENT` | `NotFoundError` | `kind: "item"` ou `"container"` conforme contexto |
|
|
325
|
+
| `EACCES` / `EPERM` | `AuthError` | `hint`: path sem permissão |
|
|
326
|
+
| `EEXIST` (em `create`) | `ConflictError` | `kind: "duplicate_title"` |
|
|
327
|
+
| `ENOTDIR` (esperava dir, achou arquivo) | `ValidationError` | |
|
|
328
|
+
| `EISDIR` (esperava arquivo, achou dir) | `ValidationError` | |
|
|
329
|
+
| `EIO`, `ENOSPC` | `TransportError` | `retryable: false` |
|
|
330
|
+
| `EMFILE` (too many open files) | `TransportError` | `retryable: true` |
|
|
331
|
+
| Path fora de `root_path` | `ValidationError` | ver seção de segurança acima |
|
|
332
|
+
|
|
333
|
+
---
|
|
334
|
+
|
|
335
|
+
## O que NÃO está implementado no MVP
|
|
336
|
+
|
|
337
|
+
- **Watch mode** (`fs.watch` + eventos) — P2. Caller re-roda `/load` manual.
|
|
338
|
+
- **Locking** (`proper-lockfile`, flock) — P2. Race window aceita.
|
|
339
|
+
- **Compression** — nada. Arquivos crus.
|
|
340
|
+
- **Git integration** — não lê `git status` / `git log`. Se quiser
|
|
341
|
+
versionamento Git, é outro adapter (`GitAdapter`, P2+).
|
|
342
|
+
- **Binary content em `fetchContent`** — caller deveria usar attachments.
|
|
343
|
+
Adapter aceita binário mas quebra `sha256` de forma desagradável.
|
|
344
|
+
|
|
345
|
+
---
|
|
346
|
+
|
|
347
|
+
## Referências
|
|
348
|
+
|
|
349
|
+
- Interface: `.github/adapters/interface.md`
|
|
350
|
+
- Erros: `.github/adapters/errors.md`
|
|
351
|
+
- Naming: `.github/adapters/naming.md`
|
|
352
|
+
- Registry: `.github/adapters/registry.md`
|
|
353
|
+
- ConfluenceAdapter (comparação): `.github/adapters/confluence.md`
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
# SourceAdapter — Interface
|
|
2
|
+
|
|
3
|
+
> Contrato que **todo adapter** (Confluence, Filesystem, Notion, JIRA, …) deve
|
|
4
|
+
> implementar. As skills `/load`, `/publish`, `/new-project` **nunca** falam com
|
|
5
|
+
> backends direto — elas falam com essa interface.
|
|
6
|
+
>
|
|
7
|
+
> Este arquivo é **especificação**, não código executável. A implementação real
|
|
8
|
+
> vive em `.github/adapters/confluence.md`, `.github/adapters/filesystem.md`, etc.
|
|
9
|
+
> (arquivos de runbook que o agent segue, no mesmo estilo das skills).
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Princípio
|
|
14
|
+
|
|
15
|
+
> **SFW é sobre processo, não sobre ferramenta.**
|
|
16
|
+
> O pipeline é backend-agnóstico. Times que usam Confluence, Notion, SharePoint,
|
|
17
|
+
> JIRA ou filesystem local plugam um adapter e o resto continua idêntico.
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## Interface TypeScript (referência)
|
|
22
|
+
|
|
23
|
+
```typescript
|
|
24
|
+
/**
|
|
25
|
+
* SourceAdapter — contrato de qualquer backend de I/O usado pelo SFW.
|
|
26
|
+
*
|
|
27
|
+
* Todos os métodos são assíncronos (um adapter pode ser rede, disco, MCP).
|
|
28
|
+
* Erros são tipados — ver .github/adapters/errors.md.
|
|
29
|
+
*/
|
|
30
|
+
interface SourceAdapter {
|
|
31
|
+
/**
|
|
32
|
+
* Identificador textual do adapter (ex: "confluence", "filesystem").
|
|
33
|
+
* Usado pelo registry pra resolver a string em sfw.config.yml.
|
|
34
|
+
*/
|
|
35
|
+
readonly name: string;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Valida o objeto de config passado pelo sfw.config.yml.
|
|
39
|
+
* Chamado 1x no load do manifest, antes de qualquer operação.
|
|
40
|
+
* Lança ValidationError se config estiver incompleto/incoerente.
|
|
41
|
+
*/
|
|
42
|
+
validateConfig(config: object): void;
|
|
43
|
+
|
|
44
|
+
// ------------------------------------------------------------------
|
|
45
|
+
// LEITURA — usado por /load e /new-project
|
|
46
|
+
// ------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Lista filhos diretos de um container.
|
|
50
|
+
*
|
|
51
|
+
* @param containerId id opaco do container (page, folder, etc.)
|
|
52
|
+
* O formato é adapter-specific — confluence usa page_id
|
|
53
|
+
* numeric, filesystem usa path absoluto ou relativo.
|
|
54
|
+
* @returns array ordenado (ordem do backend; não há garantia alfabética)
|
|
55
|
+
*/
|
|
56
|
+
listChildren(containerId: string): Promise<Item[]>;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Baixa o conteúdo de um item em formato normalizado.
|
|
60
|
+
*
|
|
61
|
+
* @returns content em markdown (adapter converte se necessário)
|
|
62
|
+
* + metadata livre do backend (pode ser útil pros skills)
|
|
63
|
+
*
|
|
64
|
+
* Importante: o adapter é responsável por normalizar pra markdown.
|
|
65
|
+
* ConfluenceAdapter converte storage format → markdown.
|
|
66
|
+
* FilesystemAdapter lê .md direto; .txt/.sql/.html também retornam
|
|
67
|
+
* como markdown (texto cru embrulhado em code fence).
|
|
68
|
+
*/
|
|
69
|
+
fetchContent(itemId: string): Promise<{
|
|
70
|
+
content: string;
|
|
71
|
+
metadata: Record<string, unknown>;
|
|
72
|
+
}>;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Lista os attachments de um item (imagens, PDFs, planilhas, etc.).
|
|
76
|
+
* Retorna metadata + função download() lazy — não baixa bytes a menos
|
|
77
|
+
* que o skill chame .download() explicitamente.
|
|
78
|
+
*/
|
|
79
|
+
fetchAttachments(itemId: string): Promise<Attachment[]>;
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Lê a versão atual do item (sem baixar o conteúdo inteiro).
|
|
83
|
+
* Usado pra conflict detection proativo e pra detectar drift.
|
|
84
|
+
*
|
|
85
|
+
* Cada adapter decide o que é "version":
|
|
86
|
+
* - Confluence: integer monotônico (ex: "7")
|
|
87
|
+
* - Filesystem: sha256 do conteúdo
|
|
88
|
+
* - Git: commit hash
|
|
89
|
+
*
|
|
90
|
+
* A interface só exige que strings possam ser comparadas por igualdade.
|
|
91
|
+
*/
|
|
92
|
+
getVersion(itemId: string): Promise<string>;
|
|
93
|
+
|
|
94
|
+
// ------------------------------------------------------------------
|
|
95
|
+
// ESCRITA — usado por /publish
|
|
96
|
+
// ------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Cria um novo item sob um parent.
|
|
100
|
+
*
|
|
101
|
+
* @throws ConflictError se já existe item com o mesmo título no parent
|
|
102
|
+
* (ou no espaço, no caso do Confluence — title uniqueness
|
|
103
|
+
* é responsabilidade do caller via naming templates)
|
|
104
|
+
* @returns itemId do novo item + version inicial
|
|
105
|
+
*/
|
|
106
|
+
create(
|
|
107
|
+
parentId: string,
|
|
108
|
+
title: string,
|
|
109
|
+
content: string,
|
|
110
|
+
): Promise<{ itemId: string; version: string }>;
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Atualiza conteúdo de um item existente com conflict detection.
|
|
114
|
+
*
|
|
115
|
+
* @param expectedVersion versão que o caller acredita estar lá.
|
|
116
|
+
* Se o adapter não suporta optimistic lock server-side
|
|
117
|
+
* (ex: Confluence MCP atual), ele faz check client-side:
|
|
118
|
+
* 1. chama getVersion(itemId)
|
|
119
|
+
* 2. se != expectedVersion → retorna { ok: false, conflict: true }
|
|
120
|
+
* 3. caso contrário, faz o update
|
|
121
|
+
* @returns ok=true + nova version, ou ok=false + conflict=true (drift detectado)
|
|
122
|
+
*
|
|
123
|
+
* Nunca sobrescreve cegamente — o caller DEVE tratar conflict=true
|
|
124
|
+
* (re-lendo, fazendo merge, pedindo confirmação, etc.).
|
|
125
|
+
*/
|
|
126
|
+
update(
|
|
127
|
+
itemId: string,
|
|
128
|
+
content: string,
|
|
129
|
+
expectedVersion: string,
|
|
130
|
+
): Promise<{ version: string; ok: boolean; conflict?: boolean }>;
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Anexa um arquivo binário a um item.
|
|
134
|
+
* Opcional — adapters que não suportam attachments lançam NotImplementedError.
|
|
135
|
+
*/
|
|
136
|
+
attachFile?(
|
|
137
|
+
itemId: string,
|
|
138
|
+
filename: string,
|
|
139
|
+
content: Buffer,
|
|
140
|
+
mimeType: string,
|
|
141
|
+
): Promise<void>;
|
|
142
|
+
}
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
---
|
|
146
|
+
|
|
147
|
+
## Tipos auxiliares
|
|
148
|
+
|
|
149
|
+
```typescript
|
|
150
|
+
/**
|
|
151
|
+
* Representa qualquer "nó" na hierarquia do backend.
|
|
152
|
+
* Um Item pode ser uma page do Confluence, um arquivo do filesystem,
|
|
153
|
+
* uma issue do JIRA, uma doc do Notion, etc.
|
|
154
|
+
*/
|
|
155
|
+
interface Item {
|
|
156
|
+
/**
|
|
157
|
+
* ID opaco, adapter-specific. Nunca é interpretado pelas skills.
|
|
158
|
+
* O caller usa como chave em logs e em chamadas subsequentes.
|
|
159
|
+
*/
|
|
160
|
+
id: string;
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Título exibido ao usuário. Para filesystem = nome do arquivo.
|
|
164
|
+
* Para Confluence = page title.
|
|
165
|
+
*/
|
|
166
|
+
title: string;
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Versão atual. Mesma semântica do getVersion().
|
|
170
|
+
*/
|
|
171
|
+
version: string;
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Tipo estrutural. Permite ao /load decidir se desce recursivamente
|
|
175
|
+
* (page/folder) ou se baixa conteúdo (file).
|
|
176
|
+
*/
|
|
177
|
+
type: "page" | "folder" | "file";
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* true se o item pode ter filhos (otimização pra /load não chamar
|
|
181
|
+
* listChildren desnecessariamente em nós folha).
|
|
182
|
+
*/
|
|
183
|
+
hasChildren: boolean;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Metadata de attachment. download() é lazy — só baixa bytes quando chamado.
|
|
188
|
+
*/
|
|
189
|
+
interface Attachment {
|
|
190
|
+
filename: string;
|
|
191
|
+
size: number; // bytes
|
|
192
|
+
mimeType: string; // ex: "image/png", "application/pdf"
|
|
193
|
+
download(): Promise<Buffer>;
|
|
194
|
+
}
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
---
|
|
198
|
+
|
|
199
|
+
## Semântica de version — tabela por adapter
|
|
200
|
+
|
|
201
|
+
| Adapter | Fonte de version | Exemplo | Monotônico? | Conflict detection |
|
|
202
|
+
|---------|------------------|---------|-------------|--------------------|
|
|
203
|
+
| **Confluence** | `version.number` da page | `"7"` | Sim (inteiro++) | Client-side (MCP não aceita `expectedVersion`) |
|
|
204
|
+
| **Filesystem** | `sha256(content)` | `"a3f5…"` | Não (conteúdo-based) | Re-leitura + compare |
|
|
205
|
+
| **Notion** | `last_edited_time` | `"2026-04-11T14:20:00Z"` | Sim (timestamp) | Client-side |
|
|
206
|
+
| **Git** | `commit hash` onde o arquivo foi tocado | `"9fc3d2e"` | Não (DAG) | `git diff` vs HEAD |
|
|
207
|
+
|
|
208
|
+
**Regra**: a interface só garante **igualdade**. Skills não fazem aritmética em
|
|
209
|
+
version. Se um adapter quer ordenação (rollback, histórico), expõe em
|
|
210
|
+
`metadata` via `fetchContent`.
|
|
211
|
+
|
|
212
|
+
---
|
|
213
|
+
|
|
214
|
+
## Contrato de comportamento (regras invioláveis)
|
|
215
|
+
|
|
216
|
+
Qualquer adapter **DEVE** garantir:
|
|
217
|
+
|
|
218
|
+
1. **`update` nunca sobrescreve sem conflict check.**
|
|
219
|
+
Mesmo backends que não suportam optimistic lock server-side precisam
|
|
220
|
+
fazer `getVersion` antes do write. É melhor falhar com `conflict: true`
|
|
221
|
+
do que perder dados do usuário.
|
|
222
|
+
|
|
223
|
+
2. **IDs são opacos pro pipeline.**
|
|
224
|
+
O adapter escolhe o formato (page_id numérico, path string, UUID, etc.).
|
|
225
|
+
Skills persistem o ID em `.ai/load-log.md` / `.ai/publish-log.md` e
|
|
226
|
+
repassam como string — nunca parseiam.
|
|
227
|
+
|
|
228
|
+
3. **Content é sempre markdown na saída e na entrada.**
|
|
229
|
+
Se o backend armazena em outro formato (Confluence storage, Notion blocks),
|
|
230
|
+
o adapter converte nos 2 sentidos. A normalização é lossy — `getVersion`
|
|
231
|
+
é a fonte de verdade pra drift, não diff de bytes do markdown.
|
|
232
|
+
|
|
233
|
+
4. **Erros são tipados.**
|
|
234
|
+
Nada de `throw new Error("deu ruim")` — usar as classes de
|
|
235
|
+
`.github/adapters/errors.md`. Skills decidem como reagir via `catch` tipado.
|
|
236
|
+
|
|
237
|
+
5. **Sem estado global.**
|
|
238
|
+
Adapter é instanciado com config do manifest e não compartilha estado
|
|
239
|
+
entre instâncias. Dois projetos rodando lado a lado devem poder usar
|
|
240
|
+
2 instâncias do mesmo adapter com configs diferentes.
|
|
241
|
+
|
|
242
|
+
6. **Operações idempotentes quando possível.**
|
|
243
|
+
`create` com o mesmo título no mesmo parent → `ConflictError` (não cria
|
|
244
|
+
duplicata). `update` com o mesmo content → ok, nova version igual ou
|
|
245
|
+
adapter detecta no-op.
|
|
246
|
+
|
|
247
|
+
---
|
|
248
|
+
|
|
249
|
+
## Fluxo típico — quem chama o quê
|
|
250
|
+
|
|
251
|
+
```
|
|
252
|
+
/load {scope}
|
|
253
|
+
├── listChildren(input.parent_page_id) ← lista items no Input
|
|
254
|
+
├── busca item por nome == {scope} ← match exato com o arg da skill
|
|
255
|
+
├── listChildren(scope.id) ← desce no scope (filhos)
|
|
256
|
+
├── fetchContent(scope.id) ← conteúdo da página-mãe/arquivo
|
|
257
|
+
├── fetchContent(child.id) para cada filho
|
|
258
|
+
├── fetchAttachments(scope.id) ← se include_attachments=true
|
|
259
|
+
└── (escreve em workspace/Input/{scope}/ localmente)
|
|
260
|
+
|
|
261
|
+
/publish (chamado por /extract, /design, /plan)
|
|
262
|
+
├── applyNaming(output_container, {scope}) ← ex: "out_app_barbearia"
|
|
263
|
+
├── listChildren(output.parent_page_id) ← acha container existente por título
|
|
264
|
+
├── se container não existe:
|
|
265
|
+
│ └── create(output.parent_page_id, containerTitle, seed) → container criado
|
|
266
|
+
├── applyNaming(output_artifact, {scope, type}) ← ex: "app_barbearia - TRD"
|
|
267
|
+
├── listChildren(container.id) ← busca artefato por título
|
|
268
|
+
├── se artefato não existe:
|
|
269
|
+
│ └── create(container.id, artifactTitle, content) → log version
|
|
270
|
+
└── se artefato existe:
|
|
271
|
+
├── getVersion(itemId) ← lê version atual
|
|
272
|
+
├── comparar com publish-log (drift check)
|
|
273
|
+
├── se drift → ConflictError pro usuário
|
|
274
|
+
└── update(itemId, content, expectedVersion) → log nova version
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
---
|
|
278
|
+
|
|
279
|
+
## O que NÃO está nesta interface (intencional)
|
|
280
|
+
|
|
281
|
+
- **Busca full-text.** Se o backend suporta, skills chamam via metadata/config
|
|
282
|
+
específica do adapter, não pela interface. Aumentaria a superfície sem ganho
|
|
283
|
+
pro MVP.
|
|
284
|
+
- **Permissions/ACL.** Adapter assume que as credenciais já têm acesso.
|
|
285
|
+
`AuthError` é o único sinal de permissão negada.
|
|
286
|
+
- **Watching/live updates.** Pull model é suficiente. Push viria depois se
|
|
287
|
+
houver caso real.
|
|
288
|
+
- **Transactions.** Nenhum backend suportado tem transactions entre itens.
|
|
289
|
+
Skills são projetadas pra serem idempotentes e re-executáveis.
|
|
290
|
+
- **Attachments bidirecionais no MVP.** `attachFile` é opcional. Só Confluence
|
|
291
|
+
implementa no MVP. FilesystemAdapter pode implementar trivialmente.
|
|
292
|
+
|
|
293
|
+
---
|
|
294
|
+
|
|
295
|
+
## Referências
|
|
296
|
+
|
|
297
|
+
- Registry + resolução: `.github/adapters/registry.md`
|
|
298
|
+
- Templating de nomes: `.github/adapters/naming.md`
|
|
299
|
+
- Contrato de erros: `.github/adapters/errors.md`
|
|
300
|
+
- Schema do manifest: `sfw.config.yml.example` (raiz do projeto)
|
|
301
|
+
- Decisão arquitetural: `planodetarefas.md §9.9`
|