skillflow-py 1.0.0__tar.gz

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 (83) hide show
  1. skillflow_py-1.0.0/LICENSE +21 -0
  2. skillflow_py-1.0.0/PKG-INFO +364 -0
  3. skillflow_py-1.0.0/README.md +342 -0
  4. skillflow_py-1.0.0/pyproject.toml +46 -0
  5. skillflow_py-1.0.0/setup.cfg +4 -0
  6. skillflow_py-1.0.0/src/skillflow/__init__.py +97 -0
  7. skillflow_py-1.0.0/src/skillflow/agent_registry.py +126 -0
  8. skillflow_py-1.0.0/src/skillflow/context.py +205 -0
  9. skillflow_py-1.0.0/src/skillflow/core.py +2037 -0
  10. skillflow_py-1.0.0/src/skillflow/exceptions.py +55 -0
  11. skillflow_py-1.0.0/src/skillflow/graph.py +754 -0
  12. skillflow_py-1.0.0/src/skillflow/notifications.py +162 -0
  13. skillflow_py-1.0.0/src/skillflow/outbox.py +33 -0
  14. skillflow_py-1.0.0/src/skillflow/plugins/linter/__init__.py +189 -0
  15. skillflow_py-1.0.0/src/skillflow/plugins/linter/cli.py +56 -0
  16. skillflow_py-1.0.0/src/skillflow/plugins/linter/tools/skillflow_lint/impl.py +19 -0
  17. skillflow_py-1.0.0/src/skillflow/plugins/linter/tools/skillflow_lint/tool.yaml +15 -0
  18. skillflow_py-1.0.0/src/skillflow/plugins/skill_converter/AGENT.md +172 -0
  19. skillflow_py-1.0.0/src/skillflow/plugins/skill_converter/__init__.py +18 -0
  20. skillflow_py-1.0.0/src/skillflow/plugins/skill_converter/converter.py +181 -0
  21. skillflow_py-1.0.0/src/skillflow/plugins/skill_converter/prompts/analyze_skill.md +32 -0
  22. skillflow_py-1.0.0/src/skillflow/plugins/skill_converter/prompts/design_graph.md +47 -0
  23. skillflow_py-1.0.0/src/skillflow/plugins/skill_converter/prompts/fix_issues.md +23 -0
  24. skillflow_py-1.0.0/src/skillflow/plugins/skill_converter/skill_converter.yaml +80 -0
  25. skillflow_py-1.0.0/src/skillflow/plugins/skill_runner/AGENT.md +161 -0
  26. skillflow_py-1.0.0/src/skillflow/plugins/skill_runner/__init__.py +19 -0
  27. skillflow_py-1.0.0/src/skillflow/plugins/skill_runner/runner.py +324 -0
  28. skillflow_py-1.0.0/src/skillflow/recovery.py +78 -0
  29. skillflow_py-1.0.0/src/skillflow/schema.py +137 -0
  30. skillflow_py-1.0.0/src/skillflow/step_validation.py +114 -0
  31. skillflow_py-1.0.0/src/skillflow/tool_loader.py +123 -0
  32. skillflow_py-1.0.0/src/skillflow/tools/__init__.py +1 -0
  33. skillflow_py-1.0.0/src/skillflow/tools/dir_tree/impl.py +32 -0
  34. skillflow_py-1.0.0/src/skillflow/tools/dir_tree/tool.yaml +10 -0
  35. skillflow_py-1.0.0/src/skillflow/tools/draft_commit/impl.py +63 -0
  36. skillflow_py-1.0.0/src/skillflow/tools/draft_commit/tool.yaml +7 -0
  37. skillflow_py-1.0.0/src/skillflow/tools/file_exists/impl.py +23 -0
  38. skillflow_py-1.0.0/src/skillflow/tools/file_exists/tool.yaml +11 -0
  39. skillflow_py-1.0.0/src/skillflow/tools/json_schema/impl.py +47 -0
  40. skillflow_py-1.0.0/src/skillflow/tools/json_schema/tool.yaml +15 -0
  41. skillflow_py-1.0.0/src/skillflow/tools/list_tree/impl.py +41 -0
  42. skillflow_py-1.0.0/src/skillflow/tools/list_tree/tool.yaml +15 -0
  43. skillflow_py-1.0.0/src/skillflow/tools/notify/impl.py +52 -0
  44. skillflow_py-1.0.0/src/skillflow/tools/notify/tool.yaml +15 -0
  45. skillflow_py-1.0.0/src/skillflow/tools/py_compile/impl.py +28 -0
  46. skillflow_py-1.0.0/src/skillflow/tools/py_compile/tool.yaml +9 -0
  47. skillflow_py-1.0.0/src/skillflow/tools/pytest/impl.py +25 -0
  48. skillflow_py-1.0.0/src/skillflow/tools/pytest/tool.yaml +9 -0
  49. skillflow_py-1.0.0/src/skillflow/tools/read_file/impl.py +26 -0
  50. skillflow_py-1.0.0/src/skillflow/tools/read_file/tool.yaml +19 -0
  51. skillflow_py-1.0.0/src/skillflow/tools/repo_apply/impl.py +53 -0
  52. skillflow_py-1.0.0/src/skillflow/tools/repo_apply/tool.yaml +10 -0
  53. skillflow_py-1.0.0/src/skillflow/tools/repo_validate/impl.py +68 -0
  54. skillflow_py-1.0.0/src/skillflow/tools/repo_validate/tool.yaml +13 -0
  55. skillflow_py-1.0.0/src/skillflow/tools/syntax_lint/impl.py +51 -0
  56. skillflow_py-1.0.0/src/skillflow/tools/syntax_lint/tool.yaml +10 -0
  57. skillflow_py-1.0.0/src/skillflow/tools/write/impl.py +24 -0
  58. skillflow_py-1.0.0/src/skillflow/tools/write/tool.yaml +12 -0
  59. skillflow_py-1.0.0/src/skillflow/validation.py +72 -0
  60. skillflow_py-1.0.0/src/skillflow/workspace.py +192 -0
  61. skillflow_py-1.0.0/src/skillflow/write_tools.py +240 -0
  62. skillflow_py-1.0.0/src/skillflow_py.egg-info/PKG-INFO +364 -0
  63. skillflow_py-1.0.0/src/skillflow_py.egg-info/SOURCES.txt +81 -0
  64. skillflow_py-1.0.0/src/skillflow_py.egg-info/dependency_links.txt +1 -0
  65. skillflow_py-1.0.0/src/skillflow_py.egg-info/requires.txt +7 -0
  66. skillflow_py-1.0.0/src/skillflow_py.egg-info/top_level.txt +2 -0
  67. skillflow_py-1.0.0/tests/test_context.py +124 -0
  68. skillflow_py-1.0.0/tests/test_core.py +1090 -0
  69. skillflow_py-1.0.0/tests/test_exceptions.py +51 -0
  70. skillflow_py-1.0.0/tests/test_graph.py +563 -0
  71. skillflow_py-1.0.0/tests/test_integration.py +365 -0
  72. skillflow_py-1.0.0/tests/test_integration_configs.py +980 -0
  73. skillflow_py-1.0.0/tests/test_migration_regression.py +163 -0
  74. skillflow_py-1.0.0/tests/test_notifications.py +180 -0
  75. skillflow_py-1.0.0/tests/test_outbox.py +67 -0
  76. skillflow_py-1.0.0/tests/test_recovery.py +100 -0
  77. skillflow_py-1.0.0/tests/test_regression.py +230 -0
  78. skillflow_py-1.0.0/tests/test_step_validation.py +132 -0
  79. skillflow_py-1.0.0/tests/test_tool_loader.py +192 -0
  80. skillflow_py-1.0.0/tests/test_tools.py +308 -0
  81. skillflow_py-1.0.0/tests/test_validation.py +66 -0
  82. skillflow_py-1.0.0/tests/test_workspace.py +87 -0
  83. skillflow_py-1.0.0/tests/test_write_tools.py +111 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Lin Xuhao
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,364 @@
1
+ Metadata-Version: 2.4
2
+ Name: skillflow-py
3
+ Version: 1.0.0
4
+ Summary: Config-agnostic LLM pipeline graph executor — turn skills into flows
5
+ Author: Skillflow contributors
6
+ License-Expression: MIT
7
+ Keywords: llm,pipeline,graph,orchestrator,agent
8
+ Classifier: Development Status :: 4 - Beta
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: Programming Language :: Python :: 3.12
11
+ Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
12
+ Requires-Python: >=3.12
13
+ Description-Content-Type: text/markdown
14
+ License-File: LICENSE
15
+ Requires-Dist: pyyaml>=6.0
16
+ Requires-Dist: ruff>=0.4
17
+ Provides-Extra: dev
18
+ Requires-Dist: pytest>=8.0; extra == "dev"
19
+ Requires-Dist: pytest-asyncio>=0.24; extra == "dev"
20
+ Requires-Dist: pytest-mock>=3.15; extra == "dev"
21
+ Dynamic: license-file
22
+
23
+ # Skillflow
24
+
25
+ Config-agnostic LLM pipeline graph executor. Define multi-agent pipelines as YAML DAGs — skillflow handles traversal, tool execution, checkpoints, recovery, and event streaming on SQLite.
26
+
27
+ ## Install
28
+
29
+ ```bash
30
+ pip install skillflow-py # PyPI
31
+ pip install -e ~/skillflow # from repo (editable)
32
+ ```
33
+
34
+ Or clone and use the install script, which also registers CLI commands:
35
+
36
+ ```bash
37
+ git clone https://github.com/your-org/skillflow.git
38
+ bash skillflow/scripts/install.sh
39
+ ```
40
+
41
+ CLI commands registered in `~/.local/bin/`:
42
+
43
+ | Command | Description |
44
+ |---------|-------------|
45
+ | `skillflow-lint` | Validate pipeline YAML files (one-shot) |
46
+ | `skillflow-run` | Run a pipeline interactively |
47
+ | `skillflow-convert` | Convert a skill description → pipeline YAML |
48
+
49
+ ```bash
50
+ skillflow-lint configs/*.yaml # one-shot validation
51
+ skillflow-run pipeline.yaml # interactive (human or agent drives)
52
+ skillflow-convert my_skill.md -o pipeline.yaml
53
+ ```
54
+
55
+ ### PyPI publish
56
+
57
+ ```bash
58
+ pip install build twine
59
+ python3 -m build
60
+ twine upload dist/*
61
+ ```
62
+
63
+ ## Getting Started
64
+
65
+ Skillflow runs pipelines in two modes.
66
+
67
+ ### Framework Mode
68
+
69
+ Skillflow is embedded in a host application. The host drives the loop — skillflow handles traversal, tool execution, and state. The host only executes agent steps via `StepRunner`.
70
+
71
+ ```python
72
+ from skillflow import SkillFlow, PipelineGraph, StepResult
73
+
74
+ graph = PipelineGraph.from_yaml("tests/fixtures/minimal_1step.yaml")
75
+
76
+ sf = SkillFlow(":memory:")
77
+ sf.register_graph(graph)
78
+ sf.register_agent_config("echo_agent", model="host")
79
+
80
+ run_id = sf.create_run("minimal_1step")
81
+ sf.start_run(run_id)
82
+
83
+ while True:
84
+ sf.advance_run(run_id)
85
+ claimed = sf.claim_next_step(run_id)
86
+ if claimed is None:
87
+ break # completed or paused
88
+ # Host StepRunner executes the agent step here
89
+ sf.confirm_step(claimed.token, StepResult(outputs={}, flags={}))
90
+ ```
91
+
92
+ In framework mode, **all tools auto-execute** inline — skillflow runs native tools and custom tools without involving the host agent.
93
+
94
+ Config reference: `tests/fixtures/minimal_1step.yaml`.
95
+
96
+ ### Runner Mode
97
+
98
+ Skillflow is driven interactively via `SkillTool`. The pipeline exposes steps as instructions — a human or LLM agent calls `action="next"` / `"submit"` / `"approve"` / `"reject"`.
99
+
100
+ ```python
101
+ from skillflow import SkillFlow, PipelineGraph
102
+ from skillflow.plugins.skill_runner import SkillTool
103
+
104
+ graph = PipelineGraph.from_yaml("tests/fixtures/skill_review.yaml")
105
+ sf = SkillFlow(":memory:", delegate_tools_to_agent=True)
106
+ sf.register_graph(graph)
107
+ sf.register_agent_config("review_analyst", model="host")
108
+ # ... register other agent configs referenced by the graph
109
+
110
+ tool = SkillTool(sf, "skill_review")
111
+ resp = tool(action="next")
112
+
113
+ while resp.status == "in_progress":
114
+ print(resp.instruction)
115
+ # Agent does work, produces output...
116
+ resp = tool(action="submit", result={"findings": {...}})
117
+ # resp.status == "completed"
118
+ ```
119
+
120
+ In runner mode, **native tools auto-execute** but **custom and unknown tools are delegated** to the agent (via `resp.tool_name` / `resp.tool_params`). Use `skillflow-run` or `skillflow-convert` to drive a pipeline interactively.
121
+
122
+ ## Node Types
123
+
124
+ | Type | Description |
125
+ |------|-------------|
126
+ | `agent` | LLM step — host app executes via `StepRunner` protocol |
127
+ | `tool` | Auto-executed by skillflow (native), or delegated to agent in runner mode (custom) |
128
+ | `gate` | Auto-resolved using match conditions against step output flags |
129
+ | `loop` | Iterates over a JSON list from a workspace file, instantiating sub-steps per item |
130
+
131
+ ## Transition Matching
132
+
133
+ Five match strategies. See `tests/fixtures/dpe_full.yaml` for a complete pipeline using all of them:
134
+
135
+ ```yaml
136
+ match: { field: "passed", value: true } # step output flags
137
+ match: { from_file: "review_verdict.json", field: "passed", value: true } # output file
138
+ match: { from: "checkpoint", value: "approved" } # checkpoint routing
139
+ match: { _error: true } # error handler
140
+ # (no match key) # always match
141
+ ```
142
+
143
+ ## Context Injection
144
+
145
+ ```yaml
146
+ context:
147
+ - source: { step: "1" }
148
+ - source: { step: "2", mode: "interfaces" }
149
+ - source: { config: "meta", output: "brief.md" }
150
+ - source: { tool: "dir_tree" }
151
+ ```
152
+
153
+ ## Checkpoints
154
+
155
+ Agent steps can pause for human approval (`tests/fixtures/checkpoint_cycle.yaml`):
156
+
157
+ ```python
158
+ sf.reject_checkpoint(run_id, "draft", "Add more detail to the analysis")
159
+ ```
160
+
161
+ ## Output Validation
162
+
163
+ Steps declare validation specs auto-executed by skillflow. See `tests/fixtures/skill_review.yaml` for inline JSON Schema validation, or `tests/fixtures/lifecycle_hooks.yaml` for syntax_lint + py_compile validators.
164
+
165
+ Available validators: `json_schema`, `syntax_lint`, `py_compile`, `pytest`, `file_exists`.
166
+
167
+ ## Lifecycle Hooks
168
+
169
+ Steps with `output.mode: "write"` can trigger deliver and post-deliver hooks. See `tests/fixtures/lifecycle_hooks.yaml`:
170
+
171
+ ```yaml
172
+ lifecycle:
173
+ on_deliver:
174
+ tool: "repo_apply"
175
+ params:
176
+ source_dir: "$STEP_DIR"
177
+ on_failure: "retry"
178
+ max_retries: 2
179
+ after_deliver:
180
+ - tool: "syntax_lint"
181
+ files: ["*.py"]
182
+ ```
183
+
184
+ ## Error Handling
185
+
186
+ Steps declare `max_retries` and an `_error` transition. See `tests/fixtures/error_handler.yaml`.
187
+
188
+ ## Feedback Loopback
189
+
190
+ Tool failures can inject output into the next step's inputs (`feedback: true`). See `plugins/skill_converter/skill_converter.yaml` — the `validate_design` step feeds lint errors into `fix_issues`.
191
+
192
+ ## End Conditions
193
+
194
+ Four termination strategies, combined with `and`/`or`. See `tests/fixtures/end_conditions.yaml` and `tests/fixtures/dpe_full.yaml`:
195
+
196
+ ```yaml
197
+ end_conditions:
198
+ combinator: or
199
+ conditions:
200
+ - type: node_reached
201
+ node: "5_review"
202
+ result: "completed"
203
+ - type: max_total_steps
204
+ limit: 200
205
+ - type: max_run_duration_seconds
206
+ limit: 3600
207
+ - type: flag_match
208
+ flag: { fatal_error: true }
209
+ ```
210
+
211
+ ## Stale Claim Recovery
212
+
213
+ Built into `advance_run`. Claims older than `stale_threshold_seconds` (default 300) are auto-reset:
214
+
215
+ ```python
216
+ sf = SkillFlow("pipeline.db", stale_threshold_seconds=300)
217
+ ```
218
+
219
+ ## Event Streaming
220
+
221
+ All state transitions are written to `skillflow_outbox`. Poll for real-time notifications:
222
+
223
+ ```python
224
+ events = sf.drain_outbox(batch_size=50)
225
+ for event in events:
226
+ print(event.event_type, event.payload)
227
+ sf.ack_outbox([e.id for e in events])
228
+ ```
229
+
230
+ In-process subscribers via `NotificationBus`:
231
+
232
+ ```python
233
+ from skillflow import NotificationBus
234
+
235
+ bus = NotificationBus()
236
+ bus.subscribe("step_completed", lambda n: print(n.payload))
237
+ sf = SkillFlow(":memory:", notification_bus=bus)
238
+ ```
239
+
240
+ ## Tools
241
+
242
+ ### Native (13 built-in)
243
+
244
+ | Tool | Description |
245
+ |------|-------------|
246
+ | `read_file` | Read a file with line numbers |
247
+ | `write` | Write content to workspace |
248
+ | `list_tree` | List directory structure |
249
+ | `dir_tree` | Context tree for prompt injection |
250
+ | `json_schema` | Validate JSON against inline schema |
251
+ | `syntax_lint` | Syntax check via ruff |
252
+ | `py_compile` | Python bytecode compile |
253
+ | `pytest` | Run pytest on test files |
254
+ | `repo_apply` | Copy files to repo + git commit |
255
+ | `repo_validate` | Multi-tool repo validation |
256
+ | `draft_commit` | Move draft files to final dir + commit |
257
+ | `file_exists` | Check files matching glob patterns |
258
+ | `notify` | Send user-visible notifications |
259
+
260
+ ### Custom tools
261
+
262
+ Host apps add tool directories. Each tool: `{name}/tool.yaml` + `{name}/impl.py`. Function name must match directory name.
263
+
264
+ ```python
265
+ from skillflow.tool_loader import ToolLoader
266
+
267
+ loader = ToolLoader()
268
+ loader.add_tools_dir("my_app/tools")
269
+ sf = SkillFlow(":memory:", tool_loader=loader)
270
+ ```
271
+
272
+ ## Use Cases
273
+
274
+ ### 1. Framework mode — embed skillflow in your app
275
+
276
+ Use skillflow as a library. Read the [Getting Started](#getting-started) section above and the fixture examples in `tests/fixtures/`.
277
+
278
+ ```python
279
+ from skillflow import SkillFlow, PipelineGraph
280
+ graph = PipelineGraph.from_yaml("my_pipeline.yaml")
281
+ sf = SkillFlow(":memory:")
282
+ sf.register_graph(graph)
283
+ # ... drive the loop with claim_next_step / confirm_step
284
+ ```
285
+
286
+ ### 2. Agent mode — convert skills to pipelines
287
+
288
+ LLM agents use the **converter** + **runner** plugins to turn skill descriptions into skillflow pipelines. The agent calls a tool named `run_skill` repeatedly — the runner tells it what to do at each step.
289
+
290
+ ```python
291
+ from skillflow.plugins.skill_converter import setup_converter
292
+ from skillflow.plugins.skill_runner import load_agent_guide
293
+
294
+ # Give the agent its user manual
295
+ system_prompt = load_agent_guide() # ← includes protocol, response format, rules
296
+
297
+ tool = setup_converter(sf, description_file="my_skill.md")
298
+ resp = tool(action="next")
299
+ # ... agent loops: submit result → get next instruction → ...
300
+ # resp.status == "completed" → pipeline YAML ready
301
+ ```
302
+
303
+ Agent manuals are shipped in the package:
304
+
305
+ | Plugin | Manual | How to load |
306
+ |--------|--------|-------------|
307
+ | `skill_runner` | Agent protocol — actions, SkillResponse format, rules | `load_agent_guide()` from `skillflow.plugins.skill_runner` |
308
+ | `skill_converter` | Step-by-step guide — analyze → design → lint → fix | `load_agent_guide()` from `skillflow.plugins.skill_converter` |
309
+
310
+ Inject the runner manual into the agent's system prompt. Inject the converter manual when the agent is asked to convert a skill.
311
+
312
+ CLI tools for manual use:
313
+
314
+ ```bash
315
+ skillflow-lint pipeline.yaml # one-shot config validation
316
+ skillflow-run pipeline.yaml # drive a pipeline interactively
317
+ skillflow-convert my_skill.md -o out.yaml # convert a skill description
318
+ ```
319
+
320
+ ### Linter (`skillflow.plugins.linter`)
321
+
322
+ Framework utility. Validates pipeline YAML — used as a skillflow tool (`skillflow_lint`) inside the converter's feedback loop, or standalone:
323
+
324
+ ```bash
325
+ skillflow-lint tests/fixtures/skill_review.yaml
326
+ skillflow-lint configs/*.yaml
327
+ ```
328
+
329
+ ## Package
330
+
331
+ ```
332
+ src/skillflow/
333
+ ├── core.py # SkillFlow orchestrator (create/claim/confirm/advance)
334
+ ├── graph.py # PipelineGraph, StepNode, Transition, GraphResolver
335
+ ├── tool_loader.py # Dynamic tool schema + implementation loading
336
+ ├── context.py # ContextResolver: cross-config, step, tool sources
337
+ ├── step_validation.py # StepValidator: multi-tool output validation
338
+ ├── write_tools.py # Constrained write tool generation from output.fixed
339
+ ├── workspace.py # Per-step atomic staging directories
340
+ ├── validation.py # Optional external-schema output validation
341
+ ├── recovery.py # Stale claim recovery
342
+ ├── schema.py # SQLite DDL + migrations
343
+ ├── exceptions.py # SkillFlowError hierarchy
344
+ ├── outbox.py # OutboxConsumer for event polling
345
+ ├── notifications.py # NotificationBus for in-process subscribers
346
+ ├── agent_registry.py # Agent config registry + schema resolution
347
+ ├── plugins/ # Built-in plugins
348
+ │ ├── linter/ # Config validator + skillflow_lint tool
349
+ │ ├── skill_runner/ # SkillTool — interactive pipeline facade
350
+ │ └── skill_converter/ # Skill description → pipeline YAML
351
+ └── tools/ # Native tools (13)
352
+ ├── read_file/ ├── write/ ├── list_tree/
353
+ ├── dir_tree/ ├── json_schema/ ├── syntax_lint/
354
+ ├── py_compile/ ├── pytest/ ├── repo_apply/
355
+ ├── repo_validate/ ├── draft_commit/ ├── file_exists/
356
+ └── notify/
357
+ ```
358
+
359
+ ## Tests
360
+
361
+ ```bash
362
+ pytest tests/ -v # 306 tests
363
+ pytest plugins/ -v # 21 plugin tests
364
+ ```