ragtime-cli 0.2.18__py3-none-any.whl → 0.3.1__py3-none-any.whl

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.

Potentially problematic release.


This version of ragtime-cli might be problematic. Click here for more details.

src/cli.py CHANGED
@@ -306,8 +306,17 @@ def setup_mcp_global(force: bool = False) -> bool:
306
306
  return False
307
307
 
308
308
 
309
+ def get_version():
310
+ """Get version from package metadata."""
311
+ try:
312
+ from importlib.metadata import version
313
+ return version("ragtime-cli")
314
+ except Exception:
315
+ return "unknown"
316
+
317
+
309
318
  @click.group()
310
- @click.version_option(version="0.2.18")
319
+ @click.version_option(version=get_version())
311
320
  @click.option("-y", "--force-defaults", is_flag=True, help="Accept all defaults without prompting")
312
321
  @click.pass_context
313
322
  def main(ctx, force_defaults: bool):
@@ -817,16 +826,81 @@ def forget(memory_id: str, path: Path):
817
826
 
818
827
 
819
828
  @main.command()
820
- @click.argument("memory_id")
829
+ @click.argument("memory_id", required=False)
821
830
  @click.option("--path", type=click.Path(exists=True, path_type=Path), default=".")
831
+ @click.option("--list", "list_candidates", is_flag=True, help="List graduation candidates")
832
+ @click.option("--branch", "-b", help="Branch name or slug (default: current branch)")
822
833
  @click.option("--confidence", default="high",
823
834
  type=click.Choice(["high", "medium", "low"]),
824
835
  help="Confidence level for graduated memory")
825
- def graduate(memory_id: str, path: Path, confidence: str):
826
- """Graduate a branch memory to app namespace."""
836
+ def graduate(memory_id: str, path: Path, list_candidates: bool, branch: str, confidence: str):
837
+ """Graduate a branch memory to app namespace.
838
+
839
+ With --list: Show branch memories that are candidates for graduation.
840
+ With MEMORY_ID: Graduate a specific memory to app namespace.
841
+
842
+ Examples:
843
+ ragtime graduate --list # List candidates for current branch
844
+ ragtime graduate --list -b feature/auth # List candidates for specific branch
845
+ ragtime graduate abc123 # Graduate specific memory
846
+ """
827
847
  path = Path(path).resolve()
828
848
  store = get_memory_store(path)
829
849
 
850
+ # List mode: show graduation candidates
851
+ if list_candidates or not memory_id:
852
+ # Determine branch
853
+ if not branch:
854
+ branch = get_current_branch(path)
855
+ if not branch:
856
+ click.echo("✗ Not in a git repository or no branch specified", err=True)
857
+ return
858
+
859
+ # Create namespace pattern (handle both original and slugified)
860
+ branch_slug = branch.replace("/", "-")
861
+ namespace = f"branch-{branch_slug}"
862
+
863
+ # Get memories for this branch
864
+ memories = store.list_memories(namespace=namespace, status="active")
865
+
866
+ if not memories:
867
+ click.echo(f"No active memories found for branch: {branch}")
868
+ click.echo(f" (namespace: {namespace})")
869
+ return
870
+
871
+ # Filter to graduation candidates (exclude context type)
872
+ candidates = [m for m in memories if m.type != "context"]
873
+
874
+ if not candidates:
875
+ click.echo(f"No graduation candidates for branch: {branch}")
876
+ click.echo(f" (found {len(memories)} memories, but all are context type)")
877
+ return
878
+
879
+ click.echo(f"\nGraduation candidates for branch: {branch}")
880
+ click.echo(f"{'─' * 50}")
881
+
882
+ for i, mem in enumerate(candidates, 1):
883
+ type_badge = f"[{mem.type}]" if mem.type else "[unknown]"
884
+ confidence_badge = f"({mem.confidence})" if hasattr(mem, 'confidence') and mem.confidence else ""
885
+
886
+ click.echo(f"\n {i}. {type_badge} {confidence_badge}")
887
+ click.echo(f" ID: {mem.id}")
888
+
889
+ # Show preview
890
+ preview = mem.content[:150].replace("\n", " ").strip()
891
+ if len(mem.content) > 150:
892
+ preview += "..."
893
+ click.echo(f" {preview}")
894
+
895
+ if hasattr(mem, 'added') and mem.added:
896
+ click.echo(f" Added: {mem.added}")
897
+
898
+ click.echo(f"\n{'─' * 50}")
899
+ click.echo(f"{len(candidates)} candidate(s) found.")
900
+ click.echo(f"\nTo graduate: ragtime graduate <ID>")
901
+ return
902
+
903
+ # Graduate mode: graduate specific memory
830
904
  try:
831
905
  graduated = store.graduate(memory_id, confidence)
832
906
  if graduated:
@@ -893,6 +967,568 @@ def reindex(path: Path):
893
967
  click.echo(f"✓ Reindexed {count} memory files")
894
968
 
895
969
 
