@flydocs/cli 0.5.0-beta.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 (134) hide show
  1. package/README.md +96 -0
  2. package/dist/cli.js +2666 -0
  3. package/package.json +32 -0
  4. package/template/.claude/CLAUDE.md +90 -0
  5. package/template/.claude/agents/README.md +19 -0
  6. package/template/.claude/agents/implementation-agent.md +29 -0
  7. package/template/.claude/agents/pm-agent.md +29 -0
  8. package/template/.claude/agents/research-agent.md +25 -0
  9. package/template/.claude/agents/review-agent.md +29 -0
  10. package/template/.claude/commands/activate.md +10 -0
  11. package/template/.claude/commands/attach.md +9 -0
  12. package/template/.claude/commands/block.md +10 -0
  13. package/template/.claude/commands/capture.md +10 -0
  14. package/template/.claude/commands/close.md +10 -0
  15. package/template/.claude/commands/flydocs-setup.md +598 -0
  16. package/template/.claude/commands/flydocs-update.md +27 -0
  17. package/template/.claude/commands/implement.md +10 -0
  18. package/template/.claude/commands/new-project.md +11 -0
  19. package/template/.claude/commands/project-update.md +10 -0
  20. package/template/.claude/commands/refine.md +10 -0
  21. package/template/.claude/commands/review.md +10 -0
  22. package/template/.claude/commands/start-session.md +10 -0
  23. package/template/.claude/commands/status.md +10 -0
  24. package/template/.claude/commands/validate.md +10 -0
  25. package/template/.claude/commands/wrap-session.md +10 -0
  26. package/template/.claude/settings.json +49 -0
  27. package/template/.claude/skills/README.md +293 -0
  28. package/template/.claude/skills/flydocs-cloud/SKILL.md +96 -0
  29. package/template/.claude/skills/flydocs-cloud/cursor-rule.mdc +50 -0
  30. package/template/.claude/skills/flydocs-cloud/scripts/assign.py +38 -0
  31. package/template/.claude/skills/flydocs-cloud/scripts/assign_cycle.py +44 -0
  32. package/template/.claude/skills/flydocs-cloud/scripts/assign_milestone.py +44 -0
  33. package/template/.claude/skills/flydocs-cloud/scripts/comment.py +39 -0
  34. package/template/.claude/skills/flydocs-cloud/scripts/create_issue.py +100 -0
  35. package/template/.claude/skills/flydocs-cloud/scripts/create_milestone.py +46 -0
  36. package/template/.claude/skills/flydocs-cloud/scripts/create_project.py +40 -0
  37. package/template/.claude/skills/flydocs-cloud/scripts/estimate.py +38 -0
  38. package/template/.claude/skills/flydocs-cloud/scripts/flydocs_api.py +277 -0
  39. package/template/.claude/skills/flydocs-cloud/scripts/get_issue.py +77 -0
  40. package/template/.claude/skills/flydocs-cloud/scripts/link.py +47 -0
  41. package/template/.claude/skills/flydocs-cloud/scripts/list_cycles.py +35 -0
  42. package/template/.claude/skills/flydocs-cloud/scripts/list_issues.py +105 -0
  43. package/template/.claude/skills/flydocs-cloud/scripts/list_milestones.py +40 -0
  44. package/template/.claude/skills/flydocs-cloud/scripts/list_projects.py +45 -0
  45. package/template/.claude/skills/flydocs-cloud/scripts/priority.py +38 -0
  46. package/template/.claude/skills/flydocs-cloud/scripts/project_update.py +59 -0
  47. package/template/.claude/skills/flydocs-cloud/scripts/transition.py +67 -0
  48. package/template/.claude/skills/flydocs-cloud/scripts/update_description.py +47 -0
  49. package/template/.claude/skills/flydocs-cloud/scripts/update_issue.py +111 -0
  50. package/template/.claude/skills/flydocs-context-graph/SKILL.md +87 -0
  51. package/template/.claude/skills/flydocs-context-graph/schema.md +78 -0
  52. package/template/.claude/skills/flydocs-context-graph/scripts/graph_build.py +299 -0
  53. package/template/.claude/skills/flydocs-context-graph/scripts/graph_context.py +338 -0
  54. package/template/.claude/skills/flydocs-context-graph/scripts/graph_query.py +191 -0
  55. package/template/.claude/skills/flydocs-context-graph/scripts/graph_session.py +161 -0
  56. package/template/.claude/skills/flydocs-context-graph/scripts/graph_update.py +194 -0
  57. package/template/.claude/skills/flydocs-context-graph/scripts/graph_utils.py +118 -0
  58. package/template/.claude/skills/flydocs-estimates/SKILL.md +384 -0
  59. package/template/.claude/skills/flydocs-estimates/references/provider-costs.md +152 -0
  60. package/template/.claude/skills/flydocs-figma/SKILL.md +377 -0
  61. package/template/.claude/skills/flydocs-figma/references/PROMPTING.md +108 -0
  62. package/template/.claude/skills/flydocs-figma/references/TROUBLESHOOTING.md +112 -0
  63. package/template/.claude/skills/flydocs-local/SKILL.md +103 -0
  64. package/template/.claude/skills/flydocs-local/cursor-rule.mdc +43 -0
  65. package/template/.claude/skills/flydocs-local/scripts/assign.py +20 -0
  66. package/template/.claude/skills/flydocs-local/scripts/comment.py +27 -0
  67. package/template/.claude/skills/flydocs-local/scripts/create_issue.py +44 -0
  68. package/template/.claude/skills/flydocs-local/scripts/estimate.py +37 -0
  69. package/template/.claude/skills/flydocs-local/scripts/flydocs_api.py +272 -0
  70. package/template/.claude/skills/flydocs-local/scripts/get_issue.py +20 -0
  71. package/template/.claude/skills/flydocs-local/scripts/link.py +41 -0
  72. package/template/.claude/skills/flydocs-local/scripts/list_issues.py +34 -0
  73. package/template/.claude/skills/flydocs-local/scripts/priority.py +37 -0
  74. package/template/.claude/skills/flydocs-local/scripts/project_update.py +67 -0
  75. package/template/.claude/skills/flydocs-local/scripts/status_summary.py +16 -0
  76. package/template/.claude/skills/flydocs-local/scripts/transition.py +24 -0
  77. package/template/.claude/skills/flydocs-local/scripts/update_description.py +35 -0
  78. package/template/.claude/skills/flydocs-local/scripts/update_issue.py +84 -0
  79. package/template/.claude/skills/flydocs-workflow/SKILL.md +85 -0
  80. package/template/.claude/skills/flydocs-workflow/cursor-rule.mdc +53 -0
  81. package/template/.claude/skills/flydocs-workflow/reference/comment-templates.md +131 -0
  82. package/template/.claude/skills/flydocs-workflow/reference/golden-rules.md +76 -0
  83. package/template/.claude/skills/flydocs-workflow/reference/priority-estimates.md +28 -0
  84. package/template/.claude/skills/flydocs-workflow/reference/status-workflow.md +50 -0
  85. package/template/.claude/skills/flydocs-workflow/session.md +128 -0
  86. package/template/.claude/skills/flydocs-workflow/stages/activate.md +46 -0
  87. package/template/.claude/skills/flydocs-workflow/stages/capture.md +50 -0
  88. package/template/.claude/skills/flydocs-workflow/stages/close.md +32 -0
  89. package/template/.claude/skills/flydocs-workflow/stages/implement.md +124 -0
  90. package/template/.claude/skills/flydocs-workflow/stages/refine.md +51 -0
  91. package/template/.claude/skills/flydocs-workflow/stages/review.md +86 -0
  92. package/template/.claude/skills/flydocs-workflow/stages/validate.md +90 -0
  93. package/template/.claude/skills/flydocs-workflow/templates/bug.md +95 -0
  94. package/template/.claude/skills/flydocs-workflow/templates/chore.md +75 -0
  95. package/template/.claude/skills/flydocs-workflow/templates/feature.md +93 -0
  96. package/template/.claude/skills/flydocs-workflow/templates/idea.md +84 -0
  97. package/template/.cursor/agents/implementation-agent.md +28 -0
  98. package/template/.cursor/agents/pm-agent.md +27 -0
  99. package/template/.cursor/agents/research-agent.md +23 -0
  100. package/template/.cursor/agents/review-agent.md +27 -0
  101. package/template/.cursor/hooks.json +29 -0
  102. package/template/.cursor/mcp.json +16 -0
  103. package/template/.env.example +44 -0
  104. package/template/.flydocs/config.json +104 -0
  105. package/template/.flydocs/hooks/auto-approve.py +71 -0
  106. package/template/.flydocs/hooks/post-edit.py +72 -0
  107. package/template/.flydocs/hooks/prefer-scripts.py +89 -0
  108. package/template/.flydocs/hooks/prompt-submit.py +277 -0
  109. package/template/.flydocs/scripts/generate_manifest.py +287 -0
  110. package/template/.flydocs/scripts/skill_manager.py +541 -0
  111. package/template/.flydocs/templates/README.md +46 -0
  112. package/template/.flydocs/templates/bug.md +166 -0
  113. package/template/.flydocs/templates/chore.md +110 -0
  114. package/template/.flydocs/templates/design-system/README.md +27 -0
  115. package/template/.flydocs/templates/design-system/component-patterns.md +92 -0
  116. package/template/.flydocs/templates/design-system/token-mapping.md +168 -0
  117. package/template/.flydocs/templates/feature.md +173 -0
  118. package/template/.flydocs/templates/idea.md +122 -0
  119. package/template/.flydocs/templates/instructions.md +228 -0
  120. package/template/.flydocs/templates/quick-capture.md +35 -0
  121. package/template/.flydocs/templates/scripts/check-design-system.template.mjs +179 -0
  122. package/template/.flydocs/version +1 -0
  123. package/template/AGENTS.md +95 -0
  124. package/template/CHANGELOG.md +271 -0
  125. package/template/flydocs/README.md +186 -0
  126. package/template/flydocs/context/project.md +51 -0
  127. package/template/flydocs/design-system/README.md +126 -0
  128. package/template/flydocs/design-system/component-patterns.md +173 -0
  129. package/template/flydocs/design-system/token-mapping.md +114 -0
  130. package/template/flydocs/knowledge/INDEX.md +100 -0
  131. package/template/flydocs/knowledge/README.md +62 -0
  132. package/template/flydocs/knowledge/product/personas.md +79 -0
  133. package/template/flydocs/knowledge/product/user-flows.md +88 -0
  134. package/template/manifest.json +221 -0
