@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.
Files changed (31) hide show
  1. package/package.json +1 -1
  2. package/skills/flywheel/example-workflows/organizing-exploring-and-iterating-on-a-research-topic.md +11 -11
  3. package/skills/flywheel/example-workflows/reproducing-papers-on-a-budget.md +8 -8
  4. package/skills/flywheel/references/ARTIFACTS.md +48 -0
  5. package/skills/flywheel/references/INTERFACES.md +206 -0
  6. package/skills/flywheel/references/experiment-design-protocol.md +5 -5
  7. package/skills/flywheel/references/flywheel-mcp-tool-map.md +73 -59
  8. package/skills/flywheel-auto/SKILL.md +4 -4
  9. package/skills/flywheel-auto/references/ARTIFACTS.md +1 -1
  10. package/skills/flywheel-auto/references/INTERFACES.md +60 -58
  11. package/skills/flywheel-auto/references/experiment-design-protocol.md +5 -5
  12. package/skills/flywheel-auto/references/flywheel-mcp-tool-map.md +73 -59
  13. package/skills/flywheel-lookahead/references/ARTIFACTS.md +1 -1
  14. package/skills/flywheel-lookahead/references/INTERFACES.md +60 -58
  15. package/skills/flywheel-lookahead/references/flywheel-mcp-tool-map.md +73 -59
  16. package/skills/flywheel-prove/SKILL.md +8 -0
  17. package/skills/flywheel-reproduce/SKILL.md +4 -4
  18. package/skills/flywheel-reproduce/references/ARTIFACTS.md +1 -1
  19. package/skills/flywheel-reproduce/references/INTERFACES.md +60 -58
  20. package/skills/flywheel-reproduce/references/experiment-design-protocol.md +5 -5
  21. package/skills/flywheel-reproduce/references/flywheel-mcp-tool-map.md +73 -59
  22. package/skills/flywheel-to-graph/SKILL.md +4 -4
  23. package/skills/flywheel-to-graph/references/ARTIFACTS.md +1 -1
  24. package/skills/flywheel-to-graph/references/INTERFACES.md +60 -58
  25. package/skills/flywheel-to-graph/references/flywheel-mcp-tool-map.md +73 -59
  26. package/skills/flywheel-tree/SKILL.md +61 -0
  27. package/skills/flywheel-tree/agents/interface.yaml +4 -0
  28. package/skills/flywheel-tree/assets/ansi_palette.json +29 -0
  29. package/skills/flywheel-tree/references/workflow.md +108 -0
  30. package/skills/flywheel-tree/scripts/render_tree.py +407 -0
  31. 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())