Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ docs/ops/loop-runner.HALT
docs/ops/loop-runner-logs/
docs/ops/all-nighter.log
docs/ops/loop-runner.force-retry.json
docs/ops/worker-sessions.db*
docs/ops/critic-*.log*
# Per-operator overrides + multi-repo dir + worker-planted settings
forge-loop.local.yaml
.forge/*
Expand All @@ -31,3 +33,8 @@ forge-loop.local.yaml
!.forge/testing-manifesto.md
.claude/settings.json
.claude/settings.local.json
# forge-loop runtime
loop-runner.pid
loop-runner.pause
loop-runner.stop
loop-runner-logs/
89 changes: 88 additions & 1 deletion src/forge_loop/init.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@
import os
from pathlib import Path

from forge_loop.eventlog import SqliteEventLog
from forge_loop.frontier import FrontierCursor, FrontierStore
from forge_loop.memory import SqliteMemoryStore
from forge_loop.worker_sessions import WorkerSessionStore

SAMPLE_YAML = """# forge-loop config — tune the loop for THIS project.
# All keys are optional; env vars (LOOP_*) override yaml values.

Expand Down Expand Up @@ -126,7 +131,24 @@ def init_project(
created.append(str(sample_md.relative_to(target_dir)))

gitignore = target_dir / ".gitignore"
snippet = "\n# forge-loop runtime\nloop-runner.pid\nloop-runner.pause\nloop-runner.stop\nloop-runner-logs/\n"
snippet = """
# forge-loop runtime
loop-runner.pid
loop-runner.pause
loop-runner.stop
loop-runner-logs/
docs/ops/loop-runner.json
docs/ops/loop-runner-events.jsonl
docs/ops/loop-runner-summaries.jsonl
docs/ops/loop-runner.pid
docs/ops/loop-runner.pause
docs/ops/loop-runner.stop
docs/ops/loop-runner.HALT
docs/ops/loop-runner-logs/
docs/ops/loop-runner.force-retry.json
docs/ops/worker-sessions.db*
docs/ops/critic-*.log*
"""
if gitignore.exists():
content = gitignore.read_text()
if "forge-loop runtime" not in content:
Expand All @@ -137,9 +159,74 @@ def init_project(
# Caller can wire .gitignore separately if needed.
pass

_ensure_control_plane_stores(target_dir, created=created, skipped=skipped, force=force)

return {"created": created, "skipped": skipped}


def _ensure_control_plane_stores(
target_dir: Path,
*,
created: list[str],
skipped: list[str],
force: bool,
) -> None:
forge_dir = target_dir / ".forge"
ops_dir = target_dir / "docs" / "ops"
forge_dir.mkdir(parents=True, exist_ok=True)
ops_dir.mkdir(parents=True, exist_ok=True)

event_log_path = forge_dir / "events.db"
_record_path(event_log_path, target_dir, created=created, skipped=skipped, force=force)
SqliteEventLog(event_log_path)

frontier_path = forge_dir / "frontier.yaml"
if frontier_path.exists() and not force:
skipped.append(str(frontier_path.relative_to(target_dir)))
else:
FrontierStore(frontier_path).save(_default_frontier_cursor())
created.append(str(frontier_path.relative_to(target_dir)))

memory_path = forge_dir / "memory.db"
_record_path(memory_path, target_dir, created=created, skipped=skipped, force=force)
SqliteMemoryStore(memory_path)

sessions_path = ops_dir / "worker-sessions.db"
_record_path(sessions_path, target_dir, created=created, skipped=skipped, force=force)
WorkerSessionStore(sessions_path).close()


def _record_path(
path: Path,
target_dir: Path,
*,
created: list[str],
skipped: list[str],
force: bool,
) -> None:
relative = str(path.relative_to(target_dir))
if path.exists() and not force:
skipped.append(relative)
else:
created.append(relative)


def _default_frontier_cursor() -> FrontierCursor:
return FrontierCursor(
product_goal="Make this repository resumable for long-running agent work.",
current_problem="Control-plane stores have just been initialized.",
next_expansion="Run a bounded milestone and promote durable events, memory, tasks, and frontier facts as real work happens.",
why_now="A reset should boot from explicit durable state instead of missing files.",
active_decisions=(
"Context windows are working memory, not durable project state.",
"Workers are disposable; control-plane state must be external and replayable.",
),
hot_files=(),
hot_tests=(),
open_questions=("Which frontier axis should the next milestone advance?",),
)


def detect_github_repo(target_dir: Path) -> str:
"""Best-effort: derive `owner/repo` from git remote origin."""
import subprocess
Expand Down
38 changes: 37 additions & 1 deletion tests/test_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@

from __future__ import annotations

from datetime import UTC, datetime
from pathlib import Path

from forge_loop.control.status import collect_control_plane_status
from forge_loop.init import init_project


Expand All @@ -19,6 +21,37 @@ def test_init_creates_yaml_and_manual_stub(tmp_path: Path) -> None:
assert (tmp_path / "manual" / "project-quickref.md").exists()


def test_init_creates_empty_durable_control_plane_stores(tmp_path: Path) -> None:
result = init_project(tmp_path, github_repo="example/foo")

assert ".forge/events.db" in result["created"]
assert ".forge/frontier.yaml" in result["created"]
assert ".forge/memory.db" in result["created"]
assert "docs/ops/worker-sessions.db" in result["created"]

status = collect_control_plane_status(
tmp_path,
datetime(2026, 6, 3, tzinfo=UTC),
)

assert status["event_log"]["available"] is True
assert status["event_log"]["last_sequence"] == 0
assert status["frontier"]["available"] is True
assert status["memory"] == {
"available": True,
"path": str(tmp_path / ".forge" / "memory.db"),
"active_count": 0,
"rejected_count": 0,
}
assert status["tasks"] == {
"available": True,
"path": str(tmp_path / "docs" / "ops" / "worker-sessions.db"),
"in_flight_count": 0,
"stale_lease_count": 0,
}
assert status["boot"]["available"] is True


def test_init_idempotent_skips_existing_without_force(tmp_path: Path) -> None:
init_project(tmp_path, github_repo="a/b")
result = init_project(tmp_path, github_repo="c/d")
Expand Down Expand Up @@ -48,7 +81,8 @@ def test_init_scaffold_does_not_leak_project_specific_deploy_task(tmp_path: Path
# Scan only the active `task:` assignment line — comments may legitimately
# mention task names as examples.
task_lines = [
line.strip() for line in body.splitlines()
line.strip()
for line in body.splitlines()
if line.strip().startswith("task:") and not line.lstrip().startswith("#")
]
assert len(task_lines) == 1, f"expected one `task:` line, got {task_lines}"
Expand All @@ -68,6 +102,8 @@ def test_init_appends_to_existing_gitignore(tmp_path: Path) -> None:
content = (tmp_path / ".gitignore").read_text()
assert "# existing" in content
assert "loop-runner.pid" in content
assert "docs/ops/worker-sessions.db*" in content
assert "docs/ops/critic-*.log*" in content


def test_init_skips_gitignore_when_missing(tmp_path: Path) -> None:
Expand Down
Loading