@@ -0,0 +1,191 @@
1
+ #!/usr/bin/env python3
2
+ """Query the context graph via BFS traversal.
3
+
4
+ Returns related nodes for a given starting node, filtered by depth and
5
+ relationship type. Output as compressed markdown or JSON.
6
+
7
+ Usage:
8
+ python3 .claude/skills/flydocs-context-graph/scripts/graph_query.py \\
9
+ --node decision:001 [--depth 2] [--rel EXTENDS] [--reverse] [--format md|json]
10
+ """
11
+
12
+ import argparse
13
+ import sys
14
+ from collections import deque
15
+ from pathlib import Path
16
+
17
+ sys.path.insert(0, str(Path(__file__).parent))
18
+ from graph_utils import find_project_root, load_graph, output_json, fail
19
+
20
+
21
+ def bfs_traverse(graph, start_node, max_depth, rel_filter, reverse):
22
+ """BFS traversal from start_node, returning discovered nodes with paths.
23
+
24
+ Returns list of (node_id, depth, edge_rel, via_node) tuples.
25
+ """
26
+ nodes = graph.get("nodes", {})
27
+ edges = graph.get("edges", [])
28
+
29
+ if start_node not in nodes:
30
+ return []
31
+
32
+ # Build adjacency from edges
33
+ adjacency = {}
34
+ for edge in edges:
35
+ if rel_filter and edge["rel"] not in rel_filter:
36
+ continue
37
+
38
+ if reverse:
39
+ src, dst = edge["to"], edge["from"]
40
+ else:
41
+ src, dst = edge["from"], edge["to"]
42
+
43
+ if src not in adjacency:
44
+ adjacency[src] = []
45
+ adjacency[src].append((dst, edge["rel"], edge.get("weight", 1.0)))
46
+
47
+ # BFS
48
+ visited = {start_node}
49
+ queue = deque()
50
+ results = []
51
+
52
+ for neighbor, rel, weight in adjacency.get(start_node, []):
53
+ if neighbor not in visited:
54
+ queue.append((neighbor, 1, rel, start_node))
55
+ visited.add(neighbor)
56
+
57
+ while queue:
58
+ node_id, depth, rel, via = queue.popleft()
59
+ results.append((node_id, depth, rel, via))
60
+
61
+ if depth < max_depth:
62
+ for neighbor, next_rel, weight in adjacency.get(node_id, []):
63
+ if neighbor not in visited:
64
+ queue.append((neighbor, depth + 1, next_rel, node_id))
65
+ visited.add(neighbor)
66
+
67
+ return results
68
+
69
+
70
+ def format_markdown(graph, start_node, results):
71
+ """Format traversal results as compressed markdown context block."""
72
+ nodes = graph.get("nodes", {})
73
+ start_info = nodes.get(start_node, {})
74
+ label = start_info.get("label", start_node)
75
+
76
+ lines = [f"## Context for: {label}"]
77
+ lines.append("")
78
+
79
+ if not results:
80
+ lines.append("No related nodes found.")
81
+ return "\n".join(lines)
82
+
83
+ # Group results by node type
84
+ by_type = {}
85
+ for node_id, depth, rel, via in results:
86
+ node = nodes.get(node_id, {})
87
+ node_type = node.get("type", "unknown")
88
+ if node_type not in by_type:
89
+ by_type[node_type] = []
90
+ by_type[node_type].append((node_id, node, rel, depth))
91
+
92
+ # Type display order
93
+ type_labels = {
94
+ "decision": "Decisions",
95
+ "skill": "Skills",
96
+ "module": "Modules",
97
+ "issue": "Issues",
98
+ "session": "Sessions",
99
+ "concept": "Concepts",
100
+ }
101
+
102
+ for node_type in ["decision", "skill", "module", "issue", "session", "concept"]:
103
+ entries = by_type.get(node_type, [])
104
+ if not entries:
105
+ continue
106
+
107
+ type_label = type_labels.get(node_type, node_type.title())
108
+ lines.append(f"**{type_label}:**")
109
+
110
+ for node_id, node, rel, depth in entries:
111
+ node_label = node.get("label", node_id)
112
+ status = node.get("status", "")
113
+ status_str = f" [{status}]" if status else ""
114
+ lines.append(f"- {node_id}: {node_label} ({rel}){status_str}")
115
+
116
+ lines.append("")
117
+
118
+ return "\n".join(lines)
119
+
120
+
121
+ def format_json(graph, start_node, results):
122
+ """Format traversal results as JSON."""
123
+ nodes = graph.get("nodes", {})
124
+
125
+ related = []
126
+ for node_id, depth, rel, via in results:
127
+ node = nodes.get(node_id, {})
128
+ related.append({
129
+ "id": node_id,
130
+ "type": node.get("type", "unknown"),
131
+ "label": node.get("label", node_id),
132
+ "relationship": rel,
133
+ "depth": depth,
134
+ "via": via,
135
+ })
136
+
137
+ return {
138
+ "node": start_node,
139
+ "label": nodes.get(start_node, {}).get("label", start_node),
140
+ "related": related,
141
+ }
142
+
143
+
144
+ def main():
145
+ parser = argparse.ArgumentParser(description="Query the context graph")
146
+ parser.add_argument(
147
+ "--node", required=True,
148
+ help="Starting node ID (e.g., decision:001, skill:typescript-strict)"
149
+ )
150
+ parser.add_argument(
151
+ "--depth", type=int, default=2,
152
+ help="BFS traversal depth (default: 2)"
153
+ )
154
+ parser.add_argument(
155
+ "--rel", action="append", dest="rels",
156
+ help="Filter by relationship type (can specify multiple)"
157
+ )
158
+ parser.add_argument(
159
+ "--reverse", action="store_true",
160
+ help="Follow edges in reverse direction"
161
+ )
162
+ parser.add_argument(
163
+ "--format", choices=["md", "json"], default="md", dest="fmt",
164
+ help="Output format (default: md)"
165
+ )
166
+ parser.add_argument(
167
+ "--root", type=str, default=None,
168
+ help="Project root (default: auto-detect)"
169
+ )
170
+ args = parser.parse_args()
171
+
172
+ root = Path(args.root) if args.root else find_project_root()
173
+ if not root:
174
+ fail("Could not find project root (no .flydocs/ directory found)")
175
+
176
+ graph = load_graph(root)
177
+
178
+ if args.node not in graph.get("nodes", {}):
179
+ fail(f"Node not found: {args.node}")
180
+
181
+ rel_filter = set(args.rels) if args.rels else None
182
+ results = bfs_traverse(graph, args.node, args.depth, rel_filter, args.reverse)
183
+
184
+ if args.fmt == "json":
185
+ output_json(format_json(graph, args.node, results))
186
+ else:
187
+ print(format_markdown(graph, args.node, results))
188
+
189
+
190
+ if __name__ == "__main__":
191
+ main()
@@ -0,0 +1,161 @@
1
+ #!/usr/bin/env python3
2
+ """Record session outcomes in the context graph.
3
+
4
+ Called during session wrap to create a session node with edges to issues
5
+ worked on and decisions produced. Supports cross-session continuity by
6
+ building the temporal layer that graph_context.py traverses.
7
+
8
+ Usage:
9
+ python3 .claude/skills/flydocs-context-graph/scripts/graph_session.py \
10
+ --summary "Completed Phase 1, started Phase 2" \
11
+ [--issue FLY-56] [--issue FLY-174] \
12
+ [--decision 006] \
13
+ [--date 2026-02-17] \
14
+ [--root PATH]
15
+
16
+ Multiple --issue and --decision flags can be provided.
17
+ """
18
+
19
+ import argparse
20
+ import sys
21
+ from datetime import date
22
+ from pathlib import Path
23
+
24
+ sys.path.insert(0, str(Path(__file__).parent))
25
+ from graph_utils import (
26
+ find_project_root,
27
+ load_graph,
28
+ save_graph,
29
+ output_json,
30
+ fail,
31
+ )
32
+
33
+
34
+ # Session nodes older than this get reduced weight in graph_context.py
35
+ DEFAULT_RETENTION_DAYS = 30
36
+
37
+
38
+ def generate_session_id(session_date):
39
+ """Generate a session ID like session:2026-02-17-a.
40
+
41
+ If a session with the same date exists, increment the sequence letter.
42
+ """
43
+ base = f"session:{session_date}"
44
+ return base
45
+
46
+
47
+ def find_next_session_id(graph, session_date):
48
+ """Find the next available session ID for a given date."""
49
+ nodes = graph.get("nodes", {})
50
+ base = f"session:{session_date}"
51
+
52
+ # Check if base ID exists, if so try a, b, c...
53
+ if base not in nodes:
54
+ return base
55
+
56
+ for suffix in "abcdefghijklmnopqrstuvwxyz":
57
+ candidate = f"{base}-{suffix}"
58
+ if candidate not in nodes:
59
+ return candidate
60
+
61
+ # Fallback
62
+ return f"{base}-z2"
63
+
64
+
65
+ def main():
66
+ parser = argparse.ArgumentParser(
67
+ description="Record session outcomes in the context graph"
68
+ )
69
+ parser.add_argument(
70
+ "--summary", required=True,
71
+ help="Brief session summary (1-2 sentences)"
72
+ )
73
+ parser.add_argument(
74
+ "--issue", action="append", dest="issues", default=[],
75
+ help="Issue identifier worked on (can specify multiple)"
76
+ )
77
+ parser.add_argument(
78
+ "--decision", action="append", dest="decisions", default=[],
79
+ help="ADR number produced/referenced (can specify multiple)"
80
+ )
81
+ parser.add_argument(
82
+ "--date", type=str, default=None,
83
+ help="Session date (YYYY-MM-DD, default: today)"
84
+ )
85
+ parser.add_argument(
86
+ "--root", type=str, default=None,
87
+ help="Project root (default: auto-detect)"
88
+ )
89
+ args = parser.parse_args()
90
+
91
+ root = Path(args.root) if args.root else find_project_root()
92
+ if not root:
93
+ fail("Could not find project root (no .flydocs/ directory found)")
94
+
95
+ graph = load_graph(root)
96
+
97
+ session_date = args.date or date.today().isoformat()
98
+ session_id = find_next_session_id(graph, session_date)
99
+
100
+ # Create session node
101
+ graph["nodes"][session_id] = {
102
+ "type": "session",
103
+ "label": args.summary,
104
+ "date": session_date,
105
+ "manual": True,
106
+ }
107
+
108
+ edges_added = []
109
+
110
+ # Create WORKED_ON edges to issues
111
+ for issue_ref in args.issues:
112
+ issue_node = f"issue:{issue_ref}"
113
+
114
+ # Create issue node if it doesn't exist
115
+ if issue_node not in graph["nodes"]:
116
+ graph["nodes"][issue_node] = {
117
+ "type": "issue",
118
+ "label": issue_ref,
119
+ "manual": True,
120
+ }
121
+
122
+ edge = {
123
+ "from": session_id,
124
+ "to": issue_node,
125
+ "rel": "WORKED_ON",
126
+ "weight": 1.0,
127
+ "manual": True,
128
+ }
129
+ graph["edges"].append(edge)
130
+ edges_added.append(f"{session_id} --WORKED_ON--> {issue_node}")
131
+
132
+ # Create PRODUCED edges to decisions
133
+ for decision_num in args.decisions:
134
+ decision_node = f"decision:{decision_num.zfill(3)}"
135
+
136
+ edge = {
137
+ "from": session_id,
138
+ "to": decision_node,
139
+ "rel": "PRODUCED",
140
+ "weight": 1.0,
141
+ "manual": True,
142
+ }
143
+
144
+ # Only add if target exists
145
+ if decision_node in graph["nodes"]:
146
+ graph["edges"].append(edge)
147
+ edges_added.append(f"{session_id} --PRODUCED--> {decision_node}")
148
+
149
+ save_graph(root, graph)
150
+
151
+ output_json({
152
+ "success": True,
153
+ "sessionId": session_id,
154
+ "date": session_date,
155
+ "summary": args.summary,
156
+ "edges": edges_added,
157
+ })
158
+
159
+
160
+ if __name__ == "__main__":
161
+ main()
@@ -0,0 +1,194 @@
1
+ #!/usr/bin/env python3
2
+ """Incrementally add or remove nodes and edges in the context graph.
3
+
4
+ Usage:
5
+ python3 .claude/skills/flydocs-context-graph/scripts/graph_update.py \\
6
+ add-node <ID> --type <TYPE> [--label STR] [--path STR]
7
+
8
+ python3 .claude/skills/flydocs-context-graph/scripts/graph_update.py \\
9
+ remove-node <ID>
10
+
11
+ python3 .claude/skills/flydocs-context-graph/scripts/graph_update.py \\
12
+ add-edge <FROM> <TO> <REL> [--weight N] [--manual]
13
+
14
+ python3 .claude/skills/flydocs-context-graph/scripts/graph_update.py \\
15
+ remove-edge <FROM> <TO> <REL>
16
+ """
17
+
18
+ import argparse
19
+ import sys
20
+ from pathlib import Path
21
+
22
+ sys.path.insert(0, str(Path(__file__).parent))
23
+ from graph_utils import (
24
+ find_project_root,
25
+ load_graph,
26
+ save_graph,
27
+ output_json,
28
+ fail,
29
+ VALID_NODE_TYPES,
30
+ VALID_REL_TYPES,
31
+ )
32
+
33
+
34
+ def cmd_add_node(args, graph):
35
+ """Add a node to the graph."""
36
+ node_id = args.id
37
+ if args.type not in VALID_NODE_TYPES:
38
+ fail(f"Invalid node type: {args.type}. Valid: {', '.join(sorted(VALID_NODE_TYPES))}")
39
+
40
+ node = {"type": args.type}
41
+ if args.label:
42
+ node["label"] = args.label
43
+ else:
44
+ # Default label from ID
45
+ node["label"] = node_id.split(":", 1)[-1] if ":" in node_id else node_id
46
+ if args.path:
47
+ node["path"] = args.path
48
+ if args.status:
49
+ node["status"] = args.status
50
+ if args.date:
51
+ node["date"] = args.date
52
+
53
+ node["manual"] = True
54
+
55
+ graph["nodes"][node_id] = node
56
+ return {"success": True, "action": "add-node", "node": node_id}
57
+
58
+
59
+ def cmd_remove_node(args, graph):
60
+ """Remove a node and its connected edges from the graph."""
61
+ node_id = args.id
62
+ if node_id not in graph["nodes"]:
63
+ fail(f"Node not found: {node_id}")
64
+
65
+ del graph["nodes"][node_id]
66
+
67
+ # Remove connected edges
68
+ before = len(graph["edges"])
69
+ graph["edges"] = [
70
+ e for e in graph["edges"]
71
+ if e["from"] != node_id and e["to"] != node_id
72
+ ]
73
+ removed_edges = before - len(graph["edges"])
74
+
75
+ return {
76
+ "success": True,
77
+ "action": "remove-node",
78
+ "removed": node_id,
79
+ "removedEdges": removed_edges,
80
+ }
81
+
82
+
83
+ def cmd_add_edge(args, graph):
84
+ """Add an edge to the graph."""
85
+ if args.rel not in VALID_REL_TYPES:
86
+ fail(f"Invalid relationship: {args.rel}. Valid: {', '.join(sorted(VALID_REL_TYPES))}")
87
+
88
+ # Validate nodes exist
89
+ if args.source not in graph["nodes"]:
90
+ fail(f"Source node not found: {args.source}")
91
+ if args.target not in graph["nodes"]:
92
+ fail(f"Target node not found: {args.target}")
93
+
94
+ # Check for duplicate
95
+ for edge in graph["edges"]:
96
+ if edge["from"] == args.source and edge["to"] == args.target and edge["rel"] == args.rel:
97
+ fail(f"Edge already exists: {args.source} --{args.rel}--> {args.target}")
98
+
99
+ edge = {
100
+ "from": args.source,
101
+ "to": args.target,
102
+ "rel": args.rel,
103
+ "weight": args.weight,
104
+ }
105
+ if args.manual:
106
+ edge["manual"] = True
107
+
108
+ graph["edges"].append(edge)
109
+
110
+ return {
111
+ "success": True,
112
+ "action": "add-edge",
113
+ "edge": f"{args.source} --{args.rel}--> {args.target}",
114
+ }
115
+
116
+
117
+ def cmd_remove_edge(args, graph):
118
+ """Remove an edge from the graph."""
119
+ before = len(graph["edges"])
120
+ graph["edges"] = [
121
+ e for e in graph["edges"]
122
+ if not (e["from"] == args.source and e["to"] == args.target and e["rel"] == args.rel)
123
+ ]
124
+
125
+ if len(graph["edges"]) == before:
126
+ fail(f"Edge not found: {args.source} --{args.rel}--> {args.target}")
127
+
128
+ return {
129
+ "success": True,
130
+ "action": "remove-edge",
131
+ "removed": f"{args.source} --{args.rel}--> {args.target}",
132
+ }
133
+
134
+
135
+ def main():
136
+ parser = argparse.ArgumentParser(description="Update the context graph")
137
+ parser.add_argument(
138
+ "--root", type=str, default=None,
139
+ help="Project root (default: auto-detect)"
140
+ )
141
+ subparsers = parser.add_subparsers(dest="command", required=True)
142
+
143
+ # add-node
144
+ p_add_node = subparsers.add_parser("add-node", help="Add a node")
145
+ p_add_node.add_argument("id", help="Node ID (e.g., module:auth)")
146
+ p_add_node.add_argument("--type", required=True, help="Node type")
147
+ p_add_node.add_argument("--label", help="Human-readable label")
148
+ p_add_node.add_argument("--path", help="File path relative to project root")
149
+ p_add_node.add_argument("--status", help="Node status")
150
+ p_add_node.add_argument("--date", help="Date for temporal nodes (ISO format)")
151
+
152
+ # remove-node
153
+ p_rm_node = subparsers.add_parser("remove-node", help="Remove a node")
154
+ p_rm_node.add_argument("id", help="Node ID to remove")
155
+
156
+ # add-edge
157
+ p_add_edge = subparsers.add_parser("add-edge", help="Add an edge")
158
+ p_add_edge.add_argument("source", help="Source node ID")
159
+ p_add_edge.add_argument("target", help="Target node ID")
160
+ p_add_edge.add_argument("rel", help="Relationship type (e.g., EXTENDS)")
161
+ p_add_edge.add_argument("--weight", type=float, default=0.8, help="Edge weight 0.0-1.0")
162
+ p_add_edge.add_argument("--manual", action="store_true", help="Mark as manually added")
163
+
164
+ # remove-edge
165
+ p_rm_edge = subparsers.add_parser("remove-edge", help="Remove an edge")
166
+ p_rm_edge.add_argument("source", help="Source node ID")
167
+ p_rm_edge.add_argument("target", help="Target node ID")
168
+ p_rm_edge.add_argument("rel", help="Relationship type")
169
+
170
+ args = parser.parse_args()
171
+
172
+ root = Path(args.root) if args.root else find_project_root()
173
+ if not root:
174
+ fail("Could not find project root (no .flydocs/ directory found)")
175
+
176
+ graph = load_graph(root)
177
+
178
+ if args.command == "add-node":
179
+ result = cmd_add_node(args, graph)
180
+ elif args.command == "remove-node":
181
+ result = cmd_remove_node(args, graph)
182
+ elif args.command == "add-edge":
183
+ result = cmd_add_edge(args, graph)
184
+ elif args.command == "remove-edge":
185
+ result = cmd_remove_edge(args, graph)
186
+ else:
187
+ fail(f"Unknown command: {args.command}")
188
+
189
+ save_graph(root, graph)
190
+ output_json(result)
191
+
192
+
193
+ if __name__ == "__main__":
194
+ main()
@@ -0,0 +1,118 @@
1
+ #!/usr/bin/env python3
2
+ """Shared utilities for context graph scripts."""
3
+
4
+ import json
5
+ import os
6
+ import re
7
+ import sys
8
+ from datetime import datetime, timezone
9
+ from pathlib import Path
10
+
11
+
12
+ GRAPH_REL_PATH = os.path.join("flydocs", "context", "graph.json")
13
+
14
+ VALID_NODE_TYPES = {"skill", "decision", "issue", "module", "session", "concept"}
15
+ VALID_REL_TYPES = {
16
+ "EXTENDS", "IMPLEMENTS", "DELEGATES_TO", "PRECEDES", "MODIFIES",
17
+ "WORKED_ON", "PRODUCED", "RELATES_TO", "SUPERSEDES", "BLOCKS",
18
+ }
19
+
20
+
21
+ def find_project_root(start=None):
22
+ """Walk up from start (or cwd) to find the directory containing .flydocs/."""
23
+ current = Path(start) if start else Path.cwd()
24
+ for parent in [current] + list(current.parents):
25
+ if (parent / ".flydocs").is_dir():
26
+ return parent
27
+ return None
28
+
29
+
30
+ def graph_path(root):
31
+ """Return the absolute path to graph.json."""
32
+ return os.path.join(str(root), GRAPH_REL_PATH)
33
+
34
+
35
+ def empty_graph():
36
+ """Return a new empty graph structure."""
37
+ return {
38
+ "version": 1,
39
+ "updated": datetime.now(timezone.utc).isoformat(),
40
+ "nodes": {},
41
+ "edges": [],
42
+ }
43
+
44
+
45
+ def load_graph(root):
46
+ """Load graph.json from the project root. Returns empty graph if missing."""
47
+ path = graph_path(root)
48
+ if not os.path.exists(path):
49
+ return empty_graph()
50
+ with open(path, "r", encoding="utf-8") as f:
51
+ return json.load(f)
52
+
53
+
54
+ def save_graph(root, graph):
55
+ """Write graph.json to the project root. Creates parent dirs if needed."""
56
+ path = graph_path(root)
57
+ os.makedirs(os.path.dirname(path), exist_ok=True)
58
+ graph["updated"] = datetime.now(timezone.utc).isoformat()
59
+ with open(path, "w", encoding="utf-8") as f:
60
+ json.dump(graph, f, indent=2)
61
+ f.write("\n")
62
+
63
+
64
+ def output_json(data):
65
+ """Print JSON to stdout."""
66
+ print(json.dumps(data, indent=2))
67
+
68
+
69
+ def fail(message):
70
+ """Print error to stderr and exit 1."""
71
+ print(message, file=sys.stderr)
72
+ sys.exit(1)
73
+
74
+
75
+ def parse_frontmatter(text):
76
+ """Parse YAML frontmatter from a SKILL.md file (no PyYAML dependency).
77
+
78
+ Returns dict with 'name', 'description', 'triggers' keys or None.
79
+ """
80
+ match = re.match(r"^---\s*\n(.*?)\n---", text, re.DOTALL)
81
+ if not match:
82
+ return None
83
+
84
+ block = match.group(1)
85
+ result = {}
86
+ current_key = None
87
+ current_list = None
88
+
89
+ for line in block.split("\n"):
90
+ # List item
91
+ if re.match(r"^\s+-\s+", line) and current_key:
92
+ value = re.sub(r"^\s+-\s+", "", line).strip()
93
+ if current_list is None:
94
+ current_list = []
95
+ current_list.append(value)
96
+ result[current_key] = current_list
97
+ continue
98
+
99
+ # Key: value
100
+ kv = re.match(r"^(\w+)\s*:\s*(.*)", line)
101
+ if kv:
102
+ if current_list is not None:
103
+ current_list = None
104
+ current_key = kv.group(1)
105
+ value = kv.group(2).strip().rstrip("|").rstrip(">").strip()
106
+ if value:
107
+ result[current_key] = value
108
+ continue
109
+
110
+ # Continuation of block scalar
111
+ if current_key and current_key in result and isinstance(result[current_key], str):
112
+ result[current_key] = result[current_key] + " " + line.strip()
113
+
114
+ # Clean up description whitespace
115
+ if "description" in result and isinstance(result["description"], str):
116
+ result["description"] = re.sub(r"\s+", " ", result["description"]).strip()
117
+
118
+ return result