970
+ @main.command("check-conventions")
971
+ @click.option("--path", type=click.Path(exists=True, path_type=Path), default=".")
972
+ @click.option("--files", "-f", help="Files to check (comma-separated or JSON array)")
973
+ @click.option("--branch", "-b", help="Branch to diff against main (default: current branch)")
974
+ @click.option("--event-file", type=click.Path(exists=True, path_type=Path), help="GHP event file (JSON)")
975
+ @click.option("--include-memories", is_flag=True, default=True, help="Include convention memories")
976
+ @click.option("--all", "return_all", is_flag=True, help="Return ALL conventions (for AI workflows)")
977
+ @click.option("--json", "json_output", is_flag=True, help="Output as JSON (for MCP/hooks)")
978
+ def check_conventions(path: Path, files: str, branch: str, event_file: Path,
979
+ include_memories: bool, return_all: bool, json_output: bool):
980
+ """Show conventions applicable to changed files.
981
+
982
+ Used by pre-PR hooks to check code against team conventions.
983
+
984
+ By default, returns only conventions RELEVANT to the changed files (semantic search).
985
+ Use --all to return ALL conventions (useful for AI workflows that analyze edge cases).
986
+
987
+ Examples:
988
+ ragtime check-conventions # Relevant conventions only
989
+ ragtime check-conventions --all # ALL conventions (for AI)
990
+ ragtime check-conventions -f "src/auth.ts" # Conventions relevant to specific file
991
+ ragtime check-conventions --event-file /tmp/ghp-event.json --all # From GHP hook
992
+ """
993
+ import json as json_module
994
+
995
+ path = Path(path).resolve()
996
+ config = RagtimeConfig.load(path)
997
+
998
+ # Load from event file if provided (GHP hook pattern)
999
+ if event_file:
1000
+ try:
1001
+ event_data = json_module.loads(Path(event_file).read_text())
1002
+ # Event file can provide changed_files and branch
1003
+ if not files and "changed_files" in event_data:
1004
+ files = event_data["changed_files"]
1005
+ if not branch and "branch" in event_data:
1006
+ branch = event_data["branch"]
1007
+ except (json_module.JSONDecodeError, IOError) as e:
1008
+ click.echo(f"⚠ Could not read event file: {e}", err=True)
1009
+
1010
+ # Determine files to check
1011
+ changed_files = []
1012
+ if files:
1013
+ # Handle list (from event file) or string (from CLI)
1014
+ if isinstance(files, list):
1015
+ changed_files = files
1016
+ elif files.startswith("["):
1017
+ try:
1018
+ changed_files = json_module.loads(files)
1019
+ except json_module.JSONDecodeError:
1020
+ changed_files = [f.strip() for f in files.split(",")]
1021
+ else:
1022
+ changed_files = [f.strip() for f in files.split(",")]
1023
+ else:
1024
+ # Get changed files from git diff
1025
+ if not branch:
1026
+ branch = get_current_branch(path)
1027
+ if branch and branch not in ("main", "master"):
1028
+ result = subprocess.run(
1029
+ ["git", "diff", "--name-only", "main...HEAD"],
1030
+ cwd=path,
1031
+ capture_output=True,
1032
+ text=True,
1033
+ )
1034
+ if result.returncode == 0:
1035
+ changed_files = [f for f in result.stdout.strip().split("\n") if f]
1036
+
1037
+ # Gather conventions from files
1038
+ conventions_data = {
1039
+ "files": [],
1040
+ "memories": [],
1041
+ "changed_files": changed_files,
1042
+ }
1043
+
1044
+ # Read convention files from config
1045
+ for conv_file in config.conventions.files:
1046
+ conv_path = path / conv_file
1047
+ if conv_path.exists():
1048
+ if conv_path.is_file():
1049
+ content = conv_path.read_text()
1050
+ conventions_data["files"].append({
1051
+ "path": str(conv_file),
1052
+ "content": content,
1053
+ })
1054
+ elif conv_path.is_dir():
1055
+ # If it's a directory, read all files in it
1056
+ for f in conv_path.rglob("*"):
1057
+ if f.is_file() and f.suffix in (".md", ".txt", ".yaml", ".yml"):
1058
+ content = f.read_text()
1059
+ conventions_data["files"].append({
1060
+ "path": str(f.relative_to(path)),
1061
+ "content": content,
1062
+ })
1063
+
1064
+ # Search memories for conventions
1065
+ if include_memories and config.conventions.also_search_memories:
1066
+ store = get_memory_store(path)
1067
+ db = get_db(path)
1068
+
1069
+ if return_all:
1070
+ # Return ALL convention/pattern memories (for AI workflows)
1071
+ for ns in ["team", "app"]:
1072
+ memories = store.list_memories(namespace=ns, type_filter="convention", status="active")
1073
+ for mem in memories:
1074
+ conventions_data["memories"].append({
1075
+ "id": mem.id,
1076
+ "namespace": mem.namespace,
1077
+ "content": mem.content,
1078
+ "component": mem.component,
1079
+ })
1080
+ # Also get pattern-type memories
1081
+ patterns = store.list_memories(namespace=ns, type_filter="pattern", status="active")
1082
+ for mem in patterns:
1083
+ conventions_data["memories"].append({
1084
+ "id": mem.id,
1085
+ "namespace": mem.namespace,
1086
+ "type": "pattern",
1087
+ "content": mem.content,
1088
+ "component": mem.component,
1089
+ })
1090
+ else:
1091
+ # Semantic search for RELEVANT conventions based on changed files
1092
+ if changed_files:
1093
+ # Build search query from file paths and names
1094
+ # Extract meaningful terms: paths, extensions, inferred components
1095
+ search_terms = set()
1096
+ for f in changed_files:
1097
+ parts = Path(f).parts
1098
+ search_terms.update(parts) # Add path components
1099
+ search_terms.add(Path(f).suffix.lstrip(".")) # Add extension
1100
+ search_terms.add(Path(f).stem) # Add filename without ext
1101
+
1102
+ # Remove common noise
1103
+ noise = {"src", "lib", "test", "tests", "spec", "specs", "", "js", "ts", "py", "md"}
1104
+ search_terms = search_terms - noise
1105
+
1106
+ query = f"conventions patterns rules standards for {' '.join(search_terms)}"
1107
+
1108
+ # Search both namespaces using the db directly
1109
+ seen_ids = set()
1110
+ for ns in ["team", "app"]:
1111
+ results = db.search(query, namespace=ns, limit=50)
1112
+ for result in results:
1113
+ meta = result.get("metadata", {})
1114
+ result_type = meta.get("type", "")
1115
+ result_category = meta.get("category", "")
1116
+ # Include convention/pattern types OR docs with category="convention"
1117
+ is_convention_content = (
1118
+ result_type in ("convention", "pattern") or
1119
+ result_category == "convention"
1120
+ )
1121
+ if is_convention_content:
1122
+ mem_id = meta.get("id") or meta.get("file", "")
1123
+ if mem_id not in seen_ids:
1124
+ seen_ids.add(mem_id)
1125
+ distance = result.get("distance", 1)
1126
+ score = 1 - distance if distance else 0
1127
+ conventions_data["memories"].append({
1128
+ "id": mem_id,
1129
+ "namespace": ns,
1130
+ "type": result_type,
1131
+ "content": result.get("content", ""),
1132
+ "component": meta.get("component"),
1133
+ "relevance": score,
1134
+ })
1135
+ else:
1136
+ # No changed files - fall back to listing all
1137
+ for ns in ["team", "app"]:
1138
+ memories = store.list_memories(namespace=ns, type_filter="convention", status="active")
1139
+ for mem in memories:
1140
+ conventions_data["memories"].append({
1141
+ "id": mem.id,
1142
+ "namespace": mem.namespace,
1143
+ "content": mem.content,
1144
+ "component": mem.component,
1145
+ })
1146
+
1147
+ # Output
1148
+ if json_output:
1149
+ click.echo(json_module.dumps(conventions_data, indent=2))
1150
+ return
1151
+
1152
+ # Human-friendly output
1153
+ click.echo(f"\nConventions Check")
1154
+ click.echo(f"{'═' * 50}")
1155
+
1156
+ if changed_files:
1157
+ click.echo(f"\nFiles to check ({len(changed_files)}):")
1158
+ for f in changed_files[:10]:
1159
+ click.echo(f" • {f}")
1160
+ if len(changed_files) > 10:
1161
+ click.echo(f" ... and {len(changed_files) - 10} more")
1162
+
1163
+ click.echo(f"\n{'─' * 50}")
1164
+ click.echo(f"Convention Files ({len(conventions_data['files'])}):")
1165
+
1166
+ if not conventions_data["files"]:
1167
+ click.echo(" No convention files found.")
1168
+ click.echo(f" Configure in .ragtime/config.yaml under 'conventions.files'")
1169
+ else:
1170
+ for conv in conventions_data["files"]:
1171
+ click.echo(f"\n 📄 {conv['path']}")
1172
+ # Show first few lines as preview
1173
+ lines = conv["content"].strip().split("\n")[:5]
1174
+ for line in lines:
1175
+ if line.strip():
1176
+ click.echo(f" {line[:70]}{'...' if len(line) > 70 else ''}")
1177
+
1178
+ if conventions_data["memories"]:
1179
+ click.echo(f"\n{'─' * 50}")
1180
+ mode_label = "All" if return_all else "Relevant"
1181
+ click.echo(f"Convention Memories - {mode_label} ({len(conventions_data['memories'])}):")
1182
+
1183
+ for mem in conventions_data["memories"]:
1184
+ type_str = mem.get("type", "convention")
1185
+ relevance = mem.get("relevance")
1186
+ relevance_str = f" (relevance: {relevance:.2f})" if relevance else ""
1187
+ click.echo(f"\n [{mem['id'][:8] if mem['id'] else '?'}] {mem['namespace']} / {type_str}{relevance_str}")
1188
+ if mem.get("component"):
1189
+ click.echo(f" Component: {mem['component']}")
1190
+ preview = mem["content"][:100].replace("\n", " ")
1191
+ click.echo(f" {preview}...")
1192
+
1193
+ click.echo(f"\n{'═' * 50}")
1194
+ total = len(conventions_data["files"]) + len(conventions_data["memories"])
1195
+ mode_note = " (all)" if return_all else " (filtered by relevance)"
1196
+ click.echo(f"Total: {total} convention source(s){mode_note}")
1197
+
1198
+
1199
+ @main.command("add-convention")
1200
+ @click.argument("content", required=False)
1201
+ @click.option("--path", type=click.Path(exists=True, path_type=Path), default=".")
1202
+ @click.option("--component", "-c", help="Component this convention applies to")
1203
+ @click.option("--to-file", type=click.Path(path_type=Path), help="Add to specific file")
1204
+ @click.option("--to-memory", is_flag=True, help="Store as memory only (not in git)")
1205
+ @click.option("--quiet", "-q", is_flag=True, help="Non-interactive mode")
1206
+ def add_convention(content: str | None, path: Path, component: str | None,
1207
+ to_file: Path | None, to_memory: bool, quiet: bool):
1208
+ """Add a new convention with smart storage routing.
1209
+
1210
+ Finds the best place to store the convention:
1211
+ - Existing doc with ## Conventions section (if component matches)
1212
+ - Convention folder (.ragtime/conventions/)
1213
+ - Central conventions file (.ragtime/CONVENTIONS.md)
1214
+ - Memory only (searchable but not committed)
1215
+
1216
+ Examples:
1217
+ ragtime add-convention "Always use async/await, never .then()"
1218
+ ragtime add-convention --component auth "JWT tokens expire after 15 minutes"
1219
+ ragtime add-convention --to-file docs/api.md "Use snake_case for JSON"
1220
+ ragtime add-convention --to-memory "Prefer composition over inheritance"
1221
+ """
1222
+ path = Path(path).resolve()
1223
+ config = RagtimeConfig.load(path)
1224
+
1225
+ # Get content interactively if not provided
1226
+ if not content:
1227
+ content = click.prompt("Convention to add")
1228
+
1229
+ if not content.strip():
1230
+ click.echo("✗ No content provided", err=True)
1231
+ return
1232
+
1233
+ # If explicit destination provided, use it
1234
+ if to_memory:
1235
+ _store_convention_as_memory(path, content, component)
1236
+ return
1237
+
1238
+ if to_file:
1239
+ _append_convention_to_file(path, to_file, content)
1240
+ return
1241
+
1242
+ # Auto-routing based on config
1243
+ storage_mode = config.conventions.storage
1244
+
1245
+ if storage_mode == "memory":
1246
+ _store_convention_as_memory(path, content, component)
1247
+ return
1248
+
1249
+ # Find storage options
1250
+ options = _find_convention_storage_options(path, config, component)
1251
+
1252
+ if storage_mode == "file" or (storage_mode == "auto" and options):
1253
+ # Use first available file option, or default
1254
+ if options:
1255
+ target = options[0]
1256
+ else:
1257
+ target = {
1258
+ "type": "default_file",
1259
+ "path": config.conventions.default_file,
1260
+ "description": "Central conventions file",
1261
+ }
1262
+ _store_convention_to_target(path, target, content, component)
1263
+ return
1264
+
1265
+ if storage_mode == "ask" or (storage_mode == "auto" and not quiet):
1266
+ # Present options to user
1267
+ click.echo("\nWhere should this convention be stored?\n")
1268
+
1269
+ choices = []
1270
+ for i, opt in enumerate(options, 1):
1271
+ icon = "📄" if opt["type"] == "existing_section" else "📁" if opt["type"] == "folder" else "📋"
1272
+ click.echo(f" {i}. {icon} {opt['description']}")
1273
+ click.echo(f" {opt['path']}")
1274
+ choices.append(opt)
1275
+
1276
+ # Add default options if not already present
1277
+ default_file_present = any(o["path"] == config.conventions.default_file for o in choices)
1278
+ if not default_file_present:
1279
+ i = len(choices) + 1
1280
+ click.echo(f" {i}. 📋 Central conventions file")
1281
+ click.echo(f" {config.conventions.default_file}")
1282
+ choices.append({
1283
+ "type": "default_file",
1284
+ "path": config.conventions.default_file,
1285
+ "description": "Central conventions file",
1286
+ })
1287
+
1288
+ i = len(choices) + 1
1289
+ click.echo(f" {i}. 🧠 Memory only (not committed)")
1290
+ click.echo(f" Stored in team namespace, searchable but not in git")
1291
+ choices.append({"type": "memory", "path": None, "description": "Memory only"})
1292
+
1293
+ click.echo()
1294
+ choice = click.prompt("Choice", type=int, default=1)
1295
+
1296
+ if choice < 1 or choice > len(choices):
1297
+ click.echo("✗ Invalid choice", err=True)
1298
+ return
1299
+
1300
+ target = choices[choice - 1]
1301
+ if target["type"] == "memory":
1302
+ _store_convention_as_memory(path, content, component)
1303
+ else:
1304
+ _store_convention_to_target(path, target, content, component)
1305
+ return
1306
+
1307
+ # Fallback: memory only
1308
+ _store_convention_as_memory(path, content, component)
1309
+
1310
+
1311
+ def _find_convention_storage_options(path: Path, config: RagtimeConfig, component: str | None) -> list[dict]:
1312
+ """Find potential storage locations for a convention."""
1313
+ import re
1314
+ options = []
1315
+
1316
+ # 1. Check for existing docs with ## Conventions sections
1317
+ for scan_path in config.conventions.scan_docs_for_sections:
1318
+ scan_dir = path / scan_path
1319
+ if scan_dir.exists() and scan_dir.is_dir():
1320
+ for md_file in scan_dir.rglob("*.md"):
1321
+ content = md_file.read_text()
1322
+ # Look for ## Conventions header
1323
+ if re.search(r'^##\s+(Conventions?|Rules|Standards|Guidelines)', content, re.MULTILINE | re.IGNORECASE):
1324
+ # If component specified, check if file relates to it
1325
+ file_component = md_file.stem.lower()
1326
+ rel_path = md_file.relative_to(path)
1327
+
1328
+ if component:
1329
+ if component.lower() in str(rel_path).lower():
1330
+ options.insert(0, { # Prioritize component match
1331
+ "type": "existing_section",
1332
+ "path": str(rel_path),
1333
+ "description": f"Add to existing Conventions section ({file_component})",
1334
+ })
1335
+ else:
1336
+ options.append({
1337
+ "type": "existing_section",
1338
+ "path": str(rel_path),
1339
+ "description": f"Add to existing Conventions section",
1340
+ })
1341
+
1342
+ # 2. Check if convention folder exists
1343
+ folder = path / config.conventions.folder
1344
+ if folder.exists() and folder.is_dir():
1345
+ if component:
1346
+ target_file = f"{component.lower()}.md"
1347
+ options.append({
1348
+ "type": "folder",
1349
+ "path": f"{config.conventions.folder}{target_file}",
1350
+ "description": f"Create/append to {target_file} in conventions folder",
1351
+ })
1352
+ else:
1353
+ options.append({
1354
+ "type": "folder",
1355
+ "path": f"{config.conventions.folder}general.md",
1356
+ "description": "Add to general.md in conventions folder",
1357
+ })
1358
+
1359
+ # 3. Check if default conventions file exists
1360
+ default_file = path / config.conventions.default_file
1361
+ if default_file.exists():
1362
+ options.append({
1363
+ "type": "default_file",
1364
+ "path": config.conventions.default_file,
1365
+ "description": "Append to central conventions file",
1366
+ })
1367
+
1368
+ return options
1369
+
1370
+
1371
+ def _store_convention_as_memory(path: Path, content: str, component: str | None):
1372
+ """Store convention as a memory in team namespace."""
1373
+ from datetime import date
1374
+ store = get_memory_store(path)
1375
+
1376
+ memory = Memory(
1377
+ content=content,
1378
+ namespace="team",
1379
+ type="convention",
1380
+ component=component,
1381
+ confidence="high",
1382
+ confidence_reason="user-added",
1383
+ source="add-convention",
1384
+ status="active",
1385
+ added=date.today().isoformat(),
1386
+ author=get_author(),
1387
+ )
1388
+
1389
+ file_path = store.save(memory)
1390
+ click.echo(f"✓ Convention stored as memory")
1391
+ click.echo(f" ID: {memory.id}")
1392
+ click.echo(f" File: {file_path.relative_to(path)}")
1393
+ click.echo(f" Namespace: team")
1394
+
1395
+
1396
+ def _store_convention_to_target(path: Path, target: dict, content: str, component: str | None):
1397
+ """Store convention to a file target."""
1398
+ import re
1399
+
1400
+ target_path = path / target["path"]
1401
+ target_type = target["type"]
1402
+
1403
+ if target_type == "existing_section":
1404
+ # Append to existing ## Conventions section
1405
+ if not target_path.exists():
1406
+ click.echo(f"✗ File not found: {target['path']}", err=True)
1407
+ return
1408
+
1409
+ file_content = target_path.read_text()
1410
+
1411
+ # Find the Conventions section and append
1412
+ pattern = r'(^##\s+(?:Conventions?|Rules|Standards|Guidelines).*?)(\n##|\Z)'
1413
+ match = re.search(pattern, file_content, re.MULTILINE | re.IGNORECASE | re.DOTALL)
1414
+
1415
+ if match:
1416
+ section_end = match.end(1)
1417
+ new_content = file_content[:section_end] + f"\n\n- {content}" + file_content[section_end:]
1418
+ target_path.write_text(new_content)
1419
+ click.echo(f"✓ Convention added to {target['path']}")
1420
+ click.echo(f" Added to ## Conventions section")
1421
+ else:
1422
+ click.echo(f"✗ Could not find Conventions section in {target['path']}", err=True)
1423
+
1424
+ elif target_type == "folder":
1425
+ # Create or append to file in conventions folder
1426
+ target_path.parent.mkdir(parents=True, exist_ok=True)
1427
+
1428
+ if target_path.exists():
1429
+ # Append to existing file
1430
+ existing = target_path.read_text()
1431
+ new_content = existing.rstrip() + f"\n\n- {content}\n"
1432
+ else:
1433
+ # Create new file
1434
+ title = target_path.stem.replace("-", " ").replace("_", " ").title()
1435
+ new_content = f"""---
1436
+ namespace: team
1437
+ type: convention
1438
+ component: {component or ''}
1439
+ ---
1440
+
1441
+ # {title} Conventions
1442
+
1443
+ - {content}
1444
+ """
1445
+ target_path.write_text(new_content)
1446
+ click.echo(f"✓ Convention added to {target['path']}")
1447
+
1448
+ elif target_type == "default_file":
1449
+ # Create or append to default conventions file
1450
+ target_path.parent.mkdir(parents=True, exist_ok=True)
1451
+
1452
+ if target_path.exists():
1453
+ existing = target_path.read_text()
1454
+ # Check if there's a component section
1455
+ if component:
1456
+ section_pattern = rf'^##\s+{re.escape(component)}.*?(?=\n##|\Z)'
1457
+ match = re.search(section_pattern, existing, re.MULTILINE | re.IGNORECASE | re.DOTALL)
1458
+ if match:
1459
+ # Append to component section
1460
+ section_end = match.end()
1461
+ new_content = existing[:section_end] + f"\n- {content}" + existing[section_end:]
1462
+ else:
1463
+ # Create new component section
1464
+ new_content = existing.rstrip() + f"\n\n## {component.title()}\n\n- {content}\n"
1465
+ else:
1466
+ # Append to general section or end
1467
+ new_content = existing.rstrip() + f"\n\n- {content}\n"
1468
+ else:
1469
+ # Create new file
1470
+ header = f"## {component.title()}\n\n" if component else ""
1471
+ new_content = f"""# Team Conventions
1472
+
1473
+ {header}- {content}
1474
+ """
1475
+ target_path.write_text(new_content)
1476
+ click.echo(f"✓ Convention added to {target['path']}")
1477
+
1478
+ # Reindex the file
1479
+ _reindex_convention_file(path, target_path)
1480
+
1481
+
1482
+ def _append_convention_to_file(path: Path, to_file: Path, content: str):
1483
+ """Append a convention directly to a user-specified file."""
1484
+ import re
1485
+
1486
+ target_path = path / to_file if not to_file.is_absolute() else to_file
1487
+
1488
+ if not target_path.exists():
1489
+ click.echo(f"✗ File not found: {to_file}", err=True)
1490
+ return
1491
+
1492
+ file_content = target_path.read_text()
1493
+
1494
+ # Try to find a Conventions section to append to
1495
+ pattern = r'(^##\s+(?:Conventions?|Rules|Standards|Guidelines).*?)(\n##|\Z)'
1496
+ match = re.search(pattern, file_content, re.MULTILINE | re.IGNORECASE | re.DOTALL)
1497
+
1498
+ if match:
1499
+ # Append to existing section
1500
+ section_end = match.end(1)
1501
+ new_content = file_content[:section_end] + f"\n\n- {content}" + file_content[section_end:]
1502
+ target_path.write_text(new_content)
1503
+ click.echo(f"✓ Convention added to {to_file}")
1504
+ click.echo(f" Added to ## Conventions section")
1505
+ else:
1506
+ # Append at end of file
1507
+ new_content = file_content.rstrip() + f"\n\n- {content}\n"
1508
+ target_path.write_text(new_content)
1509
+ click.echo(f"✓ Convention appended to {to_file}")
1510
+
1511
+ # Reindex the file
1512
+ _reindex_convention_file(path, target_path)
1513
+
1514
+
1515
+ def _reindex_convention_file(path: Path, target_path: Path):
1516
+ """Reindex a convention file after modification."""
1517
+ try:
1518
+ db = get_db(path)
1519
+ from .indexers.docs import index_file
1520
+ entries = index_file(target_path)
1521
+ for entry in entries:
1522
+ db.upsert(
1523
+ ids=[f"{entry.file_path}:{entry.chunk_index}"],
1524
+ documents=[entry.content],
1525
+ metadatas=[entry.to_metadata()],
1526
+ )
1527
+ click.echo(f" Indexed {len(entries)} section(s)")
1528
+ except Exception as e:
1529
+ click.echo(f" ⚠ Could not reindex: {e}")
1530
+
1531
+
896
1532
  @main.command()
