@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,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()