skill-dag 0.1.0__py3-none-any.whl
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.
- skill_dag-0.1.0.dist-info/METADATA +209 -0
- skill_dag-0.1.0.dist-info/RECORD +12 -0
- skill_dag-0.1.0.dist-info/WHEEL +5 -0
- skill_dag-0.1.0.dist-info/entry_points.txt +2 -0
- skill_dag-0.1.0.dist-info/licenses/LICENSE +21 -0
- skill_dag-0.1.0.dist-info/top_level.txt +1 -0
- skillflow/__init__.py +6 -0
- skillflow/__main__.py +4 -0
- skillflow/cli.py +129 -0
- skillflow/dag.py +224 -0
- skillflow/db.py +42 -0
- skillflow/mcp_server.py +204 -0
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: skill-dag
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A minimal SQLite-backed DAG runner: nodes, edges, run, status.
|
|
5
|
+
Author: NatesVibeCode
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/NatesVibeCode/skillflow
|
|
8
|
+
Project-URL: Repository, https://github.com/NatesVibeCode/skillflow
|
|
9
|
+
Keywords: dag,mcp,agents,skills
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Provides-Extra: mcp
|
|
21
|
+
Requires-Dist: mcp<2,>=1.0; extra == "mcp"
|
|
22
|
+
Dynamic: license-file
|
|
23
|
+
|
|
24
|
+
# Skillflow
|
|
25
|
+
|
|
26
|
+
A minimal SQLite-backed DAG runner. Define nodes (shell commands) and
|
|
27
|
+
dependency edges, then run the graph in topological order. Every run and
|
|
28
|
+
per-node result is recorded in a plain SQLite file — no servers, no
|
|
29
|
+
background services, no external dependencies beyond the Python standard
|
|
30
|
+
library.
|
|
31
|
+
|
|
32
|
+
## Install
|
|
33
|
+
Requires Python 3.10+.
|
|
34
|
+
|
|
35
|
+
```sh
|
|
36
|
+
pip install skill-dag
|
|
37
|
+
# with the MCP server:
|
|
38
|
+
pip install "skill-dag[mcp]"
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Or from a checkout (needed for the panel skills and `panel/run.sh`):
|
|
42
|
+
|
|
43
|
+
```sh
|
|
44
|
+
pip install .
|
|
45
|
+
pip install ".[mcp]"
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Use
|
|
49
|
+
|
|
50
|
+
```sh
|
|
51
|
+
skillflow init
|
|
52
|
+
skillflow add-node fetch --cmd "curl -s https://example.com -o page.html"
|
|
53
|
+
skillflow add-node parse --cmd "python parse.py page.html"
|
|
54
|
+
skillflow add-edge fetch parse
|
|
55
|
+
skillflow run
|
|
56
|
+
skillflow status
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Options:
|
|
60
|
+
|
|
61
|
+
- `--db PATH` (before the subcommand, e.g. `skillflow --db demo.db run`)
|
|
62
|
+
or `SKILLFLOW_DB` selects the SQLite file. Defaults to `./skillflow.db`.
|
|
63
|
+
- `show` prints the DAG as JSON.
|
|
64
|
+
- `status [--run ID] [--json]` shows the latest run (or a given one).
|
|
65
|
+
- `run` exits nonzero if any node fails; downstream nodes do not execute
|
|
66
|
+
after a failure.
|
|
67
|
+
|
|
68
|
+
## How it works
|
|
69
|
+
|
|
70
|
+
Four tables: `nodes`, `edges`, `runs`, `node_results`. Execution order is
|
|
71
|
+
computed with Kahn's algorithm; cycles are rejected when an edge is added
|
|
72
|
+
(and re-checked at run time). Node output (stdout + stderr), exit codes,
|
|
73
|
+
and timestamps are stored per run.
|
|
74
|
+
|
|
75
|
+
## Panel skills
|
|
76
|
+
|
|
77
|
+
Debate, brainstorm, review, and reframe run in the active conversation. Their
|
|
78
|
+
prose methods do the intellectual work; a local skillflow DAG enforces the order
|
|
79
|
+
and makes the session pause before moving on. Nothing launches another model.
|
|
80
|
+
|
|
81
|
+
- **Debate:** ground → activate → crossfire → continue/finish → final.
|
|
82
|
+
- **Brainstorm:** ground → activate → divergent field → activate → develop field →
|
|
83
|
+
final.
|
|
84
|
+
- **Review:** ground → activate → intent → activate → evidence/deltas → final.
|
|
85
|
+
- **Reframe:** ground → activate → stronger-shape field → lineup → continue/finish
|
|
86
|
+
→ final. Generation and lineup have separate pauses.
|
|
87
|
+
|
|
88
|
+
The session selects and activates lenses from the roster, shows the prose in the
|
|
89
|
+
conversation, and authors every result. The DAG does not select the room, extract
|
|
90
|
+
semantic tensions, decide whether an argument is good, or synthesize the answer.
|
|
91
|
+
When prior decisions, corrections, rejections, or failures matter, the active
|
|
92
|
+
session uses bounded semantic work-history recall during grounding. The current
|
|
93
|
+
instruction and live sources govern. `rewind` archives affected artifacts and
|
|
94
|
+
invalidates their dependent checkpoints when later evidence changes an earlier
|
|
95
|
+
phase. A debate/reframe refusal still requires an honest final answer.
|
|
96
|
+
|
|
97
|
+
```sh
|
|
98
|
+
bash panel/run.sh debate "ship it friday" 3 /tmp/my-debate
|
|
99
|
+
# PAUSE ground: do the grounding in the current conversation; write ground.md.
|
|
100
|
+
bash panel/run.sh resume /tmp/my-debate
|
|
101
|
+
# PAUSE activate-1: perform that phase, save it, then resume again.
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Each new checkpoint returns control before accepting its artifact, even if a file
|
|
105
|
+
was prefilled. Exit 1 with `PAUSE` means the current session should do the named
|
|
106
|
+
work and resume, not ask a human to fill a file or approve the next phase. Do not
|
|
107
|
+
batch-author future phases. `final.md` is session-authored, never a machine
|
|
108
|
+
concatenation. The receipts prove sequence, not quality. The final output is the
|
|
109
|
+
complete useful room, not a checklist of its conclusions.
|
|
110
|
+
|
|
111
|
+
Brainstorm/review have two mandatory phases. Debate/reframe accept a 1–8 round
|
|
112
|
+
ceiling (default 3); the session decides whether further rounds are worthwhile.
|
|
113
|
+
Full method and recovery instructions are in
|
|
114
|
+
[the shared protocol](skills/_shared/running-on-skillflow.md).
|
|
115
|
+
|
|
116
|
+
Install the four skills **with their shared prose and launcher**:
|
|
117
|
+
|
|
118
|
+
```sh
|
|
119
|
+
python3 scripts/install_panel_skills.py --skills-dir ~/.codex/skills
|
|
120
|
+
# Another local skill root can be passed instead, or with another --skills-dir.
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
The installed launcher resolves the checkout from a local location file, so it
|
|
124
|
+
works from any repo. Reinstall if the checkout moves. No harness settings, other
|
|
125
|
+
skills, or authentication are modified. Copying a lone SKILL.md is insufficient.
|
|
126
|
+
|
|
127
|
+
Existing session DBs retain their graphs and can still use `skillflow run` from
|
|
128
|
+
that session directory. `add-skill` remains a separate authoring workflow on its
|
|
129
|
+
legacy graph. The selector and seed tools remain available for explicit standalone
|
|
130
|
+
use and old sessions; the four conversational skills no longer depend on them.
|
|
131
|
+
|
|
132
|
+
## MCP server
|
|
133
|
+
|
|
134
|
+
Everything above is also an MCP server (stdio). Eight tools: `skillflow_init`,
|
|
135
|
+
`skillflow_add_node`, `skillflow_add_edge`, `skillflow_show`, `skillflow_run`,
|
|
136
|
+
`skillflow_status`, `panel_seed`, `panel_select_room`.
|
|
137
|
+
|
|
138
|
+
```sh
|
|
139
|
+
pip install "skill-dag[mcp]"
|
|
140
|
+
python -m skillflow.mcp_server
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Example client config (stdio):
|
|
144
|
+
|
|
145
|
+
```json
|
|
146
|
+
{
|
|
147
|
+
"mcpServers": {
|
|
148
|
+
"skillflow": {
|
|
149
|
+
"command": "python3",
|
|
150
|
+
"args": ["-m", "skillflow.mcp_server"],
|
|
151
|
+
"cwd": "/path/to/skillflow"
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Run the server from a repo checkout: the panel tools need `panel/` next to
|
|
158
|
+
the engine, and pip installs ship the engine only. (The six `skillflow_*`
|
|
159
|
+
tools work fine from an installed copy.)
|
|
160
|
+
|
|
161
|
+
Note: panel round nodes never prompt. A round with no response stops the run
|
|
162
|
+
at that stage boundary — a `run` containing an unwritten round stops there,
|
|
163
|
+
by design, and resumes when the response is written.
|
|
164
|
+
|
|
165
|
+
Trust boundary: this server executes arbitrary shell commands from the DAGs
|
|
166
|
+
you define (`skillflow_run` is annotated destructive for exactly that
|
|
167
|
+
reason). Run it locally, for your own agents only — do not expose it to
|
|
168
|
+
untrusted clients or networks.
|
|
169
|
+
|
|
170
|
+
### Client wiring: Codex + Muse
|
|
171
|
+
|
|
172
|
+
Codex (`~/.codex/config.toml`, TOML):
|
|
173
|
+
|
|
174
|
+
```toml
|
|
175
|
+
[mcp_servers.skillflow]
|
|
176
|
+
command = "python3"
|
|
177
|
+
args = ["-m", "skillflow.mcp_server"]
|
|
178
|
+
cwd = "/path/to/skillflow"
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
Muse (`~/.config/muse/settings.json`, JSON under `mcpServers`):
|
|
182
|
+
|
|
183
|
+
```json
|
|
184
|
+
{
|
|
185
|
+
"mcpServers": {
|
|
186
|
+
"skillflow": {
|
|
187
|
+
"command": "python3",
|
|
188
|
+
"args": ["-m", "skillflow.mcp_server"],
|
|
189
|
+
"cwd": "/path/to/skillflow"
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
Point `cwd` at a repo checkout (panel tools need `panel/` beside the
|
|
196
|
+
engine), or `pip install` the package and drop `cwd` for engine-only use.
|
|
197
|
+
Muse documents streamable-HTTP entries; the stdio entry above is confirmed
|
|
198
|
+
working.
|
|
199
|
+
|
|
200
|
+
## Develop
|
|
201
|
+
|
|
202
|
+
```sh
|
|
203
|
+
python -m unittest discover -t . -s tests -v
|
|
204
|
+
cd panel && python -m unittest test_select -v
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
## License
|
|
208
|
+
|
|
209
|
+
MIT. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
skill_dag-0.1.0.dist-info/licenses/LICENSE,sha256=5EAEQA9a09Et9BXQiotsIqJDXp8FJpaEmbzy31RniUY,1068
|
|
2
|
+
skillflow/__init__.py,sha256=1k7rY5W22u-oLk6GISDsmR0eu10RoKxk-jrUi9aeL6k,142
|
|
3
|
+
skillflow/__main__.py,sha256=MHKZ_ae3fSLGTLUUMOx15fWdeOnJSHhq-zslRP5F5Lc,79
|
|
4
|
+
skillflow/cli.py,sha256=paQtmSwExkJb7TuOF4sjbjD6jA-73etiPll8lN99_Nw,3517
|
|
5
|
+
skillflow/dag.py,sha256=zzCKMZ1IkNjtp0l7ab693YjoeKudbTIR-2ZNHhXDams,7691
|
|
6
|
+
skillflow/db.py,sha256=_M8i6J3SvpKvsLIUbwMi6CRoqA9Sxo_XhSCPjVbzuGo,1306
|
|
7
|
+
skillflow/mcp_server.py,sha256=xt9tsLiI3coy-kqN9ll3xhnI2SIqeqG10uLi_DpFUV4,7209
|
|
8
|
+
skill_dag-0.1.0.dist-info/METADATA,sha256=pi_H5ZJvpoi6eu7y8mWJDvciHfLyCYCh2yypLhXtVQ8,7306
|
|
9
|
+
skill_dag-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
10
|
+
skill_dag-0.1.0.dist-info/entry_points.txt,sha256=OJO2E9p4Yj8b7x6lwMv8QF-Dk1ZK1xaQkXAUvAQXS0s,49
|
|
11
|
+
skill_dag-0.1.0.dist-info/top_level.txt,sha256=xAVJwLQPLxsYwbCijOtzqRQRX4GmjLb_B5if8k5_0QA,10
|
|
12
|
+
skill_dag-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Nate Roybal
|
|
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 @@
|
|
|
1
|
+
skillflow
|
skillflow/__init__.py
ADDED
skillflow/__main__.py
ADDED
skillflow/cli.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Command line interface for skillflow."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from .dag import Flow, FlowError
|
|
9
|
+
|
|
10
|
+
DEFAULT_DB = os.environ.get("SKILLFLOW_DB", "skillflow.db")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _flow(args) -> Flow:
|
|
14
|
+
return Flow(args.db)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def cmd_init(args) -> int:
|
|
18
|
+
try:
|
|
19
|
+
with _flow(args):
|
|
20
|
+
pass
|
|
21
|
+
except FlowError as exc:
|
|
22
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
23
|
+
return 1
|
|
24
|
+
print(f"initialized {args.db}")
|
|
25
|
+
return 0
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def cmd_add_node(args) -> int:
|
|
29
|
+
try:
|
|
30
|
+
with _flow(args) as flow:
|
|
31
|
+
flow.add_node(args.name, args.cmd)
|
|
32
|
+
except FlowError as exc:
|
|
33
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
34
|
+
return 1
|
|
35
|
+
print(f"added node {args.name!r}")
|
|
36
|
+
return 0
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def cmd_add_edge(args) -> int:
|
|
40
|
+
try:
|
|
41
|
+
with _flow(args) as flow:
|
|
42
|
+
flow.add_edge(args.from_node, args.to_node)
|
|
43
|
+
except FlowError as exc:
|
|
44
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
45
|
+
return 1
|
|
46
|
+
print(f"added edge {args.from_node!r} -> {args.to_node!r}")
|
|
47
|
+
return 0
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def cmd_show(args) -> int:
|
|
51
|
+
try:
|
|
52
|
+
with _flow(args) as flow:
|
|
53
|
+
print(json.dumps({"nodes": flow.nodes(), "edges": flow.edges()}, indent=2))
|
|
54
|
+
except FlowError as exc:
|
|
55
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
56
|
+
return 1
|
|
57
|
+
return 0
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def cmd_run(args) -> int:
|
|
61
|
+
try:
|
|
62
|
+
with _flow(args) as flow:
|
|
63
|
+
run_id = flow.run()
|
|
64
|
+
result = flow.status(run_id)
|
|
65
|
+
except FlowError as exc:
|
|
66
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
67
|
+
return 1
|
|
68
|
+
print(json.dumps(result, indent=2, default=str))
|
|
69
|
+
return 0 if result["run"]["status"] == "ok" else 1
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def cmd_status(args) -> int:
|
|
73
|
+
try:
|
|
74
|
+
with _flow(args) as flow:
|
|
75
|
+
result = flow.status(args.run)
|
|
76
|
+
except FlowError as exc:
|
|
77
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
78
|
+
return 1
|
|
79
|
+
if args.json:
|
|
80
|
+
print(json.dumps(result, indent=2, default=str))
|
|
81
|
+
else:
|
|
82
|
+
run = result["run"]
|
|
83
|
+
print(f"run {run['id']}: {run['status']}")
|
|
84
|
+
for node in result["nodes"]:
|
|
85
|
+
print(f" {node['name']}: {node['status']}")
|
|
86
|
+
return 0
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
90
|
+
parser = argparse.ArgumentParser(
|
|
91
|
+
prog="skillflow", description="A minimal SQLite-backed DAG runner."
|
|
92
|
+
)
|
|
93
|
+
parser.add_argument("--db", default=DEFAULT_DB, help="path to the SQLite file")
|
|
94
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
95
|
+
|
|
96
|
+
p = sub.add_parser("init", help="create the database")
|
|
97
|
+
p.set_defaults(func=cmd_init)
|
|
98
|
+
|
|
99
|
+
p = sub.add_parser("add-node", help="add a node")
|
|
100
|
+
p.add_argument("name")
|
|
101
|
+
p.add_argument("--cmd", default="", help="shell command to run for this node")
|
|
102
|
+
p.set_defaults(func=cmd_add_node)
|
|
103
|
+
|
|
104
|
+
p = sub.add_parser("add-edge", help="add a dependency edge (from -> to)")
|
|
105
|
+
p.add_argument("from_node")
|
|
106
|
+
p.add_argument("to_node")
|
|
107
|
+
p.set_defaults(func=cmd_add_edge)
|
|
108
|
+
|
|
109
|
+
p = sub.add_parser("show", help="print the DAG as JSON")
|
|
110
|
+
p.set_defaults(func=cmd_show)
|
|
111
|
+
|
|
112
|
+
p = sub.add_parser("run", help="execute the DAG in topological order")
|
|
113
|
+
p.set_defaults(func=cmd_run)
|
|
114
|
+
|
|
115
|
+
p = sub.add_parser("status", help="show the latest (or given) run")
|
|
116
|
+
p.add_argument("--run", type=int, default=None)
|
|
117
|
+
p.add_argument("--json", action="store_true")
|
|
118
|
+
p.set_defaults(func=cmd_status)
|
|
119
|
+
|
|
120
|
+
return parser
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def main(argv=None) -> int:
|
|
124
|
+
args = build_parser().parse_args(argv)
|
|
125
|
+
return args.func(args)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
if __name__ == "__main__":
|
|
129
|
+
raise SystemExit(main())
|
skillflow/dag.py
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""Core DAG operations: nodes, edges, topological run, status."""
|
|
2
|
+
|
|
3
|
+
import sqlite3
|
|
4
|
+
import subprocess
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
|
|
7
|
+
from . import db as _db
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class FlowError(Exception):
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _now() -> str:
|
|
15
|
+
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Flow:
|
|
19
|
+
def __init__(self, path: str):
|
|
20
|
+
self.path = path
|
|
21
|
+
try:
|
|
22
|
+
self.conn = _db.connect(path)
|
|
23
|
+
except (sqlite3.Error, OSError) as exc:
|
|
24
|
+
raise FlowError(f"cannot open database {path!r}: {exc}") from exc
|
|
25
|
+
|
|
26
|
+
def close(self):
|
|
27
|
+
self.conn.close()
|
|
28
|
+
|
|
29
|
+
def __enter__(self):
|
|
30
|
+
return self
|
|
31
|
+
|
|
32
|
+
def __exit__(self, *exc):
|
|
33
|
+
self.close()
|
|
34
|
+
|
|
35
|
+
# -- definition --------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
def add_node(self, name: str, cmd: str = "") -> int:
|
|
38
|
+
if not name:
|
|
39
|
+
raise FlowError("node name must not be empty")
|
|
40
|
+
try:
|
|
41
|
+
cur = self.conn.execute(
|
|
42
|
+
"INSERT INTO nodes (name, cmd) VALUES (?, ?)", (name, cmd)
|
|
43
|
+
)
|
|
44
|
+
except Exception as exc:
|
|
45
|
+
raise FlowError(f"cannot add node {name!r}: {exc}") from exc
|
|
46
|
+
self.conn.commit()
|
|
47
|
+
return cur.lastrowid
|
|
48
|
+
|
|
49
|
+
def _node_id(self, name: str) -> int:
|
|
50
|
+
row = self.conn.execute(
|
|
51
|
+
"SELECT id FROM nodes WHERE name = ?", (name,)
|
|
52
|
+
).fetchone()
|
|
53
|
+
if row is None:
|
|
54
|
+
raise FlowError(f"unknown node {name!r}")
|
|
55
|
+
return row["id"]
|
|
56
|
+
|
|
57
|
+
def _reachable(self, start_id: int, goal_id: int) -> bool:
|
|
58
|
+
seen = {start_id}
|
|
59
|
+
stack = [start_id]
|
|
60
|
+
while stack:
|
|
61
|
+
current = stack.pop()
|
|
62
|
+
if current == goal_id:
|
|
63
|
+
return True
|
|
64
|
+
for row in self.conn.execute(
|
|
65
|
+
"SELECT to_id FROM edges WHERE from_id = ?", (current,)
|
|
66
|
+
):
|
|
67
|
+
if row["to_id"] not in seen:
|
|
68
|
+
seen.add(row["to_id"])
|
|
69
|
+
stack.append(row["to_id"])
|
|
70
|
+
return False
|
|
71
|
+
|
|
72
|
+
def add_edge(self, from_name: str, to_name: str) -> None:
|
|
73
|
+
from_id = self._node_id(from_name)
|
|
74
|
+
to_id = self._node_id(to_name)
|
|
75
|
+
if from_id == to_id:
|
|
76
|
+
raise FlowError("a node cannot depend on itself")
|
|
77
|
+
if self._reachable(to_id, from_id):
|
|
78
|
+
raise FlowError(
|
|
79
|
+
f"edge {from_name!r} -> {to_name!r} would create a cycle"
|
|
80
|
+
)
|
|
81
|
+
try:
|
|
82
|
+
self.conn.execute(
|
|
83
|
+
"INSERT INTO edges (from_id, to_id) VALUES (?, ?)",
|
|
84
|
+
(from_id, to_id),
|
|
85
|
+
)
|
|
86
|
+
except Exception as exc:
|
|
87
|
+
raise FlowError(f"cannot add edge: {exc}") from exc
|
|
88
|
+
self.conn.commit()
|
|
89
|
+
|
|
90
|
+
def nodes(self) -> list:
|
|
91
|
+
return [
|
|
92
|
+
dict(row)
|
|
93
|
+
for row in self.conn.execute("SELECT id, name, cmd FROM nodes ORDER BY name")
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
def edges(self) -> list:
|
|
97
|
+
return [
|
|
98
|
+
dict(row)
|
|
99
|
+
for row in self.conn.execute(
|
|
100
|
+
"""SELECT f.name AS from_name, t.name AS to_name
|
|
101
|
+
FROM edges e
|
|
102
|
+
JOIN nodes f ON f.id = e.from_id
|
|
103
|
+
JOIN nodes t ON t.id = e.to_id
|
|
104
|
+
ORDER BY from_name, to_name"""
|
|
105
|
+
)
|
|
106
|
+
]
|
|
107
|
+
|
|
108
|
+
# -- execution ----------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
def order(self) -> list:
|
|
111
|
+
nodes = {row["id"]: row for row in self.conn.execute("SELECT * FROM nodes")}
|
|
112
|
+
indegree = {nid: 0 for nid in nodes}
|
|
113
|
+
outgoing: dict = {nid: [] for nid in nodes}
|
|
114
|
+
for row in self.conn.execute("SELECT from_id, to_id FROM edges"):
|
|
115
|
+
outgoing[row["from_id"]].append(row["to_id"])
|
|
116
|
+
indegree[row["to_id"]] += 1
|
|
117
|
+
queue = sorted(n for n, d in indegree.items() if d == 0)
|
|
118
|
+
result = []
|
|
119
|
+
while queue:
|
|
120
|
+
nid = queue.pop(0)
|
|
121
|
+
result.append(nodes[nid])
|
|
122
|
+
for nxt in sorted(outgoing[nid]):
|
|
123
|
+
indegree[nxt] -= 1
|
|
124
|
+
if indegree[nxt] == 0:
|
|
125
|
+
queue.append(nxt)
|
|
126
|
+
if len(result) != len(nodes):
|
|
127
|
+
raise FlowError("graph contains a cycle")
|
|
128
|
+
return [dict(r) for r in result]
|
|
129
|
+
|
|
130
|
+
def run(self) -> int:
|
|
131
|
+
ordered = self.order()
|
|
132
|
+
try:
|
|
133
|
+
return self._run_ordered(ordered)
|
|
134
|
+
except sqlite3.Error as exc:
|
|
135
|
+
raise FlowError(f"database is busy or locked: {exc}") from exc
|
|
136
|
+
|
|
137
|
+
def _run_ordered(self, ordered: list) -> int:
|
|
138
|
+
started = _now()
|
|
139
|
+
# Runs left behind by a killed process never finish on their own;
|
|
140
|
+
# mark them interrupted so they stop masquerading as the latest run.
|
|
141
|
+
self.conn.execute(
|
|
142
|
+
"UPDATE runs SET status = 'interrupted', finished_at = ? "
|
|
143
|
+
"WHERE status = 'running'",
|
|
144
|
+
(started,),
|
|
145
|
+
)
|
|
146
|
+
self.conn.execute(
|
|
147
|
+
"""UPDATE node_results SET status = 'interrupted', finished_at = ?
|
|
148
|
+
WHERE status = 'running'""",
|
|
149
|
+
(started,),
|
|
150
|
+
)
|
|
151
|
+
self.conn.commit()
|
|
152
|
+
cur = self.conn.execute(
|
|
153
|
+
"INSERT INTO runs (started_at, status) VALUES (?, 'running')", (started,)
|
|
154
|
+
)
|
|
155
|
+
run_id = cur.lastrowid
|
|
156
|
+
self.conn.commit()
|
|
157
|
+
overall = "ok"
|
|
158
|
+
for node in ordered:
|
|
159
|
+
node_started = _now()
|
|
160
|
+
self.conn.execute(
|
|
161
|
+
"""INSERT INTO node_results
|
|
162
|
+
(run_id, node_id, status, started_at)
|
|
163
|
+
VALUES (?, ?, 'running', ?)""",
|
|
164
|
+
(run_id, node["id"], node_started),
|
|
165
|
+
)
|
|
166
|
+
self.conn.commit()
|
|
167
|
+
try:
|
|
168
|
+
proc = subprocess.run(
|
|
169
|
+
node["cmd"],
|
|
170
|
+
shell=True,
|
|
171
|
+
capture_output=True,
|
|
172
|
+
text=True,
|
|
173
|
+
timeout=3600,
|
|
174
|
+
)
|
|
175
|
+
status = "ok" if proc.returncode == 0 else "failed"
|
|
176
|
+
output = (proc.stdout or "") + (proc.stderr or "")
|
|
177
|
+
exit_code = proc.returncode
|
|
178
|
+
except Exception as exc:
|
|
179
|
+
status = "failed"
|
|
180
|
+
output = str(exc)
|
|
181
|
+
exit_code = None
|
|
182
|
+
self.conn.execute(
|
|
183
|
+
"""UPDATE node_results
|
|
184
|
+
SET status = ?, exit_code = ?, output = ?, finished_at = ?
|
|
185
|
+
WHERE run_id = ? AND node_id = ?""",
|
|
186
|
+
(status, exit_code, output, _now(), run_id, node["id"]),
|
|
187
|
+
)
|
|
188
|
+
self.conn.commit()
|
|
189
|
+
if status != "ok":
|
|
190
|
+
overall = "failed"
|
|
191
|
+
break
|
|
192
|
+
self.conn.execute(
|
|
193
|
+
"UPDATE runs SET status = ?, finished_at = ? WHERE id = ?",
|
|
194
|
+
(overall, _now(), run_id),
|
|
195
|
+
)
|
|
196
|
+
self.conn.commit()
|
|
197
|
+
return run_id
|
|
198
|
+
|
|
199
|
+
def status(self, run_id: int | None = None) -> dict:
|
|
200
|
+
if run_id is None:
|
|
201
|
+
row = self.conn.execute(
|
|
202
|
+
"SELECT id FROM runs ORDER BY id DESC LIMIT 1"
|
|
203
|
+
).fetchone()
|
|
204
|
+
if row is None:
|
|
205
|
+
raise FlowError("no runs yet")
|
|
206
|
+
run_id = row["id"]
|
|
207
|
+
run = self.conn.execute(
|
|
208
|
+
"SELECT * FROM runs WHERE id = ?", (run_id,)
|
|
209
|
+
).fetchone()
|
|
210
|
+
if run is None:
|
|
211
|
+
raise FlowError(f"unknown run {run_id}")
|
|
212
|
+
results = [
|
|
213
|
+
dict(r)
|
|
214
|
+
for r in self.conn.execute(
|
|
215
|
+
"""SELECT n.name, r.status, r.exit_code, r.output,
|
|
216
|
+
r.started_at, r.finished_at
|
|
217
|
+
FROM node_results r
|
|
218
|
+
JOIN nodes n ON n.id = r.node_id
|
|
219
|
+
WHERE r.run_id = ?
|
|
220
|
+
ORDER BY r.rowid""",
|
|
221
|
+
(run_id,),
|
|
222
|
+
)
|
|
223
|
+
]
|
|
224
|
+
return {"run": dict(run), "nodes": results}
|
skillflow/db.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""SQLite schema and connection handling for skillflow."""
|
|
2
|
+
|
|
3
|
+
import sqlite3
|
|
4
|
+
|
|
5
|
+
SCHEMA = """
|
|
6
|
+
CREATE TABLE IF NOT EXISTS nodes (
|
|
7
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
8
|
+
name TEXT NOT NULL UNIQUE,
|
|
9
|
+
cmd TEXT NOT NULL DEFAULT ''
|
|
10
|
+
);
|
|
11
|
+
CREATE TABLE IF NOT EXISTS edges (
|
|
12
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
13
|
+
from_id INTEGER NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
|
|
14
|
+
to_id INTEGER NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
|
|
15
|
+
UNIQUE (from_id, to_id),
|
|
16
|
+
CHECK (from_id != to_id)
|
|
17
|
+
);
|
|
18
|
+
CREATE TABLE IF NOT EXISTS runs (
|
|
19
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
20
|
+
started_at TEXT NOT NULL,
|
|
21
|
+
finished_at TEXT,
|
|
22
|
+
status TEXT NOT NULL
|
|
23
|
+
);
|
|
24
|
+
CREATE TABLE IF NOT EXISTS node_results (
|
|
25
|
+
run_id INTEGER NOT NULL REFERENCES runs(id) ON DELETE CASCADE,
|
|
26
|
+
node_id INTEGER NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
|
|
27
|
+
status TEXT NOT NULL,
|
|
28
|
+
exit_code INTEGER,
|
|
29
|
+
output TEXT NOT NULL DEFAULT '',
|
|
30
|
+
started_at TEXT,
|
|
31
|
+
finished_at TEXT,
|
|
32
|
+
PRIMARY KEY (run_id, node_id)
|
|
33
|
+
);
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def connect(path: str, timeout: float = 30.0) -> sqlite3.Connection:
|
|
38
|
+
conn = sqlite3.connect(path, timeout=timeout)
|
|
39
|
+
conn.row_factory = sqlite3.Row
|
|
40
|
+
conn.execute("PRAGMA foreign_keys = ON")
|
|
41
|
+
conn.executescript(SCHEMA)
|
|
42
|
+
return conn
|
skillflow/mcp_server.py
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""Skillflow MCP server: DAG runner + panel room selection as MCP tools.
|
|
2
|
+
|
|
3
|
+
Run with: python -m skillflow.mcp_server
|
|
4
|
+
exposure: stdio transport.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
11
|
+
PANEL_DIR = os.path.join(REPO_ROOT, "panel")
|
|
12
|
+
sys.path.insert(0, PANEL_DIR)
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
from mcp.server.fastmcp import FastMCP # noqa: E402
|
|
16
|
+
from mcp.types import ToolAnnotations # noqa: E402
|
|
17
|
+
except ImportError:
|
|
18
|
+
print("error: the 'mcp' package is not installed.",
|
|
19
|
+
"Install the server extra: pip install 'skill-dag[mcp]',",
|
|
20
|
+
file=sys.stderr)
|
|
21
|
+
raise SystemExit(2)
|
|
22
|
+
|
|
23
|
+
from .dag import Flow, FlowError # noqa: E402
|
|
24
|
+
|
|
25
|
+
READ_ONLY = ToolAnnotations(readOnlyHint=True, destructiveHint=False,
|
|
26
|
+
idempotentHint=True, openWorldHint=False)
|
|
27
|
+
WRITE_SAFE = ToolAnnotations(readOnlyHint=False, destructiveHint=False,
|
|
28
|
+
idempotentHint=False, openWorldHint=False)
|
|
29
|
+
WRITE_IDEMPOTENT = ToolAnnotations(readOnlyHint=False, destructiveHint=False,
|
|
30
|
+
idempotentHint=True, openWorldHint=False)
|
|
31
|
+
EXECUTES_SHELL = ToolAnnotations(readOnlyHint=False, destructiveHint=True,
|
|
32
|
+
idempotentHint=False, openWorldHint=False)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _panel_file(name: str) -> str:
|
|
36
|
+
path = os.path.join(PANEL_DIR, name)
|
|
37
|
+
if not os.path.isfile(path):
|
|
38
|
+
raise FileNotFoundError(
|
|
39
|
+
f"panel data missing at {path}; the panel tools need a repo "
|
|
40
|
+
f"checkout (pip installs ship the engine only)")
|
|
41
|
+
return path
|
|
42
|
+
|
|
43
|
+
mcp = FastMCP("skillflow")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
OUTPUT_LIMIT = 2000
|
|
47
|
+
|
|
48
|
+
HINTS = [
|
|
49
|
+
("would create a cycle", "Use skillflow_show to see the current edges."),
|
|
50
|
+
("unknown node", "Use skillflow_show to list node names."),
|
|
51
|
+
("unknown run", "Call skillflow_status without a run id for the latest."),
|
|
52
|
+
("no runs yet", "Call skillflow_run first."),
|
|
53
|
+
("cannot open database", "Check the db path; use skillflow_init to create it."),
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _err(exc: Exception) -> dict:
|
|
58
|
+
message = str(exc)
|
|
59
|
+
for needle, hint in HINTS:
|
|
60
|
+
if needle in message:
|
|
61
|
+
return {"ok": False, "error": message, "hint": hint}
|
|
62
|
+
return {"ok": False, "error": message}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _trim_run(result: dict) -> dict:
|
|
66
|
+
for node in result.get("nodes", []):
|
|
67
|
+
output = node.get("output") or ""
|
|
68
|
+
if len(output) > OUTPUT_LIMIT:
|
|
69
|
+
node["output"] = output[:OUTPUT_LIMIT]
|
|
70
|
+
node["truncated"] = True
|
|
71
|
+
return result
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@mcp.tool(annotations=WRITE_IDEMPOTENT)
|
|
75
|
+
def skillflow_init(db: str = "skillflow.db") -> dict:
|
|
76
|
+
"""Create a skillflow SQLite database (idempotent)."""
|
|
77
|
+
try:
|
|
78
|
+
with Flow(db):
|
|
79
|
+
pass
|
|
80
|
+
except FlowError as exc:
|
|
81
|
+
return _err(exc)
|
|
82
|
+
return {"ok": True, "db": db}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@mcp.tool(annotations=WRITE_SAFE)
|
|
86
|
+
def skillflow_add_node(db: str = "skillflow.db", name: str = "",
|
|
87
|
+
cmd: str = "") -> dict:
|
|
88
|
+
"""Add a node (a shell command) to the DAG."""
|
|
89
|
+
try:
|
|
90
|
+
with Flow(db) as flow:
|
|
91
|
+
node_id = flow.add_node(name, cmd)
|
|
92
|
+
except FlowError as exc:
|
|
93
|
+
return _err(exc)
|
|
94
|
+
return {"ok": True, "id": node_id, "name": name}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@mcp.tool(annotations=WRITE_SAFE)
|
|
98
|
+
def skillflow_add_edge(db: str = "skillflow.db", from_node: str = "",
|
|
99
|
+
to_node: str = "") -> dict:
|
|
100
|
+
"""Add a dependency edge (from_node runs before to_node). Rejects cycles."""
|
|
101
|
+
try:
|
|
102
|
+
with Flow(db) as flow:
|
|
103
|
+
flow.add_edge(from_node, to_node)
|
|
104
|
+
except FlowError as exc:
|
|
105
|
+
return _err(exc)
|
|
106
|
+
return {"ok": True, "from": from_node, "to": to_node}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@mcp.tool(annotations=READ_ONLY)
|
|
110
|
+
def skillflow_show(db: str = "skillflow.db") -> dict:
|
|
111
|
+
"""Show the DAG's nodes and edges."""
|
|
112
|
+
with Flow(db) as flow:
|
|
113
|
+
return {"ok": True, "nodes": flow.nodes(), "edges": flow.edges()}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _run_common(db: str, run: int | None, execute: bool) -> dict:
|
|
117
|
+
try:
|
|
118
|
+
with Flow(db) as flow:
|
|
119
|
+
run_id = flow.run() if execute else run
|
|
120
|
+
result = flow.status(run_id)
|
|
121
|
+
except FlowError as exc:
|
|
122
|
+
return _err(exc)
|
|
123
|
+
result["ok"] = True
|
|
124
|
+
return _trim_run(result)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@mcp.tool(annotations=EXECUTES_SHELL)
|
|
128
|
+
def skillflow_run(db: str = "skillflow.db") -> dict:
|
|
129
|
+
"""Execute the DAG in topological order and return the run record.
|
|
130
|
+
|
|
131
|
+
Use after defining nodes and edges with skillflow_add_node /
|
|
132
|
+
skillflow_add_edge. The first failing node stops downstream nodes;
|
|
133
|
+
the returned record shows per-node status, exit codes, and output
|
|
134
|
+
(trimmed to 2000 chars per node). Gate nodes that prompt on a
|
|
135
|
+
terminal fail closed without one — a run containing an unanswered
|
|
136
|
+
gate stops there, which is the correct outcome, not an error.
|
|
137
|
+
"""
|
|
138
|
+
return _run_common(db, None, execute=True)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@mcp.tool(annotations=READ_ONLY)
|
|
142
|
+
def skillflow_status(db: str = "skillflow.db", run: int | None = None) -> dict:
|
|
143
|
+
"""Show the latest run, or the given run id, with per-node results.
|
|
144
|
+
|
|
145
|
+
Use to inspect a previous run without executing anything. Omit `run`
|
|
146
|
+
for the latest. Returns run status plus each executed node's status,
|
|
147
|
+
exit code, and trimmed output.
|
|
148
|
+
"""
|
|
149
|
+
return _run_common(db, run, execute=False)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@mcp.tool(annotations=WRITE_IDEMPOTENT)
|
|
153
|
+
def panel_seed(db: str = "skillflow.db") -> dict:
|
|
154
|
+
"""Seed the panelists table in a session DB. Run once before selecting."""
|
|
155
|
+
try:
|
|
156
|
+
source = _panel_file("panelists.json")
|
|
157
|
+
_panel_file("seed.py")
|
|
158
|
+
import seed as seed_mod
|
|
159
|
+
count = seed_mod.seed(db, source)
|
|
160
|
+
except Exception as exc: # noqa: BLE001 - surfaced as tool error
|
|
161
|
+
return _err(exc)
|
|
162
|
+
return {"ok": True, "panelists": count, "db": db}
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@mcp.tool(annotations=READ_ONLY)
|
|
166
|
+
def panel_select_room(db: str = "skillflow.db", tensions: str = "",
|
|
167
|
+
size: int = 4, exclude_ids: list[str] | None = None) -> dict:
|
|
168
|
+
"""Seat a panel room from the 128-person roster in the session DB.
|
|
169
|
+
|
|
170
|
+
`tensions` is comma-separated situation topics (e.g.
|
|
171
|
+
"risk,measurement"); panelists are ranked by token overlap with their
|
|
172
|
+
tags, lens, and attributes, then diversity is enforced: one per family,
|
|
173
|
+
3-5 seats, near-duplicate tag sets skipped. Pass `exclude_ids` with
|
|
174
|
+
earlier rooms' member ids to keep later rounds fresh. Requires
|
|
175
|
+
panel_seed to have run once on this DB.
|
|
176
|
+
"""
|
|
177
|
+
if not 3 <= size <= 5:
|
|
178
|
+
return {"ok": False, "error": "--size must be 3-5"}
|
|
179
|
+
try:
|
|
180
|
+
_panel_file("select_room.py")
|
|
181
|
+
import select_room as selector
|
|
182
|
+
panelists = selector.load_panelists(db)
|
|
183
|
+
except Exception as exc: # noqa: BLE001 - surfaced as tool error
|
|
184
|
+
return {"ok": False,
|
|
185
|
+
"error": f"cannot load panelists (run panel_seed?): {exc}"}
|
|
186
|
+
parsed = [t.strip() for t in tensions.split(",") if t.strip()]
|
|
187
|
+
room = selector.select(panelists, parsed, size, set(exclude_ids or ()))
|
|
188
|
+
return {
|
|
189
|
+
"ok": True,
|
|
190
|
+
"tensions": parsed,
|
|
191
|
+
"room": [
|
|
192
|
+
{"name": p["name"], "id": p["id"], "family": p["family"],
|
|
193
|
+
"lens": p["lens"], "score": selector.score(parsed, p)}
|
|
194
|
+
for p in room
|
|
195
|
+
],
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def main() -> None:
|
|
200
|
+
mcp.run()
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
if __name__ == "__main__":
|
|
204
|
+
main()
|