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