@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.
- package/README.md +96 -0
- package/dist/cli.js +2666 -0
- package/package.json +32 -0
- package/template/.claude/CLAUDE.md +90 -0
- package/template/.claude/agents/README.md +19 -0
- package/template/.claude/agents/implementation-agent.md +29 -0
- package/template/.claude/agents/pm-agent.md +29 -0
- package/template/.claude/agents/research-agent.md +25 -0
- package/template/.claude/agents/review-agent.md +29 -0
- package/template/.claude/commands/activate.md +10 -0
- package/template/.claude/commands/attach.md +9 -0
- package/template/.claude/commands/block.md +10 -0
- package/template/.claude/commands/capture.md +10 -0
- package/template/.claude/commands/close.md +10 -0
- package/template/.claude/commands/flydocs-setup.md +598 -0
- package/template/.claude/commands/flydocs-update.md +27 -0
- package/template/.claude/commands/implement.md +10 -0
- package/template/.claude/commands/new-project.md +11 -0
- package/template/.claude/commands/project-update.md +10 -0
- package/template/.claude/commands/refine.md +10 -0
- package/template/.claude/commands/review.md +10 -0
- package/template/.claude/commands/start-session.md +10 -0
- package/template/.claude/commands/status.md +10 -0
- package/template/.claude/commands/validate.md +10 -0
- package/template/.claude/commands/wrap-session.md +10 -0
- package/template/.claude/settings.json +49 -0
- package/template/.claude/skills/README.md +293 -0
- package/template/.claude/skills/flydocs-cloud/SKILL.md +96 -0
- package/template/.claude/skills/flydocs-cloud/cursor-rule.mdc +50 -0
- package/template/.claude/skills/flydocs-cloud/scripts/assign.py +38 -0
- package/template/.claude/skills/flydocs-cloud/scripts/assign_cycle.py +44 -0
- package/template/.claude/skills/flydocs-cloud/scripts/assign_milestone.py +44 -0
- package/template/.claude/skills/flydocs-cloud/scripts/comment.py +39 -0
- package/template/.claude/skills/flydocs-cloud/scripts/create_issue.py +100 -0
- package/template/.claude/skills/flydocs-cloud/scripts/create_milestone.py +46 -0
- package/template/.claude/skills/flydocs-cloud/scripts/create_project.py +40 -0
- package/template/.claude/skills/flydocs-cloud/scripts/estimate.py +38 -0
- package/template/.claude/skills/flydocs-cloud/scripts/flydocs_api.py +277 -0
- package/template/.claude/skills/flydocs-cloud/scripts/get_issue.py +77 -0
- package/template/.claude/skills/flydocs-cloud/scripts/link.py +47 -0
- package/template/.claude/skills/flydocs-cloud/scripts/list_cycles.py +35 -0
- package/template/.claude/skills/flydocs-cloud/scripts/list_issues.py +105 -0
- package/template/.claude/skills/flydocs-cloud/scripts/list_milestones.py +40 -0
- package/template/.claude/skills/flydocs-cloud/scripts/list_projects.py +45 -0
- package/template/.claude/skills/flydocs-cloud/scripts/priority.py +38 -0
- package/template/.claude/skills/flydocs-cloud/scripts/project_update.py +59 -0
- package/template/.claude/skills/flydocs-cloud/scripts/transition.py +67 -0
- package/template/.claude/skills/flydocs-cloud/scripts/update_description.py +47 -0
- package/template/.claude/skills/flydocs-cloud/scripts/update_issue.py +111 -0
- package/template/.claude/skills/flydocs-context-graph/SKILL.md +87 -0
- package/template/.claude/skills/flydocs-context-graph/schema.md +78 -0
- package/template/.claude/skills/flydocs-context-graph/scripts/graph_build.py +299 -0
- package/template/.claude/skills/flydocs-context-graph/scripts/graph_context.py +338 -0
- package/template/.claude/skills/flydocs-context-graph/scripts/graph_query.py +191 -0
- package/template/.claude/skills/flydocs-context-graph/scripts/graph_session.py +161 -0
- package/template/.claude/skills/flydocs-context-graph/scripts/graph_update.py +194 -0
- package/template/.claude/skills/flydocs-context-graph/scripts/graph_utils.py +118 -0
- package/template/.claude/skills/flydocs-estimates/SKILL.md +384 -0
- package/template/.claude/skills/flydocs-estimates/references/provider-costs.md +152 -0
- package/template/.claude/skills/flydocs-figma/SKILL.md +377 -0
- package/template/.claude/skills/flydocs-figma/references/PROMPTING.md +108 -0
- package/template/.claude/skills/flydocs-figma/references/TROUBLESHOOTING.md +112 -0
- package/template/.claude/skills/flydocs-local/SKILL.md +103 -0
- package/template/.claude/skills/flydocs-local/cursor-rule.mdc +43 -0
- package/template/.claude/skills/flydocs-local/scripts/assign.py +20 -0
- package/template/.claude/skills/flydocs-local/scripts/comment.py +27 -0
- package/template/.claude/skills/flydocs-local/scripts/create_issue.py +44 -0
- package/template/.claude/skills/flydocs-local/scripts/estimate.py +37 -0
- package/template/.claude/skills/flydocs-local/scripts/flydocs_api.py +272 -0
- package/template/.claude/skills/flydocs-local/scripts/get_issue.py +20 -0
- package/template/.claude/skills/flydocs-local/scripts/link.py +41 -0
- package/template/.claude/skills/flydocs-local/scripts/list_issues.py +34 -0
- package/template/.claude/skills/flydocs-local/scripts/priority.py +37 -0
- package/template/.claude/skills/flydocs-local/scripts/project_update.py +67 -0
- package/template/.claude/skills/flydocs-local/scripts/status_summary.py +16 -0
- package/template/.claude/skills/flydocs-local/scripts/transition.py +24 -0
- package/template/.claude/skills/flydocs-local/scripts/update_description.py +35 -0
- package/template/.claude/skills/flydocs-local/scripts/update_issue.py +84 -0
- package/template/.claude/skills/flydocs-workflow/SKILL.md +85 -0
- package/template/.claude/skills/flydocs-workflow/cursor-rule.mdc +53 -0
- package/template/.claude/skills/flydocs-workflow/reference/comment-templates.md +131 -0
- package/template/.claude/skills/flydocs-workflow/reference/golden-rules.md +76 -0
- package/template/.claude/skills/flydocs-workflow/reference/priority-estimates.md +28 -0
- package/template/.claude/skills/flydocs-workflow/reference/status-workflow.md +50 -0
- package/template/.claude/skills/flydocs-workflow/session.md +128 -0
- package/template/.claude/skills/flydocs-workflow/stages/activate.md +46 -0
- package/template/.claude/skills/flydocs-workflow/stages/capture.md +50 -0
- package/template/.claude/skills/flydocs-workflow/stages/close.md +32 -0
- package/template/.claude/skills/flydocs-workflow/stages/implement.md +124 -0
- package/template/.claude/skills/flydocs-workflow/stages/refine.md +51 -0
- package/template/.claude/skills/flydocs-workflow/stages/review.md +86 -0
- package/template/.claude/skills/flydocs-workflow/stages/validate.md +90 -0
- package/template/.claude/skills/flydocs-workflow/templates/bug.md +95 -0
- package/template/.claude/skills/flydocs-workflow/templates/chore.md +75 -0
- package/template/.claude/skills/flydocs-workflow/templates/feature.md +93 -0
- package/template/.claude/skills/flydocs-workflow/templates/idea.md +84 -0
- package/template/.cursor/agents/implementation-agent.md +28 -0
- package/template/.cursor/agents/pm-agent.md +27 -0
- package/template/.cursor/agents/research-agent.md +23 -0
- package/template/.cursor/agents/review-agent.md +27 -0
- package/template/.cursor/hooks.json +29 -0
- package/template/.cursor/mcp.json +16 -0
- package/template/.env.example +44 -0
- package/template/.flydocs/config.json +104 -0
- package/template/.flydocs/hooks/auto-approve.py +71 -0
- package/template/.flydocs/hooks/post-edit.py +72 -0
- package/template/.flydocs/hooks/prefer-scripts.py +89 -0
- package/template/.flydocs/hooks/prompt-submit.py +277 -0
- package/template/.flydocs/scripts/generate_manifest.py +287 -0
- package/template/.flydocs/scripts/skill_manager.py +541 -0
- package/template/.flydocs/templates/README.md +46 -0
- package/template/.flydocs/templates/bug.md +166 -0
- package/template/.flydocs/templates/chore.md +110 -0
- package/template/.flydocs/templates/design-system/README.md +27 -0
- package/template/.flydocs/templates/design-system/component-patterns.md +92 -0
- package/template/.flydocs/templates/design-system/token-mapping.md +168 -0
- package/template/.flydocs/templates/feature.md +173 -0
- package/template/.flydocs/templates/idea.md +122 -0
- package/template/.flydocs/templates/instructions.md +228 -0
- package/template/.flydocs/templates/quick-capture.md +35 -0
- package/template/.flydocs/templates/scripts/check-design-system.template.mjs +179 -0
- package/template/.flydocs/version +1 -0
- package/template/AGENTS.md +95 -0
- package/template/CHANGELOG.md +271 -0
- package/template/flydocs/README.md +186 -0
- package/template/flydocs/context/project.md +51 -0
- package/template/flydocs/design-system/README.md +126 -0
- package/template/flydocs/design-system/component-patterns.md +173 -0
- package/template/flydocs/design-system/token-mapping.md +114 -0
- package/template/flydocs/knowledge/INDEX.md +100 -0
- package/template/flydocs/knowledge/README.md +62 -0
- package/template/flydocs/knowledge/product/personas.md +79 -0
- package/template/flydocs/knowledge/product/user-flows.md +88 -0
- package/template/manifest.json +221 -0
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Rebuild the context graph from project sources.
|
|
3
|
+
|
|
4
|
+
Scans skills (SKILL.md frontmatter) and ADRs (decisions/*.md cross-references)
|
|
5
|
+
to produce flydocs/context/graph.json. Preserves manually-added nodes and edges.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
python3 .claude/skills/flydocs-context-graph/scripts/graph_build.py [--root PATH]
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
sys.path.insert(0, str(Path(__file__).parent))
|
|
18
|
+
from graph_utils import (
|
|
19
|
+
find_project_root,
|
|
20
|
+
load_graph,
|
|
21
|
+
save_graph,
|
|
22
|
+
output_json,
|
|
23
|
+
fail,
|
|
24
|
+
parse_frontmatter,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# --- Skill scanning ---
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def scan_skills(root):
|
|
32
|
+
"""Scan .claude/skills/*/SKILL.md and return skill nodes + edges."""
|
|
33
|
+
skills_dir = root / ".claude" / "skills"
|
|
34
|
+
nodes = {}
|
|
35
|
+
edges = []
|
|
36
|
+
|
|
37
|
+
if not skills_dir.is_dir():
|
|
38
|
+
return nodes, edges
|
|
39
|
+
|
|
40
|
+
for skill_dir in sorted(skills_dir.iterdir()):
|
|
41
|
+
skill_md = skill_dir / "SKILL.md"
|
|
42
|
+
if not skill_md.is_file():
|
|
43
|
+
continue
|
|
44
|
+
|
|
45
|
+
text = skill_md.read_text(encoding="utf-8")
|
|
46
|
+
fm = parse_frontmatter(text)
|
|
47
|
+
if not fm or "name" not in fm:
|
|
48
|
+
continue
|
|
49
|
+
|
|
50
|
+
name = fm["name"]
|
|
51
|
+
node_id = f"skill:{name}"
|
|
52
|
+
|
|
53
|
+
node = {
|
|
54
|
+
"type": "skill",
|
|
55
|
+
"label": name,
|
|
56
|
+
"path": str(skill_md.relative_to(root)),
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
# Classify tier based on directory name
|
|
60
|
+
dir_name = skill_dir.name
|
|
61
|
+
if dir_name.startswith("flydocs-"):
|
|
62
|
+
if dir_name in ("flydocs-local", "flydocs-cloud"):
|
|
63
|
+
node["tier"] = "mechanism"
|
|
64
|
+
elif dir_name in ("flydocs-figma", "flydocs-estimates"):
|
|
65
|
+
node["tier"] = "premium"
|
|
66
|
+
else:
|
|
67
|
+
node["tier"] = "core"
|
|
68
|
+
else:
|
|
69
|
+
node["tier"] = "behavioral"
|
|
70
|
+
|
|
71
|
+
nodes[node_id] = node
|
|
72
|
+
|
|
73
|
+
# Extract PRECEDES edges from loads_after frontmatter
|
|
74
|
+
loads_after = fm.get("loads_after")
|
|
75
|
+
if loads_after:
|
|
76
|
+
if isinstance(loads_after, str):
|
|
77
|
+
loads_after = [loads_after]
|
|
78
|
+
for dep in loads_after:
|
|
79
|
+
dep = dep.strip()
|
|
80
|
+
if dep:
|
|
81
|
+
# dep PRECEDES this skill (dep should load before this one)
|
|
82
|
+
edges.append({
|
|
83
|
+
"from": f"skill:{dep}",
|
|
84
|
+
"to": node_id,
|
|
85
|
+
"rel": "PRECEDES",
|
|
86
|
+
"weight": 0.7,
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
return nodes, edges
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# --- ADR scanning ---
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def scan_adrs(root):
|
|
96
|
+
"""Scan flydocs/knowledge/decisions/*.md and return decision nodes + edges."""
|
|
97
|
+
decisions_dir = root / "flydocs" / "knowledge" / "decisions"
|
|
98
|
+
nodes = {}
|
|
99
|
+
edges = []
|
|
100
|
+
|
|
101
|
+
if not decisions_dir.is_dir():
|
|
102
|
+
return nodes, edges
|
|
103
|
+
|
|
104
|
+
for adr_file in sorted(decisions_dir.glob("*.md")):
|
|
105
|
+
# Extract ADR number from filename (e.g., 001 from 001-skills-architecture.md)
|
|
106
|
+
num_match = re.match(r"^(\d+)-", adr_file.name)
|
|
107
|
+
if not num_match:
|
|
108
|
+
continue
|
|
109
|
+
|
|
110
|
+
number = num_match.group(1)
|
|
111
|
+
node_id = f"decision:{number}"
|
|
112
|
+
|
|
113
|
+
text = adr_file.read_text(encoding="utf-8")
|
|
114
|
+
|
|
115
|
+
# Extract title from first heading
|
|
116
|
+
title_match = re.search(r"^#\s+(?:ADR-\d+:\s*)?(.+)$", text, re.MULTILINE)
|
|
117
|
+
title = title_match.group(1).strip() if title_match else adr_file.stem
|
|
118
|
+
|
|
119
|
+
# Extract status
|
|
120
|
+
status_match = re.search(r"\*\*Status\*\*:\s*(.+?)(?:\n|$)", text)
|
|
121
|
+
status = status_match.group(1).strip() if status_match else "unknown"
|
|
122
|
+
|
|
123
|
+
nodes[node_id] = {
|
|
124
|
+
"type": "decision",
|
|
125
|
+
"label": title,
|
|
126
|
+
"path": str(adr_file.relative_to(root)),
|
|
127
|
+
"status": status,
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
# Extract cross-references to other ADRs
|
|
131
|
+
# Look for ADR-NNN patterns in relationship sections
|
|
132
|
+
rel_edges = extract_adr_relationships(node_id, text)
|
|
133
|
+
edges.extend(rel_edges)
|
|
134
|
+
|
|
135
|
+
return nodes, edges
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def extract_adr_relationships(from_id, text):
|
|
139
|
+
"""Extract edges from ADR cross-reference sections.
|
|
140
|
+
|
|
141
|
+
Looks for sections like "## Relationship to Other ADRs" and parses
|
|
142
|
+
ADR-NNN references with their relationship descriptions.
|
|
143
|
+
"""
|
|
144
|
+
edges = []
|
|
145
|
+
|
|
146
|
+
# Find relationship sections — various headings used
|
|
147
|
+
rel_section = None
|
|
148
|
+
for pattern in [
|
|
149
|
+
r"##\s+Relationship to Other ADRs\s*\n(.*?)(?=\n##\s|\n---|\Z)",
|
|
150
|
+
r"##\s+Alignment with.*?\n(.*?)(?=\n##\s|\n---|\Z)",
|
|
151
|
+
]:
|
|
152
|
+
match = re.search(pattern, text, re.DOTALL)
|
|
153
|
+
if match:
|
|
154
|
+
rel_section = match.group(1)
|
|
155
|
+
break
|
|
156
|
+
|
|
157
|
+
if not rel_section:
|
|
158
|
+
return edges
|
|
159
|
+
|
|
160
|
+
# Parse each ADR reference in the section
|
|
161
|
+
# Pattern: **ADR-NNN (Title)**: description -or- - ADR-NNN: description
|
|
162
|
+
for ref_match in re.finditer(r"ADR-(\d+)", rel_section):
|
|
163
|
+
target_num = ref_match.group(1).zfill(3)
|
|
164
|
+
target_id = f"decision:{target_num}"
|
|
165
|
+
|
|
166
|
+
if target_id == from_id:
|
|
167
|
+
continue
|
|
168
|
+
|
|
169
|
+
# Determine relationship type from surrounding text
|
|
170
|
+
# Get the sentence/bullet containing this reference
|
|
171
|
+
start = max(0, ref_match.start() - 10)
|
|
172
|
+
end = min(len(rel_section), ref_match.end() + 300)
|
|
173
|
+
context = rel_section[start:end].lower()
|
|
174
|
+
|
|
175
|
+
rel_type = classify_relationship(context)
|
|
176
|
+
|
|
177
|
+
edges.append({
|
|
178
|
+
"from": from_id,
|
|
179
|
+
"to": target_id,
|
|
180
|
+
"rel": rel_type,
|
|
181
|
+
"weight": 0.8,
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
return edges
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def classify_relationship(context):
|
|
188
|
+
"""Classify the relationship type from surrounding text."""
|
|
189
|
+
if any(w in context for w in ["extends", "refines", "builds on", "extension"]):
|
|
190
|
+
return "EXTENDS"
|
|
191
|
+
if any(w in context for w in ["implements", "realizes", "application of"]):
|
|
192
|
+
return "IMPLEMENTS"
|
|
193
|
+
if any(w in context for w in ["supersedes", "replaces", "replaced by"]):
|
|
194
|
+
return "SUPERSEDES"
|
|
195
|
+
if any(w in context for w in ["delegates", "hands off"]):
|
|
196
|
+
return "DELEGATES_TO"
|
|
197
|
+
if any(w in context for w in ["precedes", "before", "prerequisite"]):
|
|
198
|
+
return "PRECEDES"
|
|
199
|
+
if any(w in context for w in ["modifies", "changes", "affects"]):
|
|
200
|
+
return "MODIFIES"
|
|
201
|
+
return "RELATES_TO"
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
# --- Graph merge ---
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def merge_graph(existing, new_nodes, new_edges):
|
|
208
|
+
"""Merge scanned nodes/edges into existing graph, preserving manual entries."""
|
|
209
|
+
graph = {
|
|
210
|
+
"version": 1,
|
|
211
|
+
"updated": "",
|
|
212
|
+
"nodes": {},
|
|
213
|
+
"edges": [],
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
# Preserve manual nodes from existing graph
|
|
217
|
+
for node_id, node in existing.get("nodes", {}).items():
|
|
218
|
+
if node.get("manual"):
|
|
219
|
+
graph["nodes"][node_id] = node
|
|
220
|
+
|
|
221
|
+
# Add scanned nodes (overwrite non-manual)
|
|
222
|
+
for node_id, node in new_nodes.items():
|
|
223
|
+
graph["nodes"][node_id] = node
|
|
224
|
+
|
|
225
|
+
# Preserve manual edges from existing graph
|
|
226
|
+
for edge in existing.get("edges", []):
|
|
227
|
+
if edge.get("manual"):
|
|
228
|
+
graph["edges"].append(edge)
|
|
229
|
+
|
|
230
|
+
# Add scanned edges (deduplicate)
|
|
231
|
+
existing_edge_keys = {
|
|
232
|
+
(e["from"], e["to"], e["rel"]) for e in graph["edges"]
|
|
233
|
+
}
|
|
234
|
+
for edge in new_edges:
|
|
235
|
+
key = (edge["from"], edge["to"], edge["rel"])
|
|
236
|
+
if key not in existing_edge_keys:
|
|
237
|
+
graph["edges"].append(edge)
|
|
238
|
+
existing_edge_keys.add(key)
|
|
239
|
+
|
|
240
|
+
return graph
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
# --- Main ---
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def main():
|
|
247
|
+
parser = argparse.ArgumentParser(
|
|
248
|
+
description="Rebuild context graph from project sources"
|
|
249
|
+
)
|
|
250
|
+
parser.add_argument(
|
|
251
|
+
"--root", type=str, default=None,
|
|
252
|
+
help="Project root (default: auto-detect from .flydocs/)"
|
|
253
|
+
)
|
|
254
|
+
args = parser.parse_args()
|
|
255
|
+
|
|
256
|
+
root = Path(args.root) if args.root else find_project_root()
|
|
257
|
+
if not root:
|
|
258
|
+
fail("Could not find project root (no .flydocs/ directory found)")
|
|
259
|
+
|
|
260
|
+
# Load existing graph to preserve manual entries
|
|
261
|
+
existing = load_graph(root)
|
|
262
|
+
|
|
263
|
+
# Scan sources
|
|
264
|
+
skill_nodes, skill_edges = scan_skills(root)
|
|
265
|
+
adr_nodes, adr_edges = scan_adrs(root)
|
|
266
|
+
|
|
267
|
+
# Merge all scanned nodes and edges
|
|
268
|
+
all_nodes = {}
|
|
269
|
+
all_nodes.update(skill_nodes)
|
|
270
|
+
all_nodes.update(adr_nodes)
|
|
271
|
+
|
|
272
|
+
all_edges = []
|
|
273
|
+
all_edges.extend(skill_edges)
|
|
274
|
+
all_edges.extend(adr_edges)
|
|
275
|
+
|
|
276
|
+
# Merge with existing graph (preserving manual entries)
|
|
277
|
+
graph = merge_graph(existing, all_nodes, all_edges)
|
|
278
|
+
|
|
279
|
+
# Save
|
|
280
|
+
save_graph(root, graph)
|
|
281
|
+
|
|
282
|
+
# Report
|
|
283
|
+
output_json({
|
|
284
|
+
"success": True,
|
|
285
|
+
"path": str(graph_path_for_report(root)),
|
|
286
|
+
"nodes": len(graph["nodes"]),
|
|
287
|
+
"edges": len(graph["edges"]),
|
|
288
|
+
"skills": len(skill_nodes),
|
|
289
|
+
"decisions": len(adr_nodes),
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def graph_path_for_report(root):
|
|
294
|
+
"""Return the relative graph path for reporting."""
|
|
295
|
+
return os.path.join("flydocs", "context", "graph.json")
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
if __name__ == "__main__":
|
|
299
|
+
main()
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Assemble compressed graph context for the prime hook.
|
|
3
|
+
|
|
4
|
+
Given an active issue and/or git branch, traverses the graph and returns
|
|
5
|
+
a compact context block suitable for injection into the prompt-submit hook.
|
|
6
|
+
Stays within a ~200-400 token budget.
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
python3 .claude/skills/flydocs-context-graph/scripts/graph_context.py \
|
|
10
|
+
[--issue FLY-56] [--branch feature/context-graph] [--root PATH]
|
|
11
|
+
|
|
12
|
+
Output: Plain text context block (not JSON) for direct injection into hook output.
|
|
13
|
+
Returns empty string if graph doesn't exist or no relevant context found.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import sys
|
|
18
|
+
from datetime import date, timedelta
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
sys.path.insert(0, str(Path(__file__).parent))
|
|
22
|
+
from graph_utils import find_project_root, load_graph
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# Maximum lines in the context block to stay within token budget
|
|
26
|
+
MAX_CONTEXT_LINES = 12
|
|
27
|
+
|
|
28
|
+
# Session staleness policy
|
|
29
|
+
SESSION_RETENTION_DAYS = 30
|
|
30
|
+
SESSION_FRESH_DAYS = 7 # Full weight within this window
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def find_issue_node(graph, issue_ref):
|
|
34
|
+
"""Find a graph node matching an issue reference (e.g., FLY-56)."""
|
|
35
|
+
if not issue_ref:
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
# Try exact match first
|
|
39
|
+
node_id = f"issue:{issue_ref}"
|
|
40
|
+
if node_id in graph.get("nodes", {}):
|
|
41
|
+
return node_id
|
|
42
|
+
|
|
43
|
+
# Try case-insensitive search
|
|
44
|
+
for nid in graph.get("nodes", {}):
|
|
45
|
+
if nid.lower() == node_id.lower():
|
|
46
|
+
return nid
|
|
47
|
+
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def find_branch_nodes(graph, branch):
|
|
52
|
+
"""Infer relevant nodes from a git branch name.
|
|
53
|
+
|
|
54
|
+
Branch names often contain issue refs (feature/FLY-56-context-graph)
|
|
55
|
+
or skill/module names (feature/context-graph).
|
|
56
|
+
"""
|
|
57
|
+
if not branch:
|
|
58
|
+
return []
|
|
59
|
+
|
|
60
|
+
nodes = graph.get("nodes", {})
|
|
61
|
+
matches = []
|
|
62
|
+
|
|
63
|
+
# Extract issue reference from branch (e.g., FLY-56)
|
|
64
|
+
import re
|
|
65
|
+
issue_match = re.search(r"([A-Z]+-\d+)", branch, re.IGNORECASE)
|
|
66
|
+
if issue_match:
|
|
67
|
+
issue_id = find_issue_node(graph, issue_match.group(1))
|
|
68
|
+
if issue_id:
|
|
69
|
+
matches.append(issue_id)
|
|
70
|
+
|
|
71
|
+
# Match skill or module names in branch
|
|
72
|
+
branch_lower = branch.lower().replace("/", "-").replace("_", "-")
|
|
73
|
+
for node_id, node in nodes.items():
|
|
74
|
+
if node["type"] in ("skill", "module"):
|
|
75
|
+
name = node_id.split(":", 1)[-1].lower()
|
|
76
|
+
if name in branch_lower and len(name) > 3:
|
|
77
|
+
matches.append(node_id)
|
|
78
|
+
|
|
79
|
+
return matches
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def find_recent_sessions(graph, issue_nodes, today=None):
|
|
83
|
+
"""Find session nodes relevant to the current work.
|
|
84
|
+
|
|
85
|
+
Returns sessions connected to active issues, or recent sessions if no
|
|
86
|
+
issue match. Applies staleness weighting — older sessions get lower weight.
|
|
87
|
+
"""
|
|
88
|
+
if today is None:
|
|
89
|
+
today = date.today()
|
|
90
|
+
|
|
91
|
+
nodes = graph.get("nodes", {})
|
|
92
|
+
edges = graph.get("edges", [])
|
|
93
|
+
cutoff = today - timedelta(days=SESSION_RETENTION_DAYS)
|
|
94
|
+
|
|
95
|
+
# Find all session nodes within retention window
|
|
96
|
+
sessions = []
|
|
97
|
+
for node_id, node in nodes.items():
|
|
98
|
+
if node.get("type") != "session":
|
|
99
|
+
continue
|
|
100
|
+
|
|
101
|
+
session_date_str = node.get("date", "")
|
|
102
|
+
if not session_date_str:
|
|
103
|
+
continue
|
|
104
|
+
|
|
105
|
+
try:
|
|
106
|
+
session_date = date.fromisoformat(session_date_str)
|
|
107
|
+
except ValueError:
|
|
108
|
+
continue
|
|
109
|
+
|
|
110
|
+
if session_date < cutoff:
|
|
111
|
+
continue
|
|
112
|
+
|
|
113
|
+
# Calculate staleness weight
|
|
114
|
+
age_days = (today - session_date).days
|
|
115
|
+
if age_days <= SESSION_FRESH_DAYS:
|
|
116
|
+
weight = 1.0
|
|
117
|
+
else:
|
|
118
|
+
# Linear decay from 1.0 to 0.1 over the retention period
|
|
119
|
+
weight = max(0.1, 1.0 - (age_days / SESSION_RETENTION_DAYS) * 0.9)
|
|
120
|
+
|
|
121
|
+
sessions.append((node_id, node, session_date, weight))
|
|
122
|
+
|
|
123
|
+
if not sessions:
|
|
124
|
+
return []
|
|
125
|
+
|
|
126
|
+
# Check which sessions are connected to active issues
|
|
127
|
+
issue_set = set(issue_nodes)
|
|
128
|
+
connected = []
|
|
129
|
+
unconnected = []
|
|
130
|
+
|
|
131
|
+
for session_id, node, session_date, weight in sessions:
|
|
132
|
+
# Check if this session has WORKED_ON edges to any active issue
|
|
133
|
+
has_issue_link = False
|
|
134
|
+
for edge in edges:
|
|
135
|
+
if (edge["from"] == session_id
|
|
136
|
+
and edge["rel"] == "WORKED_ON"
|
|
137
|
+
and edge["to"] in issue_set):
|
|
138
|
+
has_issue_link = True
|
|
139
|
+
break
|
|
140
|
+
|
|
141
|
+
if has_issue_link:
|
|
142
|
+
connected.append((session_id, node, weight))
|
|
143
|
+
else:
|
|
144
|
+
unconnected.append((session_id, node, weight))
|
|
145
|
+
|
|
146
|
+
# Prefer connected sessions, fall back to most recent
|
|
147
|
+
if connected:
|
|
148
|
+
connected.sort(key=lambda x: x[2], reverse=True)
|
|
149
|
+
return connected[:3]
|
|
150
|
+
|
|
151
|
+
# No connected sessions — return most recent ones
|
|
152
|
+
unconnected.sort(key=lambda x: x[0], reverse=True) # Sort by ID (date-based)
|
|
153
|
+
return unconnected[:2]
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def get_related_context(graph, start_nodes, max_depth=2):
|
|
157
|
+
"""BFS traverse from start nodes and collect related context.
|
|
158
|
+
|
|
159
|
+
Returns a deduplicated list of (node_id, node, rel, depth) tuples.
|
|
160
|
+
"""
|
|
161
|
+
nodes = graph.get("nodes", {})
|
|
162
|
+
edges = graph.get("edges", [])
|
|
163
|
+
|
|
164
|
+
# Build adjacency (both directions for full context)
|
|
165
|
+
forward = {}
|
|
166
|
+
reverse = {}
|
|
167
|
+
for edge in edges:
|
|
168
|
+
src, dst = edge["from"], edge["to"]
|
|
169
|
+
rel = edge["rel"]
|
|
170
|
+
weight = edge.get("weight", 1.0)
|
|
171
|
+
|
|
172
|
+
if src not in forward:
|
|
173
|
+
forward[src] = []
|
|
174
|
+
forward[src].append((dst, rel, weight))
|
|
175
|
+
|
|
176
|
+
if dst not in reverse:
|
|
177
|
+
reverse[dst] = []
|
|
178
|
+
reverse[dst].append((src, rel, weight))
|
|
179
|
+
|
|
180
|
+
visited = set(start_nodes)
|
|
181
|
+
results = []
|
|
182
|
+
|
|
183
|
+
# BFS from each start node
|
|
184
|
+
from collections import deque
|
|
185
|
+
queue = deque()
|
|
186
|
+
|
|
187
|
+
for start in start_nodes:
|
|
188
|
+
# Forward edges
|
|
189
|
+
for neighbor, rel, weight in forward.get(start, []):
|
|
190
|
+
if neighbor not in visited:
|
|
191
|
+
queue.append((neighbor, 1, rel, "forward"))
|
|
192
|
+
visited.add(neighbor)
|
|
193
|
+
# Reverse edges
|
|
194
|
+
for neighbor, rel, weight in reverse.get(start, []):
|
|
195
|
+
if neighbor not in visited:
|
|
196
|
+
queue.append((neighbor, 1, rel, "reverse"))
|
|
197
|
+
visited.add(neighbor)
|
|
198
|
+
|
|
199
|
+
while queue:
|
|
200
|
+
node_id, depth, rel, direction = queue.popleft()
|
|
201
|
+
node = nodes.get(node_id, {})
|
|
202
|
+
results.append((node_id, node, rel, depth))
|
|
203
|
+
|
|
204
|
+
if depth < max_depth:
|
|
205
|
+
adj = forward if direction == "forward" else reverse
|
|
206
|
+
for neighbor, next_rel, weight in adj.get(node_id, []):
|
|
207
|
+
if neighbor not in visited:
|
|
208
|
+
queue.append((neighbor, depth + 1, next_rel, direction))
|
|
209
|
+
visited.add(neighbor)
|
|
210
|
+
|
|
211
|
+
return results
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def format_context_block(graph, start_nodes, related):
|
|
215
|
+
"""Format a compressed context block within the token budget."""
|
|
216
|
+
nodes = graph.get("nodes", {})
|
|
217
|
+
lines = []
|
|
218
|
+
|
|
219
|
+
# Temporal anchor
|
|
220
|
+
lines.append(f"Today is {date.today().strftime('%B %d, %Y')}")
|
|
221
|
+
|
|
222
|
+
if not related:
|
|
223
|
+
return "\n".join(lines)
|
|
224
|
+
|
|
225
|
+
# Group by type, prioritize decisions and skills
|
|
226
|
+
decisions = []
|
|
227
|
+
skills = []
|
|
228
|
+
sessions = []
|
|
229
|
+
other = []
|
|
230
|
+
|
|
231
|
+
for node_id, node, rel, depth in related:
|
|
232
|
+
node_type = node.get("type", "unknown")
|
|
233
|
+
if node_type == "decision":
|
|
234
|
+
decisions.append((node_id, node, rel))
|
|
235
|
+
elif node_type == "skill":
|
|
236
|
+
skills.append((node_id, node, rel))
|
|
237
|
+
elif node_type == "session":
|
|
238
|
+
sessions.append((node_id, node, rel))
|
|
239
|
+
else:
|
|
240
|
+
other.append((node_id, node, rel))
|
|
241
|
+
|
|
242
|
+
# Decisions — most valuable context
|
|
243
|
+
if decisions:
|
|
244
|
+
lines.append("Related decisions:")
|
|
245
|
+
for node_id, node, rel in decisions[:4]:
|
|
246
|
+
num = node_id.split(":")[-1]
|
|
247
|
+
label = node.get("label", node_id)
|
|
248
|
+
lines.append(f" ADR-{num}: {label} ({rel})")
|
|
249
|
+
|
|
250
|
+
# Skills
|
|
251
|
+
if skills:
|
|
252
|
+
skill_names = [n.get("label", nid) for nid, n, r in skills[:3]]
|
|
253
|
+
lines.append(f"Related skills: {', '.join(skill_names)}")
|
|
254
|
+
|
|
255
|
+
# Sessions — prior work context
|
|
256
|
+
if sessions:
|
|
257
|
+
for node_id, node, rel in sessions[:2]:
|
|
258
|
+
label = node.get("label", "")
|
|
259
|
+
session_date = node.get("date", "")
|
|
260
|
+
if label:
|
|
261
|
+
lines.append(f"Prior session ({session_date}): {label}")
|
|
262
|
+
|
|
263
|
+
# Truncate to budget
|
|
264
|
+
if len(lines) > MAX_CONTEXT_LINES:
|
|
265
|
+
lines = lines[:MAX_CONTEXT_LINES]
|
|
266
|
+
|
|
267
|
+
return "\n".join(lines)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def main():
|
|
271
|
+
parser = argparse.ArgumentParser(
|
|
272
|
+
description="Assemble graph context for prime hook"
|
|
273
|
+
)
|
|
274
|
+
parser.add_argument(
|
|
275
|
+
"--issue", type=str, default=None,
|
|
276
|
+
help="Active issue reference (e.g., FLY-56)"
|
|
277
|
+
)
|
|
278
|
+
parser.add_argument(
|
|
279
|
+
"--branch", type=str, default=None,
|
|
280
|
+
help="Current git branch name"
|
|
281
|
+
)
|
|
282
|
+
parser.add_argument(
|
|
283
|
+
"--root", type=str, default=None,
|
|
284
|
+
help="Project root (default: auto-detect)"
|
|
285
|
+
)
|
|
286
|
+
args = parser.parse_args()
|
|
287
|
+
|
|
288
|
+
root = Path(args.root) if args.root else find_project_root()
|
|
289
|
+
if not root:
|
|
290
|
+
# Graceful fallback — no output
|
|
291
|
+
sys.exit(0)
|
|
292
|
+
|
|
293
|
+
# Load graph — graceful fallback if missing
|
|
294
|
+
graph = load_graph(root)
|
|
295
|
+
if not graph.get("nodes"):
|
|
296
|
+
# Empty or missing graph — just output temporal anchor
|
|
297
|
+
print(f"Today is {date.today().strftime('%B %d, %Y')}")
|
|
298
|
+
sys.exit(0)
|
|
299
|
+
|
|
300
|
+
# Find starting nodes
|
|
301
|
+
start_nodes = []
|
|
302
|
+
|
|
303
|
+
issue_node = find_issue_node(graph, args.issue)
|
|
304
|
+
if issue_node:
|
|
305
|
+
start_nodes.append(issue_node)
|
|
306
|
+
|
|
307
|
+
branch_nodes = find_branch_nodes(graph, args.branch)
|
|
308
|
+
start_nodes.extend(n for n in branch_nodes if n not in start_nodes)
|
|
309
|
+
|
|
310
|
+
# If no specific entry points, just output temporal anchor
|
|
311
|
+
if not start_nodes:
|
|
312
|
+
print(f"Today is {date.today().strftime('%B %d, %Y')}")
|
|
313
|
+
sys.exit(0)
|
|
314
|
+
|
|
315
|
+
# Traverse and assemble context
|
|
316
|
+
related = get_related_context(graph, start_nodes, max_depth=2)
|
|
317
|
+
|
|
318
|
+
# Find recent sessions (connected to active issues or just recent)
|
|
319
|
+
issue_start_nodes = [n for n in start_nodes if n.startswith("issue:")]
|
|
320
|
+
recent_sessions = find_recent_sessions(graph, issue_start_nodes)
|
|
321
|
+
|
|
322
|
+
# Add sessions to related if not already present
|
|
323
|
+
seen = {r[0] for r in related}
|
|
324
|
+
for session_id, node, weight in recent_sessions:
|
|
325
|
+
if session_id not in seen:
|
|
326
|
+
related.append((session_id, node, "WORKED_ON", 1))
|
|
327
|
+
seen.add(session_id)
|
|
328
|
+
|
|
329
|
+
context = format_context_block(graph, start_nodes, related)
|
|
330
|
+
|
|
331
|
+
if context:
|
|
332
|
+
print(context)
|
|
333
|
+
|
|
334
|
+
sys.exit(0)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
if __name__ == "__main__":
|
|
338
|
+
main()
|