@paradigma-inc/flywheel 0.1.25 → 0.1.35
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/skills/flywheel/example-workflows/organizing-exploring-and-iterating-on-a-research-topic.md +11 -11
- package/skills/flywheel/example-workflows/reproducing-papers-on-a-budget.md +8 -8
- package/skills/flywheel/references/ARTIFACTS.md +48 -0
- package/skills/flywheel/references/INTERFACES.md +206 -0
- package/skills/flywheel/references/experiment-design-protocol.md +5 -5
- package/skills/flywheel/references/flywheel-mcp-tool-map.md +73 -59
- package/skills/flywheel-auto/SKILL.md +4 -4
- package/skills/flywheel-auto/references/ARTIFACTS.md +1 -1
- package/skills/flywheel-auto/references/INTERFACES.md +60 -58
- package/skills/flywheel-auto/references/experiment-design-protocol.md +5 -5
- package/skills/flywheel-auto/references/flywheel-mcp-tool-map.md +73 -59
- package/skills/flywheel-lookahead/references/ARTIFACTS.md +1 -1
- package/skills/flywheel-lookahead/references/INTERFACES.md +60 -58
- package/skills/flywheel-lookahead/references/flywheel-mcp-tool-map.md +73 -59
- package/skills/flywheel-prove/SKILL.md +8 -0
- package/skills/flywheel-reproduce/SKILL.md +4 -4
- package/skills/flywheel-reproduce/references/ARTIFACTS.md +1 -1
- package/skills/flywheel-reproduce/references/INTERFACES.md +60 -58
- package/skills/flywheel-reproduce/references/experiment-design-protocol.md +5 -5
- package/skills/flywheel-reproduce/references/flywheel-mcp-tool-map.md +73 -59
- package/skills/flywheel-to-graph/SKILL.md +4 -4
- package/skills/flywheel-to-graph/references/ARTIFACTS.md +1 -1
- package/skills/flywheel-to-graph/references/INTERFACES.md +60 -58
- package/skills/flywheel-to-graph/references/flywheel-mcp-tool-map.md +73 -59
- package/skills/flywheel-tree/SKILL.md +61 -0
- package/skills/flywheel-tree/agents/interface.yaml +4 -0
- package/skills/flywheel-tree/assets/ansi_palette.json +29 -0
- package/skills/flywheel-tree/references/workflow.md +108 -0
- package/skills/flywheel-tree/scripts/render_tree.py +407 -0
- package/skills/flywheel-tree/scripts/render_tree_via_mcp.py +694 -0
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Render a Flywheel get_node_tree response into terminal-ready tree text.
|
|
3
|
+
|
|
4
|
+
Input:
|
|
5
|
+
- Raw JSON from flywheel_get_node_tree (stdin or file).
|
|
6
|
+
|
|
7
|
+
Output:
|
|
8
|
+
- Text suitable for direct terminal printing, including ANSI colors and summary:
|
|
9
|
+
- root: <anchor title>
|
|
10
|
+
- node_count: <anchor-rooted descendant node count>
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import json
|
|
17
|
+
import sys
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any, Dict, Iterable, Iterator, List, NamedTuple, Set, Tuple
|
|
20
|
+
|
|
21
|
+
DEFAULT_COLORS = [
|
|
22
|
+
"\u001b[94m",
|
|
23
|
+
"\u001b[96m",
|
|
24
|
+
"\u001b[95m",
|
|
25
|
+
"\u001b[92m",
|
|
26
|
+
"\u001b[38;5;45m",
|
|
27
|
+
"\u001b[38;5;39m",
|
|
28
|
+
"\u001b[38;5;51m",
|
|
29
|
+
"\u001b[38;5;75m",
|
|
30
|
+
"\u001b[38;5;81m",
|
|
31
|
+
"\u001b[38;5;87m",
|
|
32
|
+
"\u001b[38;5;69m",
|
|
33
|
+
"\u001b[38;5;63m",
|
|
34
|
+
"\u001b[38;5;57m",
|
|
35
|
+
"\u001b[38;5;99m",
|
|
36
|
+
"\u001b[38;5;105m",
|
|
37
|
+
"\u001b[38;5;111m",
|
|
38
|
+
"\u001b[38;5;117m",
|
|
39
|
+
"\u001b[38;5;123m",
|
|
40
|
+
"\u001b[38;5;159m",
|
|
41
|
+
"\u001b[38;5;195m",
|
|
42
|
+
]
|
|
43
|
+
DEFAULT_RESET = "\u001b[0m"
|
|
44
|
+
GRAY = "\u001b[38;5;245m"
|
|
45
|
+
PASTEL_RED = "\u001b[38;5;217m"
|
|
46
|
+
PASTEL_YELLOW = "\u001b[38;5;229m"
|
|
47
|
+
|
|
48
|
+
BOX_MID = "├── "
|
|
49
|
+
BOX_LAST = "└── "
|
|
50
|
+
BOX_PIPE = "│ "
|
|
51
|
+
BOX_SPACE = " "
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class Node(NamedTuple):
|
|
55
|
+
title: str
|
|
56
|
+
kind: str
|
|
57
|
+
slug_name: str
|
|
58
|
+
parent_ids: Tuple[str, ...]
|
|
59
|
+
child_ids: Tuple[str, ...]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _normalize_ansi(value: str) -> str:
|
|
63
|
+
if "\\u001b" in value or "\\x1b" in value:
|
|
64
|
+
try:
|
|
65
|
+
return value.encode("utf-8").decode("unicode_escape")
|
|
66
|
+
except Exception: # noqa: BLE001
|
|
67
|
+
return value
|
|
68
|
+
return value
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _extract_tree_payload(payload: Any) -> dict:
|
|
72
|
+
if isinstance(payload, dict) and "nodes" in payload and "anchor_node_id" in payload:
|
|
73
|
+
return payload
|
|
74
|
+
if isinstance(payload, dict):
|
|
75
|
+
for key in ("result", "data", "response"):
|
|
76
|
+
nested = payload.get(key)
|
|
77
|
+
if (
|
|
78
|
+
isinstance(nested, dict)
|
|
79
|
+
and "nodes" in nested
|
|
80
|
+
and "anchor_node_id" in nested
|
|
81
|
+
):
|
|
82
|
+
return nested
|
|
83
|
+
raise ValueError(
|
|
84
|
+
"Input JSON is not a flywheel_get_node_tree response (missing nodes/anchor_node_id)."
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _read_json_from_path(path: str) -> dict:
|
|
89
|
+
if path == "-":
|
|
90
|
+
raw = sys.stdin.buffer.read().decode("utf-8-sig")
|
|
91
|
+
payload = json.loads(raw)
|
|
92
|
+
else:
|
|
93
|
+
with Path(path).open("r", encoding="utf-8-sig") as handle:
|
|
94
|
+
payload = json.load(handle)
|
|
95
|
+
return _extract_tree_payload(payload)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _load_palette(asset_path: Path) -> tuple[List[str], str]:
|
|
99
|
+
if not asset_path.exists():
|
|
100
|
+
return DEFAULT_COLORS, DEFAULT_RESET
|
|
101
|
+
|
|
102
|
+
with asset_path.open("r", encoding="utf-8-sig") as handle:
|
|
103
|
+
data = json.load(handle)
|
|
104
|
+
|
|
105
|
+
colors = data.get("colors") if isinstance(data, dict) else None
|
|
106
|
+
reset = (
|
|
107
|
+
data.get("reset", DEFAULT_RESET) if isinstance(data, dict) else DEFAULT_RESET
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
if not isinstance(colors, list) or not colors:
|
|
111
|
+
return DEFAULT_COLORS, DEFAULT_RESET
|
|
112
|
+
|
|
113
|
+
safe_colors = [
|
|
114
|
+
_normalize_ansi(color) for color in colors if isinstance(color, str) and color
|
|
115
|
+
]
|
|
116
|
+
if not safe_colors:
|
|
117
|
+
return DEFAULT_COLORS, DEFAULT_RESET
|
|
118
|
+
|
|
119
|
+
if not isinstance(reset, str) or not reset:
|
|
120
|
+
reset = DEFAULT_RESET
|
|
121
|
+
|
|
122
|
+
return safe_colors, _normalize_ansi(reset)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _clean_text(value: Any) -> str:
|
|
126
|
+
return value.strip() if isinstance(value, str) else ""
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _build_node_map(nodes_payload: Iterable[dict]) -> Dict[str, Node]:
|
|
130
|
+
node_map: Dict[str, Node] = {}
|
|
131
|
+
|
|
132
|
+
for raw in nodes_payload:
|
|
133
|
+
if not isinstance(raw, dict):
|
|
134
|
+
continue
|
|
135
|
+
|
|
136
|
+
node_id = _clean_text(raw.get("node_id"))
|
|
137
|
+
if not node_id:
|
|
138
|
+
continue
|
|
139
|
+
|
|
140
|
+
title = _clean_text(raw.get("title")) or node_id
|
|
141
|
+
kind_raw = raw.get("kind", raw.get("node_type", ""))
|
|
142
|
+
kind = _clean_text(kind_raw) or "untyped"
|
|
143
|
+
slug_name_raw = raw.get("slug_name", raw.get("slug"))
|
|
144
|
+
slug_name = _clean_text(slug_name_raw)
|
|
145
|
+
|
|
146
|
+
parent_raw = raw.get("parent_ids")
|
|
147
|
+
if not isinstance(parent_raw, list):
|
|
148
|
+
parent_raw = raw.get("incoming_ids", [])
|
|
149
|
+
child_raw = raw.get("child_ids")
|
|
150
|
+
if not isinstance(child_raw, list):
|
|
151
|
+
child_raw = raw.get("outgoing_ids", [])
|
|
152
|
+
|
|
153
|
+
parent_ids = tuple(str(p) for p in parent_raw if isinstance(p, str))
|
|
154
|
+
child_ids = tuple(str(c) for c in child_raw if isinstance(c, str))
|
|
155
|
+
|
|
156
|
+
node_map[node_id] = Node(
|
|
157
|
+
title=title,
|
|
158
|
+
kind=kind,
|
|
159
|
+
slug_name=slug_name,
|
|
160
|
+
parent_ids=parent_ids,
|
|
161
|
+
child_ids=child_ids,
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
return node_map
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _collect_descendants(
|
|
168
|
+
anchor_node_id: str, node_map: Dict[str, Node]
|
|
169
|
+
) -> tuple[Set[str], List[str]]:
|
|
170
|
+
descendants: Set[str] = set()
|
|
171
|
+
order: List[str] = []
|
|
172
|
+
stack: List[str] = [anchor_node_id]
|
|
173
|
+
|
|
174
|
+
while stack:
|
|
175
|
+
current = stack.pop()
|
|
176
|
+
if current in descendants:
|
|
177
|
+
continue
|
|
178
|
+
|
|
179
|
+
node = node_map.get(current)
|
|
180
|
+
if node is None:
|
|
181
|
+
continue
|
|
182
|
+
|
|
183
|
+
descendants.add(current)
|
|
184
|
+
order.append(current)
|
|
185
|
+
for child_id in reversed(node.child_ids):
|
|
186
|
+
stack.append(child_id)
|
|
187
|
+
|
|
188
|
+
return descendants, order
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def render_tree_text(
|
|
192
|
+
tree_payload: dict,
|
|
193
|
+
anchor_override: str | None = None,
|
|
194
|
+
use_color: bool = True,
|
|
195
|
+
palette_path: Path | None = None,
|
|
196
|
+
) -> str:
|
|
197
|
+
return "\n".join(
|
|
198
|
+
_iter_render_lines(
|
|
199
|
+
tree_payload=tree_payload,
|
|
200
|
+
anchor_override=anchor_override,
|
|
201
|
+
use_color=use_color,
|
|
202
|
+
palette_path=palette_path,
|
|
203
|
+
)
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _iter_render_lines(
|
|
208
|
+
tree_payload: dict,
|
|
209
|
+
anchor_override: str | None = None,
|
|
210
|
+
use_color: bool = True,
|
|
211
|
+
palette_path: Path | None = None,
|
|
212
|
+
) -> Iterator[str]:
|
|
213
|
+
node_map = _build_node_map(tree_payload.get("nodes", []))
|
|
214
|
+
|
|
215
|
+
anchor = _clean_text(anchor_override) or _clean_text(
|
|
216
|
+
tree_payload.get("anchor_node_id")
|
|
217
|
+
)
|
|
218
|
+
if not anchor:
|
|
219
|
+
raise ValueError("Missing anchor node id.")
|
|
220
|
+
if anchor not in node_map:
|
|
221
|
+
raise ValueError(f"Anchor node '{anchor}' is not present in nodes payload.")
|
|
222
|
+
|
|
223
|
+
descendants, ordered_descendants = _collect_descendants(anchor, node_map)
|
|
224
|
+
if anchor not in descendants:
|
|
225
|
+
raise ValueError(f"Could not build descendant subgraph for anchor '{anchor}'.")
|
|
226
|
+
|
|
227
|
+
retained_children: Dict[str, Tuple[str, ...]] = {}
|
|
228
|
+
multi_parent_nodes: List[str] = []
|
|
229
|
+
|
|
230
|
+
for node_id in ordered_descendants:
|
|
231
|
+
node = node_map[node_id]
|
|
232
|
+
|
|
233
|
+
retained = tuple(child for child in node.child_ids if child in descendants)
|
|
234
|
+
retained_children[node_id] = retained
|
|
235
|
+
|
|
236
|
+
parent_count = 0
|
|
237
|
+
for parent_id in node.parent_ids:
|
|
238
|
+
if parent_id in descendants:
|
|
239
|
+
parent_count += 1
|
|
240
|
+
if parent_count > 1:
|
|
241
|
+
multi_parent_nodes.append(node_id)
|
|
242
|
+
break
|
|
243
|
+
|
|
244
|
+
colors, reset = (
|
|
245
|
+
_load_palette(palette_path) if palette_path else (DEFAULT_COLORS, DEFAULT_RESET)
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
multi_parent_color: Dict[str, str] = {}
|
|
249
|
+
for idx, node_id in enumerate(multi_parent_nodes):
|
|
250
|
+
multi_parent_color[node_id] = colors[idx % len(colors)]
|
|
251
|
+
|
|
252
|
+
seen_multi_parent: Dict[str, int] = {node_id: 0 for node_id in multi_parent_nodes}
|
|
253
|
+
ancestry: Set[str] = set()
|
|
254
|
+
expanded_nodes: Set[str] = set()
|
|
255
|
+
|
|
256
|
+
def line_for(node_id: str) -> str:
|
|
257
|
+
node = node_map[node_id]
|
|
258
|
+
kind_token = f"[{node.kind}]"
|
|
259
|
+
seen_count = seen_multi_parent.get(node_id)
|
|
260
|
+
has_multi_parent = seen_count is not None
|
|
261
|
+
mp_color = multi_parent_color[node_id] if has_multi_parent else ""
|
|
262
|
+
|
|
263
|
+
if has_multi_parent:
|
|
264
|
+
suffix = " (multi-parent)" if seen_count == 0 else " (duplicate view)"
|
|
265
|
+
seen_multi_parent[node_id] = seen_count + 1
|
|
266
|
+
else:
|
|
267
|
+
suffix = ""
|
|
268
|
+
|
|
269
|
+
if not use_color:
|
|
270
|
+
rendered = f"{node.title} {kind_token}"
|
|
271
|
+
if node.slug_name:
|
|
272
|
+
rendered += f" | {node.slug_name}"
|
|
273
|
+
rendered += suffix
|
|
274
|
+
return rendered
|
|
275
|
+
|
|
276
|
+
if node.kind == "insight":
|
|
277
|
+
kind_color = PASTEL_RED
|
|
278
|
+
elif node.kind == "empirical":
|
|
279
|
+
kind_color = PASTEL_YELLOW
|
|
280
|
+
elif node.kind == "untyped":
|
|
281
|
+
kind_color = GRAY
|
|
282
|
+
else:
|
|
283
|
+
kind_color = ""
|
|
284
|
+
|
|
285
|
+
title_part = (
|
|
286
|
+
f"{mp_color}{node.title}{reset}" if has_multi_parent else node.title
|
|
287
|
+
)
|
|
288
|
+
kind_part = f"{kind_color}{kind_token}{reset}" if kind_color else kind_token
|
|
289
|
+
|
|
290
|
+
rendered = f"{title_part} {kind_part}"
|
|
291
|
+
if node.slug_name:
|
|
292
|
+
rendered += f" {GRAY}| {node.slug_name}{reset}"
|
|
293
|
+
if suffix:
|
|
294
|
+
rendered += f" {mp_color}{suffix}{reset}" if has_multi_parent else suffix
|
|
295
|
+
return rendered
|
|
296
|
+
|
|
297
|
+
# Iterative DFS avoids RecursionError on very deep trees.
|
|
298
|
+
stack: List[Tuple[Any, ...]] = [("enter", anchor, "", True, True)]
|
|
299
|
+
|
|
300
|
+
while stack:
|
|
301
|
+
frame = stack.pop()
|
|
302
|
+
tag = frame[0]
|
|
303
|
+
|
|
304
|
+
if tag == "exit":
|
|
305
|
+
_, node_id = frame
|
|
306
|
+
ancestry.remove(node_id)
|
|
307
|
+
continue
|
|
308
|
+
|
|
309
|
+
_, node_id, prefix, is_last, is_root = frame
|
|
310
|
+
if node_id in ancestry:
|
|
311
|
+
connector = "" if is_root else (BOX_LAST if is_last else BOX_MID)
|
|
312
|
+
yield f"{prefix}{connector}[cycle] {node_id}"
|
|
313
|
+
continue
|
|
314
|
+
|
|
315
|
+
if is_root:
|
|
316
|
+
yield line_for(node_id)
|
|
317
|
+
child_prefix = ""
|
|
318
|
+
else:
|
|
319
|
+
connector = BOX_LAST if is_last else BOX_MID
|
|
320
|
+
yield f"{prefix}{connector}{line_for(node_id)}"
|
|
321
|
+
child_prefix = prefix + (BOX_SPACE if is_last else BOX_PIPE)
|
|
322
|
+
|
|
323
|
+
# Dense DAGs can have many parent-paths to the same node.
|
|
324
|
+
# Expand each node subtree once, then show duplicate views without
|
|
325
|
+
# re-expanding descendants to prevent combinatorial memory/runtime blowups.
|
|
326
|
+
if node_id in expanded_nodes:
|
|
327
|
+
continue
|
|
328
|
+
expanded_nodes.add(node_id)
|
|
329
|
+
|
|
330
|
+
ancestry.add(node_id)
|
|
331
|
+
children = retained_children.get(node_id, ())
|
|
332
|
+
stack.append(("exit", node_id))
|
|
333
|
+
|
|
334
|
+
# Reverse push order to preserve original left-to-right DFS output.
|
|
335
|
+
for idx in range(len(children) - 1, -1, -1):
|
|
336
|
+
child_id = children[idx]
|
|
337
|
+
child_is_last = idx == (len(children) - 1)
|
|
338
|
+
stack.append(("enter", child_id, child_prefix, child_is_last, False))
|
|
339
|
+
|
|
340
|
+
yield ""
|
|
341
|
+
yield f"root: {node_map[anchor].title}"
|
|
342
|
+
yield f"node_count: {len(descendants)}"
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _build_arg_parser() -> argparse.ArgumentParser:
|
|
346
|
+
parser = argparse.ArgumentParser(
|
|
347
|
+
description="Render Flywheel get_node_tree JSON into terminal-ready tree text."
|
|
348
|
+
)
|
|
349
|
+
parser.add_argument(
|
|
350
|
+
"--input",
|
|
351
|
+
required=True,
|
|
352
|
+
help="Path to JSON input file, or '-' to read from stdin.",
|
|
353
|
+
)
|
|
354
|
+
parser.add_argument(
|
|
355
|
+
"--root-node",
|
|
356
|
+
default=None,
|
|
357
|
+
help="Optional anchor node id override. Defaults to anchor_node_id from input.",
|
|
358
|
+
)
|
|
359
|
+
parser.add_argument(
|
|
360
|
+
"--no-color",
|
|
361
|
+
action="store_true",
|
|
362
|
+
help="Disable ANSI color output.",
|
|
363
|
+
)
|
|
364
|
+
parser.add_argument(
|
|
365
|
+
"--palette",
|
|
366
|
+
default=None,
|
|
367
|
+
help="Optional path to palette JSON file with fields: colors (list), reset (string).",
|
|
368
|
+
)
|
|
369
|
+
return parser
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def main() -> int:
|
|
373
|
+
parser = _build_arg_parser()
|
|
374
|
+
args = parser.parse_args()
|
|
375
|
+
|
|
376
|
+
try:
|
|
377
|
+
payload = _read_json_from_path(args.input)
|
|
378
|
+
palette = Path(args.palette) if args.palette else None
|
|
379
|
+
rendered_lines = _iter_render_lines(
|
|
380
|
+
tree_payload=payload,
|
|
381
|
+
anchor_override=args.root_node,
|
|
382
|
+
use_color=not args.no_color,
|
|
383
|
+
palette_path=palette,
|
|
384
|
+
)
|
|
385
|
+
except Exception as exc: # noqa: BLE001
|
|
386
|
+
print(f"render_tree.py error: {exc}", file=sys.stderr)
|
|
387
|
+
return 1
|
|
388
|
+
|
|
389
|
+
try:
|
|
390
|
+
sys.stdout.reconfigure(encoding="utf-8") # type: ignore[attr-defined]
|
|
391
|
+
except Exception: # noqa: BLE001
|
|
392
|
+
pass
|
|
393
|
+
|
|
394
|
+
wrote_any = False
|
|
395
|
+
for line in rendered_lines:
|
|
396
|
+
if wrote_any:
|
|
397
|
+
sys.stdout.write("\n")
|
|
398
|
+
sys.stdout.write(line)
|
|
399
|
+
wrote_any = True
|
|
400
|
+
if wrote_any:
|
|
401
|
+
sys.stdout.write("\n")
|
|
402
|
+
|
|
403
|
+
return 0
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
if __name__ == "__main__":
|
|
407
|
+
raise SystemExit(main())
|