spec-first-copilot 0.5.0-beta.2 → 0.5.0-beta.3

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.
@@ -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`