Skip to content

Commit 09f1376

Browse files
feat: Phase 6 — Project Setup & Discovery (#2)
* feat: journal table with create trigger Amp-Thread-ID: https://ampcode.com/threads/T-019cf10a-0f05-77aa-9a2f-292ddee96bba Co-authored-by: Amp <amp@ampcode.com> * feat: journal triggers for update and delete Amp-Thread-ID: https://ampcode.com/threads/T-019cf10a-0f05-77aa-9a2f-292ddee96bba Co-authored-by: Amp <amp@ampcode.com> * feat: export journal to JSONL Amp-Thread-ID: https://ampcode.com/threads/T-019cf10a-0f05-77aa-9a2f-292ddee96bba Co-authored-by: Amp <amp@ampcode.com> * feat: rebuild database from journal Amp-Thread-ID: https://ampcode.com/threads/T-019cf10a-0f05-77aa-9a2f-292ddee96bba Co-authored-by: Amp <amp@ampcode.com> * refactor: extract bean SQL constants and bean_values helper Amp-Thread-ID: https://ampcode.com/threads/T-019cf10a-0f05-77aa-9a2f-292ddee96bba Co-authored-by: Amp <amp@ampcode.com> * feat: beans init with project discovery and default AGENTS.md Amp-Thread-ID: https://ampcode.com/threads/T-019cf255-639d-746e-9a46-c88c06cabe75 Co-authored-by: Amp <amp@ampcode.com> * refactor: use class attribute for BeanId prefix Amp-Thread-ID: https://ampcode.com/threads/T-019cf255-639d-746e-9a46-c88c06cabe75 Co-authored-by: Amp <amp@ampcode.com> * docs: strengthen default-args guideline in AGENTS.md Amp-Thread-ID: https://ampcode.com/threads/T-019cf255-639d-746e-9a46-c88c06cabe75 Co-authored-by: Amp <amp@ampcode.com> --------- Co-authored-by: Amp <amp@ampcode.com>
1 parent e133576 commit 09f1376

7 files changed

Lines changed: 220 additions & 12 deletions

File tree

AGENTS.md

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -51,15 +51,24 @@ class BeanStore:
5151

5252
### Default arguments for configurability
5353

54-
Extract magic values as default arguments. This makes functions testable and reusable
55-
without adding configuration infrastructure.
54+
Never reference module-level constants directly inside function bodies. Instead, pass
55+
them as default arguments. This makes functions testable and reusable without adding
56+
configuration infrastructure. For classes, use class attributes instead of reaching for
57+
the global.
5658

5759
```python
58-
def generate_id(prefix="bean-", fn=partial(secrets.token_hex, ID_BYTES)) -> str:
59-
return prefix + fn()
60-
61-
def local_timestamp(dt, fmt="%Y-%m-%d %H:%M") -> str:
62-
return dt.astimezone().strftime(fmt)
60+
# Yes — constant flows through the signature
61+
def find_beans_dir(start=None, dirname=BEANS_DIR) -> Path: ...
62+
63+
# Yes — class owns its configuration
64+
class BeanId(str):
65+
prefix = ID_PREFIX
66+
def __new__(cls, value="", **kwargs):
67+
if not value.startswith(cls.prefix): ...
68+
69+
# No — function reaches for the global directly
70+
def find_beans_dir(start=None) -> Path:
71+
candidate = current / BEANS_DIR # hidden dependency
6372
```
6473

6574
### `**kwargs` over dict for named fields

src/beans/cli.py

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
# Python imports
22
from datetime import UTC, datetime
3+
import importlib.resources
34
import json
5+
from pathlib import Path
46
from typing import Annotated
57

68
# Pip imports
@@ -61,11 +63,52 @@ def error(e: Exception):
6163
raise typer.Exit(code=1) from e
6264

6365

64-
def get_store() -> Store:
65-
db_path = state.get("db") or "beans.db" # TODO: project discovery (Phase 6.2)
66+
BEANS_DIR = ".beans"
67+
DB_NAME = "beans.db"
68+
AGENTS_MD = "AGENTS.md"
69+
70+
71+
def get_store(db_name=DB_NAME) -> Store:
72+
if state.get("db"):
73+
db_path = state["db"]
74+
else:
75+
try:
76+
db_path = str(find_beans_dir() / db_name)
77+
except FileNotFoundError:
78+
db_path = db_name
6679
return Store.from_path(db_path, dry_run=state.get("dry_run", False))
6780

6881

82+
def find_beans_dir(start=None, dirname=BEANS_DIR) -> Path:
83+
"""Walk up from start (default cwd) to find a .beans/ directory."""
84+
current = Path(start) if start else Path.cwd()
85+
while True:
86+
candidate = current / dirname
87+
if candidate.is_dir():
88+
return candidate
89+
parent = current.parent
90+
if parent == current:
91+
raise FileNotFoundError(f"No {dirname} directory found (walked up from {start or Path.cwd()})")
92+
current = parent
93+
94+
95+
@app.command()
96+
def init(dirname=BEANS_DIR, db_name=DB_NAME, agents_md=AGENTS_MD):
97+
"""Initialize a beans project in the current directory."""
98+
beans_dir = Path.cwd() / dirname
99+
beans_dir.mkdir(exist_ok=True)
100+
101+
db_path = beans_dir / db_name
102+
Store.from_path(str(db_path)).close()
103+
104+
agents_file = beans_dir / agents_md
105+
if not agents_file.exists():
106+
template = importlib.resources.files("beans.templates").joinpath(agents_md).read_text()
107+
agents_file.write_text(template)
108+
109+
typer.echo(f"Initialized beans project in {beans_dir}")
110+
111+
69112
@app.command()
70113
def create(
71114
title: str,

src/beans/models.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,10 @@ class BeanNotFoundError(KeyError):
1717

1818

1919
class BeanId(str):
20+
prefix = ID_PREFIX
21+
2022
def __new__(cls, value="", **kwargs):
21-
if not value.startswith(ID_PREFIX):
23+
if not value.startswith(cls.prefix):
2224
raise ValueError(f"Invalid bean id: {value}")
2325
return super().__new__(cls, value)
2426

@@ -30,7 +32,7 @@ def __get_pydantic_core_schema__(cls, source_type, handler):
3032

3133
@classmethod
3234
def __get_pydantic_json_schema__(cls, schema, handler):
33-
return {"type": "string", "pattern": f"^{ID_PREFIX}[0-9a-f]{{8}}$"}
35+
return {"type": "string", "pattern": f"^{cls.prefix}[0-9a-f]{{8}}$"}
3436

3537
@classmethod
3638
def generate(cls, prefix=ID_PREFIX, fn=partial(secrets.token_hex, ID_BYTES)) -> BeanId:

src/beans/templates/AGENTS.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# Beans — Agent Integration
2+
3+
This project uses [beans](https://github.com/henriquebastos/beans) for issue tracking.
4+
5+
## Workflow
6+
7+
Before starting work, check for available tasks:
8+
9+
```bash
10+
beans ready
11+
```
12+
13+
Claim a task before working on it:
14+
15+
```bash
16+
beans claim <bean-id> --actor <your-name>
17+
```
18+
19+
When done, close the task:
20+
21+
```bash
22+
beans close <bean-id>
23+
```
24+
25+
## Commands Reference
26+
27+
| Command | Description |
28+
|---------|-------------|
29+
| `beans list` | List all beans |
30+
| `beans ready` | List unblocked beans |
31+
| `beans show <id>` | Show bean details |
32+
| `beans create <title>` | Create a new bean |
33+
| `beans update <id>` | Update bean fields |
34+
| `beans close <id>` | Close a bean |
35+
| `beans claim <id>` | Claim a bean |
36+
| `beans release <id>` | Release a bean |
37+
| `beans dep add <from> <to>` | Add dependency |
38+
| `beans dep remove <from> <to>` | Remove dependency |

src/beans/templates/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+

tests/test_init.py

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
# Python imports
2+
import json
3+
4+
# Pip imports
5+
import pytest
6+
from typer.testing import CliRunner
7+
8+
# Internal imports
9+
from beans.cli import app, find_beans_dir
10+
11+
runner = CliRunner()
12+
13+
14+
@pytest.fixture()
15+
def project_dir(tmp_path, monkeypatch):
16+
d = tmp_path / "myproject"
17+
d.mkdir()
18+
monkeypatch.chdir(d)
19+
return d
20+
21+
22+
@pytest.fixture()
23+
def invoke(project_dir):
24+
def invoke(*args):
25+
result = runner.invoke(app, ["--json", *args])
26+
data = json.loads(result.output) if result.output.strip() else None
27+
return result.exit_code, data
28+
29+
return invoke
30+
31+
32+
def init_project():
33+
return runner.invoke(app, ["init"])
34+
35+
36+
class TestInitCommand:
37+
"""'beans init' creates .beans/ directory with db and AGENTS.md."""
38+
39+
def test_init_creates_beans_dir(self, project_dir):
40+
result = init_project()
41+
42+
assert result.exit_code == 0
43+
assert (project_dir / ".beans").is_dir()
44+
45+
def test_init_creates_db(self, project_dir):
46+
init_project()
47+
48+
assert (project_dir / ".beans" / "beans.db").exists()
49+
50+
def test_init_creates_agents_md(self, project_dir):
51+
init_project()
52+
53+
agents_file = project_dir / ".beans" / "AGENTS.md"
54+
assert agents_file.exists()
55+
assert "beans" in agents_file.read_text().lower()
56+
57+
def test_init_idempotent(self, project_dir):
58+
init_project()
59+
result = init_project()
60+
61+
assert result.exit_code == 0
62+
63+
def test_init_does_not_overwrite_existing_agents_md(self, project_dir):
64+
beans_dir = project_dir / ".beans"
65+
beans_dir.mkdir()
66+
agents_file = beans_dir / "AGENTS.md"
67+
agents_file.write_text("custom content")
68+
69+
init_project()
70+
71+
assert agents_file.read_text() == "custom content"
72+
73+
74+
class TestFindBeansDir:
75+
"""find_beans_dir() walks up from cwd to find .beans/."""
76+
77+
def test_finds_beans_dir_in_cwd(self, project_dir):
78+
(project_dir / ".beans").mkdir()
79+
assert find_beans_dir(project_dir) == project_dir / ".beans"
80+
81+
def test_finds_beans_dir_in_parent(self, project_dir):
82+
(project_dir / ".beans").mkdir()
83+
subdir = project_dir / "src" / "app"
84+
subdir.mkdir(parents=True)
85+
assert find_beans_dir(subdir) == project_dir / ".beans"
86+
87+
def test_raises_when_not_found(self, tmp_path, monkeypatch):
88+
empty = tmp_path / "empty"
89+
empty.mkdir()
90+
monkeypatch.chdir(empty)
91+
with pytest.raises(FileNotFoundError, match=r"\.beans"):
92+
find_beans_dir(empty)
93+
94+
95+
class TestProjectDiscovery:
96+
"""CLI commands use project discovery to find .beans/beans.db."""
97+
98+
def test_create_uses_discovered_db(self, project_dir, invoke):
99+
init_project()
100+
101+
exit_code, _ = invoke("create", "Fix auth")
102+
assert exit_code == 0
103+
104+
_, data = invoke("list")
105+
assert len(data) == 1
106+
assert data[0]["title"] == "Fix auth"
107+
108+
def test_db_flag_overrides_discovery(self, project_dir, invoke):
109+
init_project()
110+
111+
db_path = str(project_dir / "custom.db")
112+
runner.invoke(app, ["--db", db_path, "--json", "create", "Custom"])
113+
114+
_, data = invoke("list")
115+
assert len(data) == 0

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)