897
1533
  @click.option("--path", type=click.Path(exists=True, path_type=Path), default=".")
898
1534
  @click.option("--dry-run", is_flag=True, help="Show what would be removed")
@@ -1020,9 +1656,9 @@ def new_branch(issue: int, path: Path, content: str, issue_json: str, branch: st
1020
1656
  click.echo(f"✗ Could not fetch issue #{issue}", err=True)
1021
1657
  return
1022
1658
 
1023
- title = issue_data.get("title", f"Issue #{issue}")
1024
- body = issue_data.get("body", "")
1025
- labels = issue_data.get("labels", [])
1659
+ title = issue_data.get("title") or f"Issue #{issue}"
1660
+ body = issue_data.get("body") or "" # Handle null and empty
1661
+ labels = issue_data.get("labels") or []
1026
1662
 
1027
1663
  if labels:
1028
1664
  if isinstance(labels[0], dict):
@@ -1076,115 +1712,244 @@ author: {get_author()}
1076
1712
 
1077
1713
 
1078
1714
  # ============================================================================
1079
- # Command Installation
1715
+ # Usage Documentation
1080
1716
  # ============================================================================
1081
1717
 
1082
1718
 
1083
- def get_commands_dir() -> Path:
1084
- """Get the directory containing bundled command templates."""
1085
- return Path(__file__).parent / "commands"
1719
+ @main.command("usage")
1720
+ @click.option("--section", "-s", help="Show specific section (mcp, cli, workflows, conventions)")
1721
+ def usage(section: str | None):
1722
+ """Show how to use ragtime effectively with AI agents.
1086
1723
 
1724
+ Prints documentation on integrating ragtime into your AI workflow,
1725
+ whether using the MCP server or CLI directly.
1726
+ """
1727
+ sections = {
1728
+ "mcp": USAGE_MCP,
1729
+ "cli": USAGE_CLI,
1730
+ "workflows": USAGE_WORKFLOWS,
1731
+ "conventions": USAGE_CONVENTIONS,
1732
+ }
1087
1733
 
1088
- def get_available_commands() -> list[str]:
1089
- """List available command templates."""
1090
- commands_dir = get_commands_dir()
1091
- if not commands_dir.exists():
1092
- return []
1093
- return [f.stem for f in commands_dir.glob("*.md")]
1094
-
1095
-
1096
- @main.command("install")
1097
- @click.option("--global", "global_install", is_flag=True, help="Install to ~/.claude/commands/")
1098
- @click.option("--workspace", "workspace_install", is_flag=True, help="Install to .claude/commands/")
1099
- @click.option("--list", "list_commands", is_flag=True, help="List available commands")
1100
- @click.option("--force", is_flag=True, help="Overwrite existing commands without asking")
1101
- @click.argument("commands", nargs=-1)
1102
- def install_commands(global_install: bool, workspace_install: bool, list_commands: bool,
1103
- force: bool, commands: tuple):
1104
- """Install Claude command templates."""
1105
- available = get_available_commands()
1106
-
1107
- if list_commands:
1108
- click.echo("Available commands:")
1109
- for cmd in available:
1110
- click.echo(f" - {cmd}")
1734
+ if section:
1735
+ if section.lower() in sections:
1736
+ click.echo(sections[section.lower()])
1737
+ else:
1738
+ click.echo(f"Unknown section: {section}")
1739
+ click.echo(f"Available: {', '.join(sections.keys())}")
1111
1740
  return
1112
1741
 
1113
- if global_install and workspace_install:
1114
- click.echo("Error: Cannot specify both --global and --workspace", err=True)
1115
- return
1742
+ # Print all sections
1743
+ click.echo(USAGE_HEADER)
1744
+ for content in sections.values():
1745
+ click.echo(content)
1746
+ click.echo()
1116
1747
 
1117
- if global_install:
1118
- target_dir = Path.home() / ".claude" / "commands"
1119
- elif workspace_install:
1120
- target_dir = Path.cwd() / ".claude" / "commands"
1121
- else:
1122
- target_dir = Path.cwd() / ".claude" / "commands"
1123
- click.echo("Installing to workspace (.claude/commands/)")
1124
-
1125
- if commands:
1126
- to_install = [c for c in commands if c in available]
1127
- not_found = [c for c in commands if c not in available]
1128
- if not_found:
1129
- click.echo(f"Warning: Commands not found: {', '.join(not_found)}", err=True)
1130
- else:
1131
- to_install = available
1132
1748
 
1133
- if not to_install:
1134
- click.echo("No commands to install.")
1135
- return
1749
+ USAGE_HEADER = """
1750
+ # Ragtime Usage Guide
1136
1751
 
1137
- target_dir.mkdir(parents=True, exist_ok=True)
1138
- commands_dir = get_commands_dir()
1139
- installed = 0
1140
- skipped = 0
1141
- namespaced = 0
1142
-
1143
- for cmd in to_install:
1144
- source = commands_dir / f"{cmd}.md"
1145
- target = target_dir / f"{cmd}.md"
1146
- namespaced_target = target_dir / f"ragtime-{cmd}.md"
1147
-
1148
- if target.exists() and not force:
1149
- # Check if it's our file (contains ragtime marker)
1150
- existing_content = target.read_text()
1151
- is_ragtime_file = "ragtime" in existing_content.lower() and "mcp__ragtime" in existing_content
1152
-
1153
- if is_ragtime_file:
1154
- # It's our file, safe to overwrite
1155
- target.write_text(source.read_text())
1156
- click.echo(f" ✓ {cmd}.md (updated)")
1157
- installed += 1
1158
- else:
1159
- # Conflict with non-ragtime command
1160
- click.echo(f"\n⚠️ Conflict: {cmd}.md already exists (not a ragtime command)")
1161
- click.echo(f" 1. Overwrite with ragtime's version")
1162
- click.echo(f" 2. Skip (keep existing)")
1163
- click.echo(f" 3. Install as ragtime-{cmd}.md")
1164
-
1165
- choice = click.prompt(" Choice", type=click.Choice(["1", "2", "3"]), default="2")
1166
-
1167
- if choice == "1":
1168
- target.write_text(source.read_text())
1169
- click.echo(f" ✓ {cmd}.md (overwritten)")
1170
- installed += 1
1171
- elif choice == "2":
1172
- click.echo(f" {cmd}.md (skipped)")
1173
- skipped += 1
1174
- else:
1175
- namespaced_target.write_text(source.read_text())
1176
- click.echo(f" ✓ ragtime-{cmd}.md")
1177
- namespaced += 1
1178
- else:
1179
- target.write_text(source.read_text())
1180
- click.echo(f" ✓ {cmd}.md")
1181
- installed += 1
1752
+ Ragtime provides persistent memory for AI coding sessions. Use it via:
1753
+ - **MCP Server** (recommended for Claude Code / AI tools)
1754
+ - **CLI** (for scripts, hooks, and manual use)
1755
+ """
1756
+
1757
+ USAGE_MCP = """
1758
+ ## MCP Server Tools
1759
+
1760
+ After `ragtime init`, add to your `.mcp.json`:
1761
+
1762
+ ```json
1763
+ {
1764
+ "mcpServers": {
1765
+ "ragtime": {
1766
+ "command": "ragtime",
1767
+ "args": ["serve"]
1768
+ }
1769
+ }
1770
+ }
1771
+ ```
1772
+
1773
+ ### Core Tools
1774
+
1775
+ | Tool | Purpose |
1776
+ |------|---------|
1777
+ | `mcp__ragtime__search` | Find relevant memories, docs, and code |
1778
+ | `mcp__ragtime__remember` | Store new knowledge |
1779
+ | `mcp__ragtime__list_memories` | Browse stored memories |
1780
+ | `mcp__ragtime__forget` | Delete a memory |
1781
+ | `mcp__ragtime__graduate` | Promote branch memory to app namespace |
1782
+
1783
+ ### Search Examples
1784
+
1785
+ ```
1786
+ # Find architecture knowledge
1787
+ mcp__ragtime__search(query="authentication flow", namespace="app")
1788
+
1789
+ # Find team conventions
1790
+ mcp__ragtime__search(query="error handling", namespace="team", type="convention")
1791
+
1792
+ # Search with auto-qualifier detection
1793
+ mcp__ragtime__search(query="how does auth work in mobile", tiered=true)
1794
+ ```
1795
+
1796
+ ### Remember Examples
1797
+
1798
+ ```
1799
+ # Store architecture insight
1800
+ mcp__ragtime__remember(
1801
+ content="JWT tokens expire after 15 minutes, refresh tokens after 7 days",
1802
+ namespace="app",
1803
+ type="architecture",
1804
+ component="auth"
1805
+ )
1806
+
1807
+ # Store team convention
1808
+ mcp__ragtime__remember(
1809
+ content="Always use async/await, never .then() chains",
1810
+ namespace="team",
1811
+ type="convention"
1812
+ )
1813
+
1814
+ # Store branch-specific decision
1815
+ mcp__ragtime__remember(
1816
+ content="Using Redis for session storage because of horizontal scaling needs",
1817
+ namespace="branch-feature/auth",
1818
+ type="decision"
1819
+ )
1820
+ ```
1821
+ """
1822
+
1823
+ USAGE_CLI = """
1824
+ ## CLI Commands
1825
+
1826
+ ### Memory Management
1827
+
1828
+ ```bash
1829
+ # Search memories
1830
+ ragtime search "authentication patterns"
1831
+ ragtime search "conventions" --namespace team
1832
+
1833
+ # Add a convention
1834
+ ragtime add-convention "Always validate user input"
1835
+ ragtime add-convention -c auth "JWT must be validated on every request"
1836
+
1837
+ # List memories
1838
+ ragtime memories --namespace app
1839
+ ragtime memories --type convention
1840
+
1841
+ # Check conventions for changed files
1842
+ ragtime check-conventions
1843
+ ragtime check-conventions --all # For AI (comprehensive)
1844
+ ```
1845
+
1846
+ ### Branch Context
1847
+
1848
+ ```bash
1849
+ # Create branch context from GitHub issue
1850
+ ragtime new-branch 123 --branch feature/auth
1851
+
1852
+ # Graduate branch memories after PR merge
1853
+ ragtime graduate --branch feature/auth
1854
+ ```
1855
+
1856
+ ### Indexing
1857
+
1858
+ ```bash
1859
+ # Index docs and code
1860
+ ragtime index
1861
+ ragtime reindex # Full reindex
1862
+ ```
1863
+ """
1864
+
1865
+ USAGE_WORKFLOWS = """
1866
+ ## Recommended Workflows
1867
+
1868
+ ### Starting Work on an Issue
1869
+
1870
+ 1. AI reads the issue and existing context:
1871
+ ```
1872
+ mcp__ragtime__search(query="auth implementation", namespace="app")
1873
+ ```
1874
+
1875
+ 2. Check for branch context:
1876
+ ```bash
1877
+ # Look for .ragtime/branches/{branch}/context.md
1878
+ ```
1879
+
1880
+ 3. As you make decisions, store them:
1881
+ ```
1882
+ mcp__ragtime__remember(
1883
+ content="Chose PKCE flow for mobile OAuth",
1884
+ namespace="branch-feature/oauth",
1885
+ type="decision"
1886
+ )
1887
+ ```
1888
+
1889
+ ### Before Creating a PR
1182
1890
 
1183
- click.echo(f"\nInstalled {installed} commands to {target_dir}")
1184
- if namespaced:
1185
- click.echo(f" ({namespaced} installed with ragtime- prefix)")
1186
- if skipped:
1187
- click.echo(f" ({skipped} skipped due to conflicts)")
1891
+ 1. Check conventions:
1892
+ ```bash
1893
+ ragtime check-conventions
1894
+ ```
1895
+
1896
+ 2. Review branch memories for graduation candidates
1897
+
1898
+ ### After PR Merge
1899
+
1900
+ 1. Graduate valuable branch memories to app namespace:
1901
+ ```bash
1902
+ ragtime graduate --branch feature/auth
1903
+ ```
1904
+
1905
+ ### Session Handoff
1906
+
1907
+ Store context in `.ragtime/branches/{branch}/context.md`:
1908
+ - Current state
1909
+ - What's left to do
1910
+ - Key decisions made
1911
+ - Blockers or notes for next session
1912
+ """
1913
+
1914
+ USAGE_CONVENTIONS = """
1915
+ ## Convention System
1916
+
1917
+ ### Storing Conventions
1918
+
1919
+ Conventions can live in:
1920
+ - **Files**: `.ragtime/CONVENTIONS.md` or `docs/conventions/`
1921
+ - **Doc sections**: Any `## Conventions` section in markdown
1922
+ - **Memories**: `team` namespace with type `convention`
1923
+
1924
+ ```bash
1925
+ # Add via CLI (routes automatically)
1926
+ ragtime add-convention "Use snake_case for JSON fields"
1927
+
1928
+ # Add to specific file
1929
+ ragtime add-convention --to-file docs/api.md "Return 404 for missing resources"
1930
+
1931
+ # Add as memory only
1932
+ ragtime add-convention --to-memory "Prefer composition over inheritance"
1933
+ ```
1934
+
1935
+ ### Convention Detection
1936
+
1937
+ The indexer automatically detects conventions from:
1938
+ - Files named `*conventions*`, `*rules*`, `*standards*`
1939
+ - Sections with headers like `## Conventions`, `## Rules`
1940
+ - Content between `<!-- convention -->` markers
1941
+ - Frontmatter: `has_conventions: true` or `convention_sections: [...]`
1942
+
1943
+ ### Checking Conventions
1944
+
1945
+ ```bash
1946
+ # Human use (filtered to relevant)
1947
+ ragtime check-conventions
1948
+
1949
+ # AI use (comprehensive)
1950
+ ragtime check-conventions --all --json
1951
+ ```
1952
+ """
1188
1953
 
1189
1954
 
1190
1955
  @main.command("setup-ghp")
@@ -1219,7 +1984,8 @@ def setup_ghp(remove: bool):
1219
1984
  return
1220
1985
 
1221
1986
  # Updated path for .ragtime/
1222
- hook_command = "ragtime new-branch ${issue.number} --issue-json '${issue.json}' --branch '${branch}'"
1987
+ # NOTE: No quotes around template vars - GHP's shellEscape() handles escaping
1988
+ hook_command = "ragtime new-branch ${issue.number} --issue-json ${issue.json} --branch ${branch}"
1223
1989
 
1224
1990
  result = subprocess.run(
1225
1991
  [