ragtime-cli 0.1.2__tar.gz → 0.2.1__tar.gz

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 (26) hide show
  1. {ragtime_cli-0.1.2/ragtime_cli.egg-info → ragtime_cli-0.2.1}/PKG-INFO +1 -1
  2. {ragtime_cli-0.1.2 → ragtime_cli-0.2.1}/pyproject.toml +1 -1
  3. {ragtime_cli-0.1.2 → ragtime_cli-0.2.1/ragtime_cli.egg-info}/PKG-INFO +1 -1
  4. {ragtime_cli-0.1.2 → ragtime_cli-0.2.1}/src/cli.py +395 -293
  5. {ragtime_cli-0.1.2 → ragtime_cli-0.2.1}/src/config.py +3 -2
  6. {ragtime_cli-0.1.2 → ragtime_cli-0.2.1}/src/memory.py +3 -3
  7. {ragtime_cli-0.1.2 → ragtime_cli-0.2.1}/LICENSE +0 -0
  8. {ragtime_cli-0.1.2 → ragtime_cli-0.2.1}/README.md +0 -0
  9. {ragtime_cli-0.1.2 → ragtime_cli-0.2.1}/ragtime_cli.egg-info/SOURCES.txt +0 -0
  10. {ragtime_cli-0.1.2 → ragtime_cli-0.2.1}/ragtime_cli.egg-info/dependency_links.txt +0 -0
  11. {ragtime_cli-0.1.2 → ragtime_cli-0.2.1}/ragtime_cli.egg-info/entry_points.txt +0 -0
  12. {ragtime_cli-0.1.2 → ragtime_cli-0.2.1}/ragtime_cli.egg-info/requires.txt +0 -0
  13. {ragtime_cli-0.1.2 → ragtime_cli-0.2.1}/ragtime_cli.egg-info/top_level.txt +0 -0
  14. {ragtime_cli-0.1.2 → ragtime_cli-0.2.1}/setup.cfg +0 -0
  15. {ragtime_cli-0.1.2 → ragtime_cli-0.2.1}/src/__init__.py +0 -0
  16. {ragtime_cli-0.1.2 → ragtime_cli-0.2.1}/src/commands/audit.md +0 -0
  17. {ragtime_cli-0.1.2 → ragtime_cli-0.2.1}/src/commands/handoff.md +0 -0
  18. {ragtime_cli-0.1.2 → ragtime_cli-0.2.1}/src/commands/pr-graduate.md +0 -0
  19. {ragtime_cli-0.1.2 → ragtime_cli-0.2.1}/src/commands/recall.md +0 -0
  20. {ragtime_cli-0.1.2 → ragtime_cli-0.2.1}/src/commands/remember.md +0 -0
  21. {ragtime_cli-0.1.2 → ragtime_cli-0.2.1}/src/commands/save.md +0 -0
  22. {ragtime_cli-0.1.2 → ragtime_cli-0.2.1}/src/commands/start.md +0 -0
  23. {ragtime_cli-0.1.2 → ragtime_cli-0.2.1}/src/db.py +0 -0
  24. {ragtime_cli-0.1.2 → ragtime_cli-0.2.1}/src/indexers/__init__.py +0 -0
  25. {ragtime_cli-0.1.2 → ragtime_cli-0.2.1}/src/indexers/docs.py +0 -0
  26. {ragtime_cli-0.1.2 → ragtime_cli-0.2.1}/src/mcp_server.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ragtime-cli
3
- Version: 0.1.2
3
+ Version: 0.2.1
4
4
  Summary: Local-first memory and RAG system for Claude Code - semantic search over code, docs, and team knowledge
5
5
  Author-email: Bret Martineau <bretwardjames@gmail.com>
6
6
  License-Expression: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ragtime-cli"
3
- version = "0.1.2"
3
+ version = "0.2.1"
4
4
  description = "Local-first memory and RAG system for Claude Code - semantic search over code, docs, and team knowledge"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ragtime-cli
3
- Version: 0.1.2
3
+ Version: 0.2.1
4
4
  Summary: Local-first memory and RAG system for Claude Code - semantic search over code, docs, and team knowledge
5
5
  Author-email: Bret Martineau <bretwardjames@gmail.com>
6
6
  License-Expression: MIT
@@ -5,6 +5,9 @@ Ragtime CLI - semantic search and memory storage.
5
5
  from pathlib import Path
6
6
  import subprocess
7
7
  import click
8
+ import os
9
+ import signal
10
+ import sys
8
11
 
9
12
  from .db import RagtimeDB
10
13
  from .config import RagtimeConfig, init_config
@@ -27,7 +30,6 @@ def get_memory_store(project_path: Path) -> MemoryStore:
27
30
  def get_author() -> str:
28
31
  """Get the current developer's username."""
29
32
  try:
30
- # Try gh CLI first
31
33
  result = subprocess.run(
32
34
  ["gh", "api", "user", "--jq", ".login"],
33
35
  capture_output=True,
@@ -40,7 +42,6 @@ def get_author() -> str:
40
42
  pass
41
43
 
42
44
  try:
43
- # Fall back to git config
44
45
  result = subprocess.run(
45
46
  ["git", "config", "user.name"],
46
47
  capture_output=True,
@@ -122,8 +123,50 @@ def get_current_branch(path: Path) -> str | None:
122
123
  return None
123
124
 
124
125
 
126
+ def get_branch_slug(ref: str) -> str:
127
+ """Convert a git ref to a branch slug for folder naming."""
128
+ if ref.startswith("origin/"):
129
+ ref = ref[7:]
130
+ return ref.replace("/", "-")
131
+
132
+
133
+ def get_remote_branches_with_ragtime(path: Path) -> list[str]:
134
+ """Get list of remote branches that have .ragtime/branches/ content."""
135
+ try:
136
+ # Get all remote branches
137
+ result = subprocess.run(
138
+ ["git", "branch", "-r", "--format=%(refname:short)"],
139
+ cwd=path,
140
+ capture_output=True,
141
+ text=True,
142
+ timeout=10,
143
+ )
144
+ if result.returncode != 0:
145
+ return []
146
+
147
+ branches = []
148
+ for ref in result.stdout.strip().split("\n"):
149
+ if not ref or ref.endswith("/HEAD"):
150
+ continue
151
+
152
+ # Check if this branch has ragtime content
153
+ check = subprocess.run(
154
+ ["git", "ls-tree", "-r", "--name-only", ref, ".ragtime/branches/"],
155
+ cwd=path,
156
+ capture_output=True,
157
+ text=True,
158
+ timeout=5,
159
+ )
160
+ if check.returncode == 0 and check.stdout.strip():
161
+ branches.append(ref)
162
+
163
+ return branches
164
+ except Exception:
165
+ return []
166
+
167
+
125
168
  @click.group()
126
- @click.version_option(version="0.1.2")
169
+ @click.version_option(version="0.2.1")
127
170
  def main():
128
171
  """Ragtime - semantic search over code and documentation."""
129
172
  pass
@@ -140,19 +183,42 @@ def init(path: Path):
140
183
  click.echo(f" Code paths: {config.code.paths}")
141
184
  click.echo(f" Languages: {config.code.languages}")
142
185
 
143
- # Create memory directory structure
144
- memory_dir = path / ".claude" / "memory"
145
- (memory_dir / "app").mkdir(parents=True, exist_ok=True)
146
- (memory_dir / "team").mkdir(parents=True, exist_ok=True)
147
- (memory_dir / "branches").mkdir(parents=True, exist_ok=True)
148
- click.echo(f"\nCreated .claude/memory/ structure")
186
+ # Create directory structure
187
+ ragtime_dir = path / ".ragtime"
188
+ (ragtime_dir / "app").mkdir(parents=True, exist_ok=True)
189
+ (ragtime_dir / "team").mkdir(parents=True, exist_ok=True)
190
+ (ragtime_dir / "branches").mkdir(parents=True, exist_ok=True)
191
+ (ragtime_dir / "archive" / "branches").mkdir(parents=True, exist_ok=True)
192
+
193
+ # Create .gitkeep files
194
+ for subdir in ["app", "team", "archive/branches"]:
195
+ gitkeep = ragtime_dir / subdir / ".gitkeep"
196
+ if not gitkeep.exists():
197
+ gitkeep.touch()
198
+
199
+ # Create .gitignore for synced branches (dot-prefixed)
200
+ gitignore_path = ragtime_dir / ".gitignore"
201
+ gitignore_content = """# Synced branches from teammates (dot-prefixed)
202
+ branches/.*
203
+
204
+ # Index database
205
+ index/
206
+ """
207
+ gitignore_path.write_text(gitignore_content)
208
+
209
+ click.echo(f"\nCreated .ragtime/ structure:")
210
+ click.echo(f" app/ - Graduated knowledge (tracked)")
211
+ click.echo(f" team/ - Team conventions (tracked)")
212
+ click.echo(f" branches/ - Active branches (yours tracked, synced gitignored)")
213
+ click.echo(f" archive/ - Completed branches (tracked)")
149
214
 
150
215
  # Check for ghp-cli
151
216
  if check_ghp_installed():
152
- click.echo(f"\n✓ ghp-cli detected - will use for issue lookups")
217
+ click.echo(f"\n✓ ghp-cli detected")
218
+ click.echo(f" Run 'ragtime setup-ghp' to enable auto-context on 'ghp start'")
153
219
  else:
154
- click.echo(f"\n• ghp-cli not found - will use gh CLI for issue lookups")
155
- click.echo(f" Install ghp for enhanced workflow: pip install ghp-cli")
220
+ click.echo(f"\n• ghp-cli not found")
221
+ click.echo(f" Install for enhanced workflow: npm install -g @bretwardjames/ghp-cli")
156
222
 
157
223
 
158
224
  @main.command()
@@ -172,7 +238,6 @@ def index(path: Path, index_type: str, clear: bool):
172
238
  else:
173
239
  db.clear(type_filter=index_type)
174
240
 
175
- # Index docs
176
241
  if index_type in ("all", "docs"):
177
242
  total_entries = []
178
243
  for docs_path in config.docs.paths:
@@ -192,24 +257,17 @@ def index(path: Path, index_type: str, clear: bool):
192
257
  ids = [e.file_path for e in total_entries]
193
258
  documents = [e.content for e in total_entries]
194
259
  metadatas = [e.to_metadata() for e in total_entries]
195
-
196
260
  db.upsert(ids=ids, documents=documents, metadatas=metadatas)
197
261
  click.echo(f" Indexed {len(total_entries)} documents")
198
262
  else:
199
263
  click.echo(" No documents found")
200
264
 
201
- # Index code
202
265
  if index_type in ("all", "code"):
203
- # Build exclude list that includes docs paths
204
266
  code_exclude = list(config.code.exclude)
205
267
  for docs_path in config.docs.paths:
206
268
  code_exclude.append(f"**/{docs_path}/**")
207
-
208
269
  click.echo("Code indexing not yet implemented")
209
- click.echo(f" Will exclude docs paths: {config.docs.paths}")
210
- # TODO: Implement code indexer with code_exclude
211
270
 
212
- # Show stats
213
271
  stats = db.stats()
214
272
  click.echo(f"\nIndex stats: {stats['total']} total ({stats['docs']} docs, {stats['code']} code)")
215
273
 
@@ -219,9 +277,11 @@ def index(path: Path, index_type: str, clear: bool):
219
277
  @click.option("--path", type=click.Path(exists=True, path_type=Path), default=".")
220
278
  @click.option("--type", "type_filter", type=click.Choice(["all", "docs", "code"]), default="all")
221
279
  @click.option("--namespace", "-n", help="Filter by namespace")
280
+ @click.option("--include-archive", is_flag=True, help="Also search archived branches")
222
281
  @click.option("--limit", "-l", default=5, help="Max results")
223
282
  @click.option("--verbose", "-v", is_flag=True, help="Show full content")
224
- def search(query: str, path: Path, type_filter: str, namespace: str, limit: int, verbose: bool):
283
+ def search(query: str, path: Path, type_filter: str, namespace: str,
284
+ include_archive: bool, limit: int, verbose: bool):
225
285
  """Search indexed content."""
226
286
  path = Path(path).resolve()
227
287
  db = get_db(path)
@@ -253,7 +313,6 @@ def search(query: str, path: Path, type_filter: str, namespace: str, limit: int,
253
313
  if verbose:
254
314
  click.echo(f"\n{result['content'][:500]}...")
255
315
  else:
256
- # Show first 150 chars
257
316
  preview = result["content"][:150].replace("\n", " ")
258
317
  click.echo(f" {preview}...")
259
318
 
@@ -368,11 +427,7 @@ def remember(content: str, path: Path, namespace: str, memory_type: str,
368
427
  type=click.Choice(["handoff", "document", "plan", "notes"]),
369
428
  help="Document type")
370
429
  def store_doc(file: Path, path: Path, namespace: str, doc_type: str):
371
- """Store a document verbatim (like handoff.md).
372
-
373
- Example:
374
- ragtime store-doc .claude/handoff.md --namespace branch-feature/auth
375
- """
430
+ """Store a document verbatim (like handoff.md)."""
376
431
  path = Path(path).resolve()
377
432
  file = Path(file).resolve()
378
433
  store = get_memory_store(path)
@@ -389,11 +444,7 @@ def store_doc(file: Path, path: Path, namespace: str, doc_type: str):
389
444
  @click.option("--path", type=click.Path(exists=True, path_type=Path), default=".")
390
445
  @click.confirmation_option(prompt="Are you sure you want to delete this memory?")
391
446
  def forget(memory_id: str, path: Path):
392
- """Delete a memory by ID.
393
-
394
- Example:
395
- ragtime forget abc123
396
- """
447
+ """Delete a memory by ID."""
397
448
  path = Path(path).resolve()
398
449
  store = get_memory_store(path)
399
450
 
@@ -410,14 +461,7 @@ def forget(memory_id: str, path: Path):
410
461
  type=click.Choice(["high", "medium", "low"]),
411
462
  help="Confidence level for graduated memory")
412
463
  def graduate(memory_id: str, path: Path, confidence: str):
413
- """Graduate a branch memory to app namespace.
414
-
415
- Copies the memory to app namespace with high confidence
416
- and marks the original as graduated.
417
-
418
- Example:
419
- ragtime graduate abc123
420
- """
464
+ """Graduate a branch memory to app namespace."""
421
465
  path = Path(path).resolve()
422
466
  store = get_memory_store(path)
423
467
 
@@ -443,13 +487,7 @@ def graduate(memory_id: str, path: Path, confidence: str):
443
487
  @click.option("--verbose", "-v", is_flag=True, help="Show full content")
444
488
  def list_memories(path: Path, namespace: str, type_filter: str, status: str,
445
489
  component: str, limit: int, verbose: bool):
446
- """List memories with optional filters.
447
-
448
- Examples:
449
- ragtime memories --namespace app
450
- ragtime memories --namespace branch-* --status active
451
- ragtime memories --type decision --component auth
452
- """
490
+ """List memories with optional filters."""
453
491
  path = Path(path).resolve()
454
492
  store = get_memory_store(path)
455
493
 
@@ -485,10 +523,7 @@ def list_memories(path: Path, namespace: str, type_filter: str, status: str,
485
523
  @main.command()
486
524
  @click.option("--path", type=click.Path(exists=True, path_type=Path), default=".")
487
525
  def reindex(path: Path):
488
- """Reindex all memory files.
489
-
490
- Scans .claude/memory/ and adds any files not in the index.
491
- """
526
+ """Reindex all memory files."""
492
527
  path = Path(path).resolve()
493
528
  store = get_memory_store(path)
494
529
 
@@ -505,22 +540,15 @@ def reindex(path: Path):
505
540
  def new_branch(issue: int, path: Path, content: str, issue_json: str, branch: str):
506
541
  """Initialize a branch context from a GitHub issue.
507
542
 
508
- Creates .claude/memory/branches/{branch-slug}/context.md with either:
543
+ Creates .ragtime/branches/{branch-slug}/context.md with either:
509
544
  - Provided content (from --content flag, e.g., LLM-generated plan)
510
545
  - Auto-generated scaffold from issue metadata (fallback)
511
-
512
- Examples:
513
- ragtime new-branch 42 # Scaffold from issue #42
514
- ragtime new-branch 42 --content "..." # Use provided content
515
- ragtime new-branch 42 --issue-json '{...}' # Use JSON from ghp hook
516
- ragtime new-branch 42 --branch my-feat # Specify branch name
517
546
  """
518
547
  import json
519
548
  from datetime import date
520
549
 
521
550
  path = Path(path).resolve()
522
551
 
523
- # Determine branch name
524
552
  if not branch:
525
553
  branch = get_current_branch(path)
526
554
  if not branch or branch in ("main", "master"):
@@ -529,23 +557,21 @@ def new_branch(issue: int, path: Path, content: str, issue_json: str, branch: st
529
557
 
530
558
  # Create branch slug for folder name
531
559
  branch_slug = branch.replace("/", "-")
532
- branch_dir = path / ".claude" / "memory" / "branches" / branch_slug
560
+ branch_dir = path / ".ragtime" / "branches" / branch_slug
533
561
  branch_dir.mkdir(parents=True, exist_ok=True)
534
562
 
535
563
  context_file = branch_dir / "context.md"
536
564
 
537
565
  if content:
538
- # Use provided content directly
539
566
  context_file.write_text(content)
540
567
  click.echo(f"✓ Created context.md with provided content")
541
568
  click.echo(f" Path: {context_file.relative_to(path)}")
542
569
  return
543
570
 
544
- # Get issue data from JSON, ghp, or gh
571
+ # Get issue data
545
572
  issue_data = None
546
573
  source = None
547
574
 
548
- # Use provided JSON if available (from ghp hook)
549
575
  if issue_json:
550
576
  try:
551
577
  issue_data = json.loads(issue_json)
@@ -554,30 +580,22 @@ def new_branch(issue: int, path: Path, content: str, issue_json: str, branch: st
554
580
  click.echo(f"✗ Invalid JSON: {e}", err=True)
555
581
  return
556
582
  else:
557
- # Fall back to fetching from API
558
583
  click.echo(f"Fetching issue #{issue}...")
559
-
560
- # Try ghp first if available
561
584
  if check_ghp_installed():
562
585
  issue_data = get_issue_from_ghp(issue, path)
563
586
  source = "ghp"
564
-
565
- # Fall back to gh
566
587
  if not issue_data:
567
588
  issue_data = get_issue_from_gh(issue, path)
568
589
  source = "gh"
569
590
 
570
591
  if not issue_data:
571
592
  click.echo(f"✗ Could not fetch issue #{issue}", err=True)
572
- click.echo(" Make sure you're in a git repo with GitHub remote")
573
593
  return
574
594
 
575
- # Extract issue fields
576
595
  title = issue_data.get("title", f"Issue #{issue}")
577
596
  body = issue_data.get("body", "")
578
597
  labels = issue_data.get("labels", [])
579
598
 
580
- # Format labels
581
599
  if labels:
582
600
  if isinstance(labels[0], dict):
583
601
  label_names = [l.get("name", "") for l in labels]
@@ -587,7 +605,6 @@ def new_branch(issue: int, path: Path, content: str, issue_json: str, branch: st
587
605
  else:
588
606
  labels_str = ""
589
607
 
590
- # Generate scaffold context.md
591
608
  scaffold = f"""---
592
609
  type: context
593
610
  branch: {branch}
@@ -628,7 +645,6 @@ author: {get_author()}
628
645
  click.echo(f"✓ Created context.md from issue #{issue}")
629
646
  click.echo(f" Path: {context_file.relative_to(path)}")
630
647
  click.echo(f" Source: {source}")
631
- click.echo(f"\nNext: Fill in the Plan section or use /start to generate it")
632
648
 
633
649
 
634
650
  # ============================================================================
@@ -657,13 +673,7 @@ def get_available_commands() -> list[str]:
657
673
  @click.argument("commands", nargs=-1)
658
674
  def install_commands(global_install: bool, workspace_install: bool, list_commands: bool,
659
675
  force: bool, commands: tuple):
660
- """Install Claude command templates.
661
-
662
- Examples:
663
- ragtime install --list # List available commands
664
- ragtime install --workspace # Install all to .claude/commands/
665
- ragtime install --global remember recall # Install specific commands globally
666
- """
676
+ """Install Claude command templates."""
667
677
  available = get_available_commands()
668
678
 
669
679
  if list_commands:
@@ -672,7 +682,6 @@ def install_commands(global_install: bool, workspace_install: bool, list_command
672
682
  click.echo(f" - {cmd}")
673
683
  return
674
684
 
675
- # Determine target directory
676
685
  if global_install and workspace_install:
677
686
  click.echo("Error: Cannot specify both --global and --workspace", err=True)
678
687
  return
@@ -682,12 +691,9 @@ def install_commands(global_install: bool, workspace_install: bool, list_command
682
691
  elif workspace_install:
683
692
  target_dir = Path.cwd() / ".claude" / "commands"
684
693
  else:
685
- # Default to workspace
686
694
  target_dir = Path.cwd() / ".claude" / "commands"
687
695
  click.echo("Installing to workspace (.claude/commands/)")
688
- click.echo("Use --global for ~/.claude/commands/")
689
696
 
690
- # Determine which commands to install
691
697
  if commands:
692
698
  to_install = [c for c in commands if c in available]
693
699
  not_found = [c for c in commands if c not in available]
@@ -700,13 +706,9 @@ def install_commands(global_install: bool, workspace_install: bool, list_command
700
706
  click.echo("No commands to install.")
701
707
  return
702
708
 
703
- # Create target directory
704
709
  target_dir.mkdir(parents=True, exist_ok=True)
705
-
706
- # Install each command
707
710
  commands_dir = get_commands_dir()
708
711
  installed = 0
709
- skipped = 0
710
712
 
711
713
  for cmd in to_install:
712
714
  source = commands_dir / f"{cmd}.md"
@@ -715,45 +717,26 @@ def install_commands(global_install: bool, workspace_install: bool, list_command
715
717
  if target.exists() and not force:
716
718
  if click.confirm(f" {cmd}.md exists. Overwrite?", default=False):
717
719
  target.write_text(source.read_text())
718
- click.echo(f" ✓ {cmd}.md (overwritten)")
719
720
  installed += 1
720
- else:
721
- click.echo(f" - {cmd}.md (skipped)")
722
- skipped += 1
723
721
  else:
724
722
  target.write_text(source.read_text())
725
723
  click.echo(f" ✓ {cmd}.md")
726
724
  installed += 1
727
725
 
728
726
  click.echo(f"\nInstalled {installed} commands to {target_dir}")
729
- if skipped:
730
- click.echo(f"Skipped {skipped} existing commands (use --force to overwrite)")
731
-
732
- # Remind about MCP server setup
733
- click.echo("\nTo use these commands, add ragtime MCP server to your Claude config:")
734
- click.echo(' "ragtime": {"command": "ragtime-mcp", "args": ["--path", "."]}')
735
727
 
736
728
 
737
729
  @main.command("setup-ghp")
738
730
  @click.option("--remove", is_flag=True, help="Remove ragtime hooks from ghp")
739
731
  def setup_ghp(remove: bool):
740
- """Register ragtime hooks with ghp-cli.
741
-
742
- Adds event hooks so ghp automatically creates context.md when starting issues.
743
-
744
- Examples:
745
- ragtime setup-ghp # Register hooks
746
- ragtime setup-ghp --remove # Remove hooks
747
- """
732
+ """Register ragtime hooks with ghp-cli."""
748
733
  if not check_ghp_installed():
749
734
  click.echo("✗ ghp-cli not installed", err=True)
750
- click.echo(" Install with: npm install -g @bretwardjames/ghp-cli")
751
735
  return
752
736
 
753
737
  hook_name = "ragtime-context"
754
738
 
755
739
  if remove:
756
- # Remove the hook
757
740
  result = subprocess.run(
758
741
  ["ghp", "hooks", "remove", hook_name],
759
742
  capture_output=True,
@@ -762,13 +745,9 @@ def setup_ghp(remove: bool):
762
745
  if result.returncode == 0:
763
746
  click.echo(f"✓ Removed hook: {hook_name}")
764
747
  else:
765
- if "not found" in result.stderr.lower():
766
- click.echo(f"• Hook {hook_name} not registered")
767
- else:
768
- click.echo(f"✗ Failed to remove hook: {result.stderr}", err=True)
748
+ click.echo(f"• Hook {hook_name} not registered")
769
749
  return
770
750
 
771
- # Check if hook already exists
772
751
  result = subprocess.run(
773
752
  ["ghp", "hooks", "show", hook_name],
774
753
  capture_output=True,
@@ -776,11 +755,9 @@ def setup_ghp(remove: bool):
776
755
  )
777
756
  if result.returncode == 0:
778
757
  click.echo(f"• Hook {hook_name} already registered")
779
- click.echo(" Use --remove to unregister first")
780
758
  return
781
759
 
782
- # Register the hook
783
- # The command uses ${issue.number}, ${issue.json}, and ${branch} from ghp
760
+ # Updated path for .ragtime/
784
761
  hook_command = "ragtime new-branch ${issue.number} --issue-json '${issue.json}' --branch '${branch}'"
785
762
 
786
763
  result = subprocess.run(
@@ -798,7 +775,6 @@ def setup_ghp(remove: bool):
798
775
  click.echo(f"✓ Registered hook: {hook_name}")
799
776
  click.echo(f" Event: issue-started")
800
777
  click.echo(f" Action: Creates context.md from issue metadata")
801
- click.echo(f"\nNow when you run 'ghp start <issue>', ragtime will auto-create context.md")
802
778
  else:
803
779
  click.echo(f"✗ Failed to register hook: {result.stderr}", err=True)
804
780
 
@@ -808,250 +784,376 @@ def setup_ghp(remove: bool):
808
784
  # ============================================================================
809
785
 
810
786
 
811
- def get_branch_slug(ref: str) -> str:
812
- """Convert a git ref to a branch slug for folder naming."""
813
- # Remove origin/ prefix if present
814
- if ref.startswith("origin/"):
815
- ref = ref[7:]
816
- # Replace / with - for folder names
817
- return ref.replace("/", "-")
818
-
819
-
820
787
  @main.command()
821
- @click.argument("ref")
822
788
  @click.option("--path", type=click.Path(exists=True, path_type=Path), default=".")
823
- def sync(ref: str, path: Path):
824
- """Sync memories from a remote branch.
789
+ @click.option("--quiet", "-q", is_flag=True, help="Suppress output (for automated runs)")
790
+ @click.option("--auto-prune", is_flag=True, help="Automatically prune stale synced branches")
791
+ def sync(path: Path, quiet: bool, auto_prune: bool):
792
+ """Sync memories from all remote branches.
825
793
 
826
- Fetches .claude/memory/branches/* from the specified git ref
827
- and copies to a local (unmerged) folder for searching.
828
-
829
- Examples:
830
- ragtime sync origin/jm/feature-auth
831
- ragtime sync origin/sm/fix-bug
794
+ Fetches .ragtime/branches/* from remote branches and copies to
795
+ local dot-prefixed folders (e.g., .feature-branch/).
832
796
  """
833
797
  import shutil
834
- import tempfile
835
798
 
836
799
  path = Path(path).resolve()
837
- store = get_memory_store(path)
838
-
839
- # Get the branch slug for folder naming
840
- branch_slug = get_branch_slug(ref)
841
- unmerged_dir = path / ".claude" / "memory" / "branches" / f"{branch_slug}(unmerged)"
800
+ branches_dir = path / ".ragtime" / "branches"
842
801
 
843
- click.echo(f"Syncing memories from {ref}...")
802
+ if not quiet:
803
+ click.echo("Fetching remote branches...")
844
804
 
845
- # Fetch the ref first
846
- result = subprocess.run(
847
- ["git", "fetch", "origin"],
805
+ # Fetch first
806
+ subprocess.run(
807
+ ["git", "fetch", "--quiet"],
848
808
  cwd=path,
849
809
  capture_output=True,
850
- text=True,
851
810
  )
852
811
 
853
- # Check if the ref exists
854
- result = subprocess.run(
855
- ["git", "rev-parse", "--verify", ref],
856
- cwd=path,
857
- capture_output=True,
858
- text=True,
859
- )
860
- if result.returncode != 0:
861
- click.echo(f"✗ Ref not found: {ref}", err=True)
862
- return
863
-
864
- commit_hash = result.stdout.strip()[:8]
865
-
866
- # Check if there are memory files in that ref
867
- result = subprocess.run(
868
- ["git", "ls-tree", "-r", "--name-only", ref, ".claude/memory/branches/"],
869
- cwd=path,
870
- capture_output=True,
871
- text=True,
872
- )
873
-
874
- if result.returncode != 0 or not result.stdout.strip():
875
- click.echo(f"✗ No memories found in {ref}", err=True)
876
- return
877
-
878
- files = result.stdout.strip().split("\n")
879
- click.echo(f" Found {len(files)} memory files")
812
+ # Get current branch to exclude
813
+ current = get_current_branch(path)
814
+ current_slug = get_branch_slug(current) if current else None
880
815
 
881
- # Clear existing unmerged folder if it exists
882
- if unmerged_dir.exists():
883
- shutil.rmtree(unmerged_dir)
816
+ # Find remote branches with ragtime content
817
+ remote_branches = get_remote_branches_with_ragtime(path)
884
818
 
885
- unmerged_dir.mkdir(parents=True, exist_ok=True)
819
+ if not remote_branches and not quiet:
820
+ click.echo("No remote branches with ragtime content found.")
886
821
 
887
- # Extract each file
888
822
  synced = 0
889
- for file_path in files:
890
- if not file_path.endswith(".md"):
823
+ for ref in remote_branches:
824
+ branch_slug = get_branch_slug(ref)
825
+
826
+ # Skip current branch
827
+ if branch_slug == current_slug:
891
828
  continue
892
829
 
893
- # Get file content from git
830
+ # Synced folders are dot-prefixed
831
+ synced_dir = branches_dir / f".{branch_slug}"
832
+
833
+ # Get files from remote
894
834
  result = subprocess.run(
895
- ["git", "show", f"{ref}:{file_path}"],
835
+ ["git", "ls-tree", "-r", "--name-only", ref, ".ragtime/branches/"],
896
836
  cwd=path,
897
837
  capture_output=True,
898
838
  text=True,
899
839
  )
900
840
 
901
- if result.returncode != 0:
841
+ if result.returncode != 0 or not result.stdout.strip():
902
842
  continue
903
843
 
904
- content = result.stdout
844
+ files = result.stdout.strip().split("\n")
905
845
 
906
- # Determine target path (flatten to unmerged folder)
907
- # Original: .claude/memory/branches/sm-feature/abc.md
908
- # Target: .claude/memory/branches/sm-feature(unmerged)/abc.md
909
- filename = Path(file_path).name
910
- target_path = unmerged_dir / filename
846
+ # Clear and recreate synced folder
847
+ if synced_dir.exists():
848
+ shutil.rmtree(synced_dir)
849
+ synced_dir.mkdir(parents=True, exist_ok=True)
911
850
 
912
- target_path.write_text(content)
913
- synced += 1
851
+ # Extract files
852
+ for file_path in files:
853
+ if not file_path.endswith(".md"):
854
+ continue
914
855
 
915
- # Index with pre-merge status
916
- try:
917
- memory = Memory.from_file(target_path)
918
- memory.status = "pre-merge"
919
- # Update the namespace to include (unmerged) marker
920
- memory.namespace = f"branch-{branch_slug}(unmerged)"
921
-
922
- store.db.upsert(
923
- ids=[f"{memory.id}-unmerged"],
924
- documents=[memory.content],
925
- metadatas=[{
926
- **memory.to_metadata(),
927
- "status": "pre-merge",
928
- "source_ref": ref,
929
- "source_commit": commit_hash,
930
- }],
856
+ content_result = subprocess.run(
857
+ ["git", "show", f"{ref}:{file_path}"],
858
+ cwd=path,
859
+ capture_output=True,
860
+ text=True,
931
861
  )
932
- except Exception as e:
933
- click.echo(f" Warning: Could not index {filename}: {e}", err=True)
934
862
 
935
- # Write source tracking file
936
- source_file = unmerged_dir / ".source"
937
- source_file.write_text(f"{ref} @ {commit_hash}\n")
863
+ if content_result.returncode == 0:
864
+ filename = Path(file_path).name
865
+ (synced_dir / filename).write_text(content_result.stdout)
938
866
 
939
- click.echo(f"✓ Synced {synced} memories to {unmerged_dir.relative_to(path)}")
940
- click.echo(f" Source: {ref} @ {commit_hash}")
941
- click.echo(f"\nSearch with: ragtime search 'query' --namespace 'branch-{branch_slug}(unmerged)'")
867
+ synced += 1
868
+ if not quiet:
869
+ click.echo(f" Synced .{branch_slug}")
870
+
871
+ # Check for stale synced branches (dot-prefixed with undotted counterpart)
872
+ stale = []
873
+ if branches_dir.exists():
874
+ for folder in branches_dir.iterdir():
875
+ if folder.is_dir() and folder.name.startswith("."):
876
+ undotted = folder.name[1:]
877
+ undotted_path = branches_dir / undotted
878
+ if undotted_path.exists():
879
+ stale.append(folder)
880
+
881
+ if stale:
882
+ if not quiet:
883
+ click.echo(f"\nStale synced branches detected:")
884
+ for folder in stale:
885
+ click.echo(f" • {folder.name} → {folder.name[1:]} exists (merged)")
886
+
887
+ if auto_prune:
888
+ for folder in stale:
889
+ shutil.rmtree(folder)
890
+ if not quiet:
891
+ click.echo(f"\n✓ Pruned {len(stale)} stale branches")
892
+ elif not quiet:
893
+ if click.confirm("\nPrune stale branches?", default=True):
894
+ for folder in stale:
895
+ shutil.rmtree(folder)
896
+ click.echo(f"✓ Pruned {len(stale)} stale branches")
897
+
898
+ if not quiet:
899
+ click.echo(f"\nDone. Synced {synced} branches.")
942
900
 
943
901
 
944
902
  @main.command()
945
903
  @click.option("--path", type=click.Path(exists=True, path_type=Path), default=".")
946
- @click.option("--dry-run", is_flag=True, help="Show what would be pruned without deleting")
904
+ @click.option("--dry-run", is_flag=True, help="Show what would be pruned")
947
905
  def prune(path: Path, dry_run: bool):
948
- """Remove stale (unmerged) memory folders.
906
+ """Remove stale synced branch folders.
949
907
 
950
- Checks each (unmerged) folder and removes it if:
951
- - The source branch was deleted
952
- - The source branch was merged (PR closed)
953
-
954
- Examples:
955
- ragtime prune --dry-run # See what would be pruned
956
- ragtime prune # Actually prune
908
+ Removes dot-prefixed folders (.branch) when an undotted
909
+ counterpart (branch) exists (indicating the branch was merged).
957
910
  """
958
911
  import shutil
959
912
 
960
913
  path = Path(path).resolve()
961
- branches_dir = path / ".claude" / "memory" / "branches"
914
+ branches_dir = path / ".ragtime" / "branches"
962
915
 
963
916
  if not branches_dir.exists():
964
917
  click.echo("No branches directory found.")
965
918
  return
966
919
 
967
- # Find all (unmerged) folders
968
- unmerged_folders = [d for d in branches_dir.iterdir()
969
- if d.is_dir() and d.name.endswith("(unmerged)")]
920
+ # Find dot-prefixed folders with undotted counterparts
921
+ to_prune = []
922
+ for folder in branches_dir.iterdir():
923
+ if folder.is_dir() and folder.name.startswith("."):
924
+ undotted = folder.name[1:]
925
+ if (branches_dir / undotted).exists():
926
+ to_prune.append(folder)
970
927
 
971
- if not unmerged_folders:
972
- click.echo("No (unmerged) folders to prune.")
928
+ if not to_prune:
929
+ click.echo("Nothing to prune.")
973
930
  return
974
931
 
975
- click.echo(f"Checking {len(unmerged_folders)} (unmerged) folders...\n")
932
+ click.echo("Will prune:")
933
+ for folder in to_prune:
934
+ click.echo(f" ✗ {folder.name} → {folder.name[1:]} exists")
976
935
 
977
- to_prune = []
978
- to_keep = []
936
+ if dry_run:
937
+ click.echo(f"\n--dry-run: Would prune {len(to_prune)} folders")
938
+ else:
939
+ for folder in to_prune:
940
+ shutil.rmtree(folder)
941
+ click.echo(f" Pruned: {folder.name}")
942
+ click.echo(f"\n✓ Pruned {len(to_prune)} folders")
979
943
 
980
- for folder in unmerged_folders:
981
- source_file = folder / ".source"
982
- if not source_file.exists():
983
- # No source tracking - mark for pruning
984
- to_prune.append((folder, "no source tracking"))
985
- continue
986
944
 
987
- source_info = source_file.read_text().strip()
988
- ref = source_info.split(" @ ")[0] if " @ " in source_info else source_info
945
+ # ============================================================================
946
+ # Daemon Commands
947
+ # ============================================================================
948
+
989
949
 
990
- # Check if ref still exists
991
- result = subprocess.run(
992
- ["git", "rev-parse", "--verify", ref],
993
- cwd=path,
994
- capture_output=True,
995
- text=True,
996
- )
950
+ def get_pid_file(path: Path) -> Path:
951
+ """Get path to daemon PID file."""
952
+ return path / ".ragtime" / "daemon.pid"
997
953
 
998
- if result.returncode != 0:
999
- # Ref doesn't exist - check if it was merged
1000
- # Extract branch name from ref (e.g., origin/jm/feature -> jm/feature)
1001
- branch_name = ref.replace("origin/", "") if ref.startswith("origin/") else ref
1002
954
 
1003
- # Check if a PR was merged for this branch
1004
- pr_result = subprocess.run(
1005
- ["gh", "pr", "list", "--head", branch_name, "--state", "merged", "--json", "number"],
955
+ def get_log_file(path: Path) -> Path:
956
+ """Get path to daemon log file."""
957
+ return path / ".ragtime" / "daemon.log"
958
+
959
+
960
+ @main.group()
961
+ def daemon():
962
+ """Manage the ragtime sync daemon."""
963
+ pass
964
+
965
+
966
+ @daemon.command("start")
967
+ @click.option("--path", type=click.Path(exists=True, path_type=Path), default=".")
968
+ @click.option("--interval", default="5m", help="Sync interval (e.g., 5m, 1h)")
969
+ def daemon_start(path: Path, interval: str):
970
+ """Start the sync daemon.
971
+
972
+ Runs git fetch && ragtime sync on an interval to keep
973
+ remote branches synced automatically.
974
+ """
975
+ path = Path(path).resolve()
976
+ pid_file = get_pid_file(path)
977
+ log_file = get_log_file(path)
978
+
979
+ # Check if already running
980
+ if pid_file.exists():
981
+ pid = int(pid_file.read_text().strip())
982
+ try:
983
+ os.kill(pid, 0)
984
+ click.echo(f"Daemon already running (PID: {pid})")
985
+ return
986
+ except OSError:
987
+ pid_file.unlink()
988
+
989
+ # Parse interval
990
+ interval_seconds = 300 # default 5m
991
+ if interval.endswith("m"):
992
+ interval_seconds = int(interval[:-1]) * 60
993
+ elif interval.endswith("h"):
994
+ interval_seconds = int(interval[:-1]) * 3600
995
+ elif interval.endswith("s"):
996
+ interval_seconds = int(interval[:-1])
997
+ else:
998
+ try:
999
+ interval_seconds = int(interval)
1000
+ except ValueError:
1001
+ click.echo(f"Invalid interval: {interval}", err=True)
1002
+ return
1003
+
1004
+ # Fork daemon process
1005
+ pid = os.fork()
1006
+ if pid > 0:
1007
+ # Parent process
1008
+ click.echo(f"✓ Daemon started (PID: {pid})")
1009
+ click.echo(f" Interval: {interval}")
1010
+ click.echo(f" Log: {log_file.relative_to(path)}")
1011
+ click.echo(f"\nStop with: ragtime daemon stop")
1012
+ return
1013
+
1014
+ # Child process - become daemon
1015
+ os.setsid()
1016
+
1017
+ # Write PID file
1018
+ pid_file.write_text(str(os.getpid()))
1019
+
1020
+ # Redirect output to log file
1021
+ log_fd = open(log_file, "a")
1022
+ os.dup2(log_fd.fileno(), sys.stdout.fileno())
1023
+ os.dup2(log_fd.fileno(), sys.stderr.fileno())
1024
+
1025
+ import time
1026
+ from datetime import datetime
1027
+
1028
+ print(f"\n[{datetime.now().isoformat()}] Daemon started (interval: {interval})")
1029
+
1030
+ while True:
1031
+ try:
1032
+ print(f"[{datetime.now().isoformat()}] Running sync...")
1033
+
1034
+ # Fetch
1035
+ subprocess.run(
1036
+ ["git", "fetch", "--quiet"],
1006
1037
  cwd=path,
1007
1038
  capture_output=True,
1008
- text=True,
1009
1039
  )
1010
1040
 
1011
- if pr_result.returncode == 0 and pr_result.stdout.strip() != "[]":
1012
- to_prune.append((folder, f"PR merged for {branch_name}"))
1013
- else:
1014
- to_prune.append((folder, f"branch deleted: {ref}"))
1015
- else:
1016
- to_keep.append((folder, ref))
1041
+ # Sync
1042
+ subprocess.run(
1043
+ ["ragtime", "sync", "--quiet", "--auto-prune"],
1044
+ cwd=path,
1045
+ capture_output=True,
1046
+ )
1017
1047
 
1018
- # Report findings
1019
- if to_prune:
1020
- click.echo("Will prune:")
1021
- for folder, reason in to_prune:
1022
- click.echo(f" ✗ {folder.name} ({reason})")
1048
+ print(f"[{datetime.now().isoformat()}] Sync complete")
1023
1049
 
1024
- if to_keep:
1025
- click.echo("\nKeeping (branch still active):")
1026
- for folder, ref in to_keep:
1027
- click.echo(f" ✓ {folder.name}")
1050
+ except Exception as e:
1051
+ print(f"[{datetime.now().isoformat()}] Error: {e}")
1028
1052
 
1029
- if not to_prune:
1030
- click.echo("\nNothing to prune.")
1053
+ time.sleep(interval_seconds)
1054
+
1055
+
1056
+ @daemon.command("stop")
1057
+ @click.option("--path", type=click.Path(exists=True, path_type=Path), default=".")
1058
+ def daemon_stop(path: Path):
1059
+ """Stop the sync daemon."""
1060
+ path = Path(path).resolve()
1061
+ pid_file = get_pid_file(path)
1062
+
1063
+ if not pid_file.exists():
1064
+ click.echo("Daemon not running.")
1031
1065
  return
1032
1066
 
1033
- # Actually prune if not dry-run
1034
- if dry_run:
1035
- click.echo(f"\n--dry-run: Would prune {len(to_prune)} folders")
1036
- else:
1037
- click.echo("")
1038
- db = get_db(path)
1039
-
1040
- for folder, reason in to_prune:
1041
- # Remove from index
1042
- branch_slug = folder.name.replace("(unmerged)", "")
1043
- # Find and delete indexed memories for this folder
1044
- results = db.collection.get(
1045
- where={"namespace": f"branch-{folder.name}"}
1046
- )
1047
- if results["ids"]:
1048
- db.delete(results["ids"])
1067
+ pid = int(pid_file.read_text().strip())
1049
1068
 
1050
- # Remove folder
1051
- shutil.rmtree(folder)
1052
- click.echo(f" Pruned: {folder.name}")
1069
+ try:
1070
+ os.kill(pid, signal.SIGTERM)
1071
+ pid_file.unlink()
1072
+ click.echo(f"✓ Daemon stopped (PID: {pid})")
1073
+ except OSError:
1074
+ pid_file.unlink()
1075
+ click.echo("Daemon was not running (stale PID file removed).")
1053
1076
 
1054
- click.echo(f"\n✓ Pruned {len(to_prune)} folders")
1077
+
1078
+ @daemon.command("status")
1079
+ @click.option("--path", type=click.Path(exists=True, path_type=Path), default=".")
1080
+ def daemon_status(path: Path):
1081
+ """Check daemon status."""
1082
+ path = Path(path).resolve()
1083
+ pid_file = get_pid_file(path)
1084
+ log_file = get_log_file(path)
1085
+
1086
+ if not pid_file.exists():
1087
+ click.echo("Daemon: not running")
1088
+ return
1089
+
1090
+ pid = int(pid_file.read_text().strip())
1091
+
1092
+ try:
1093
+ os.kill(pid, 0)
1094
+ click.echo(f"Daemon: running (PID: {pid})")
1095
+
1096
+ # Show last few log lines
1097
+ if log_file.exists():
1098
+ lines = log_file.read_text().strip().split("\n")
1099
+ if lines:
1100
+ click.echo(f"\nRecent log:")
1101
+ for line in lines[-5:]:
1102
+ click.echo(f" {line}")
1103
+ except OSError:
1104
+ click.echo("Daemon: not running (stale PID file)")
1105
+ pid_file.unlink()
1106
+
1107
+
1108
+ @main.command()
1109
+ @click.option("--check", is_flag=True, help="Only check for updates, don't install")
1110
+ def update(check: bool):
1111
+ """Check for and install ragtime updates."""
1112
+ import json
1113
+ from urllib.request import urlopen
1114
+ from urllib.error import URLError
1115
+
1116
+ current = "0.2.1"
1117
+
1118
+ click.echo(f"Current version: {current}")
1119
+ click.echo("Checking PyPI for updates...")
1120
+
1121
+ try:
1122
+ with urlopen("https://pypi.org/pypi/ragtime-cli/json", timeout=10) as resp:
1123
+ data = json.loads(resp.read().decode())
1124
+ latest = data["info"]["version"]
1125
+ except (URLError, json.JSONDecodeError, KeyError) as e:
1126
+ click.echo(f"✗ Could not check for updates: {e}", err=True)
1127
+ return
1128
+
1129
+ # Compare versions
1130
+ def parse_version(v):
1131
+ return tuple(int(x) for x in v.split("."))
1132
+
1133
+ current_v = parse_version(current)
1134
+ latest_v = parse_version(latest)
1135
+
1136
+ if latest_v > current_v:
1137
+ click.echo(f"✓ New version available: {latest}")
1138
+
1139
+ if check:
1140
+ click.echo(f"\nUpdate with: pip install --upgrade ragtime-cli")
1141
+ return
1142
+
1143
+ if click.confirm(f"\nInstall {latest}?", default=True):
1144
+ result = subprocess.run(
1145
+ [sys.executable, "-m", "pip", "install", "--upgrade", "ragtime-cli"],
1146
+ capture_output=False,
1147
+ )
1148
+ if result.returncode == 0:
1149
+ click.echo(f"\n✓ Updated to {latest}")
1150
+ click.echo(" Restart your shell to use the new version")
1151
+ else:
1152
+ click.echo(f"\n✗ Update failed", err=True)
1153
+ elif latest_v < current_v:
1154
+ click.echo(f"✓ You're ahead of PyPI ({current} > {latest})")
1155
+ else:
1156
+ click.echo(f"✓ You're on the latest version ({current})")
1055
1157
 
1056
1158
 
1057
1159
  if __name__ == "__main__":
@@ -12,12 +12,13 @@ import yaml
12
12
  @dataclass
13
13
  class DocsConfig:
14
14
  """Configuration for docs indexing."""
15
- paths: list[str] = field(default_factory=lambda: ["docs", ".claude/memory"])
15
+ paths: list[str] = field(default_factory=lambda: ["docs", ".ragtime"])
16
16
  patterns: list[str] = field(default_factory=lambda: ["**/*.md"])
17
17
  exclude: list[str] = field(default_factory=lambda: [
18
18
  "**/node_modules/**",
19
19
  "**/.git/**",
20
- "**/.ragtime/**",
20
+ "**/.ragtime/index/**",
21
+ "**/.ragtime/branches/.*", # Exclude synced (dot-prefixed) branches
21
22
  ])
22
23
 
23
24
 
@@ -1,7 +1,7 @@
1
1
  """
2
2
  Memory storage for ragtime.
3
3
 
4
- Handles structured memory storage in .claude/memory/ directory.
4
+ Handles structured memory storage in .ragtime/ directory.
5
5
  Each memory is a markdown file with YAML frontmatter.
6
6
  """
7
7
 
@@ -153,7 +153,7 @@ class MemoryStore:
153
153
  db: RagtimeDB instance for vector search
154
154
  """
155
155
  self.project_path = project_path
156
- self.memory_dir = project_path / ".claude" / "memory"
156
+ self.memory_dir = project_path / ".ragtime"
157
157
  self.db = db
158
158
 
159
159
  def save(self, memory: Memory) -> Path:
@@ -348,7 +348,7 @@ class MemoryStore:
348
348
  """
349
349
  Reindex all memory files.
350
350
 
351
- Scans .claude/memory/ and indexes any files not in ChromaDB.
351
+ Scans .ragtime/ and indexes any files not in ChromaDB.
352
352
  Returns count of files indexed.
353
353
  """
354
354
  if not self.memory_dir.exists():
File without changes
File without changes
File without changes
File without changes
File without changes