spec-first-copilot 0.7.0-beta.1 → 0.7.0

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.
Files changed (55) hide show
  1. package/README.md +252 -167
  2. package/bin/cli.js +70 -70
  3. package/lib/init.js +92 -92
  4. package/lib/update.js +132 -132
  5. package/package.json +1 -1
  6. package/templates/.ai/memory/napkin.md +68 -68
  7. package/templates/.github/CHANGELOG.md +560 -533
  8. package/templates/.github/adapters/SETUP.md +314 -314
  9. package/templates/.github/adapters/confluence.md +295 -295
  10. package/templates/.github/adapters/errors.md +234 -234
  11. package/templates/.github/adapters/filesystem.md +353 -353
  12. package/templates/.github/adapters/interface.md +301 -301
  13. package/templates/.github/adapters/naming.md +241 -241
  14. package/templates/.github/adapters/registry.md +244 -244
  15. package/templates/.github/agents/backend-coder.md +215 -215
  16. package/templates/.github/agents/db-coder.md +165 -165
  17. package/templates/.github/agents/doc-writer.md +66 -66
  18. package/templates/.github/agents/frontend-coder.md +222 -222
  19. package/templates/.github/agents/infra-coder.md +341 -341
  20. package/templates/.github/agents/reviewer.md +99 -99
  21. package/templates/.github/agents/security-reviewer.md +153 -153
  22. package/templates/.github/copilot-instructions.md +272 -272
  23. package/templates/.github/instructions/docs.instructions.md +147 -145
  24. package/templates/.github/instructions/sensitive-files.instructions.md +32 -32
  25. package/templates/.github/rules.md +229 -229
  26. package/templates/.github/scripts/bootstrap-confluence.js +289 -289
  27. package/templates/.github/skills/sf-design/SKILL.md +161 -161
  28. package/templates/.github/skills/sf-dev/SKILL.md +204 -204
  29. package/templates/.github/skills/sf-discovery/SKILL.md +415 -415
  30. package/templates/.github/skills/sf-extract/SKILL.md +225 -225
  31. package/templates/.github/skills/sf-load/SKILL.md +296 -296
  32. package/templates/.github/skills/sf-mcp/SKILL.md +386 -386
  33. package/templates/.github/skills/sf-merge-docs/SKILL.md +152 -152
  34. package/templates/.github/skills/sf-plan/SKILL.md +152 -152
  35. package/templates/.github/skills/sf-publish/SKILL.md +144 -144
  36. package/templates/.github/skills/sf-session-finish/SKILL.md +93 -93
  37. package/templates/.github/skills/sf-start/SKILL.md +192 -192
  38. package/templates/.github/templates/estrutura/apiContracts.template.md +160 -159
  39. package/templates/.github/templates/estrutura/architecture.template.md +169 -168
  40. package/templates/.github/templates/estrutura/conventions.template.md +214 -212
  41. package/templates/.github/templates/estrutura/decisions.template.md +107 -107
  42. package/templates/.github/templates/estrutura/domain.template.md +161 -160
  43. package/templates/.github/templates/feature/PRD.template.md +279 -279
  44. package/templates/.github/templates/feature/Progresso.template.md +141 -141
  45. package/templates/.github/templates/feature/TRD.template.md +358 -358
  46. package/templates/.github/templates/feature/context.template.md +89 -89
  47. package/templates/.github/templates/feature/extract-log.template.md +49 -49
  48. package/templates/.github/templates/feature/projetos.template.yaml +79 -79
  49. package/templates/.github/templates/global/progresso_global.template.md +59 -57
  50. package/templates/.github/templates/specs/brief.template.md +66 -66
  51. package/templates/.github/templates/specs/contracts.template.md +147 -147
  52. package/templates/.github/templates/specs/scenarios.template.md +125 -125
  53. package/templates/.github/templates/specs/tasks.template.md +65 -65
  54. package/templates/_gitignore +35 -35
  55. package/templates/sfw.config.yml.example +147 -147
@@ -1,353 +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 - PRD"`). 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 - PRD"` → arquivo `app_barbearia - PRD.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, Progresso]
263
- mode: auto
264
- conflict_detection: hash
265
- approval_mechanism: none
266
- ```
267
-
268
- PM edita arquivos direto em `workspace/Input/`. `/sf-load` vira basicamente
269
- no-op (ou apenas copia pra pasta canônica se `root_path` for diferente).
270
- `/sf-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, 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 `/sf-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`
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 - PRD"`). 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 - PRD"` → arquivo `app_barbearia - PRD.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, Progresso]
263
+ mode: auto
264
+ conflict_detection: hash
265
+ approval_mechanism: none
266
+ ```
267
+
268
+ PM edita arquivos direto em `workspace/Input/`. `/sf-load` vira basicamente
269
+ no-op (ou apenas copia pra pasta canônica se `root_path` for diferente).
270
+ `/sf-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, 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 `/sf-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`