Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
84720d6
Preserve issue discussions by resuming the original OMX session
vkehfdl1 Mar 22, 2026
5f6f832
Make resume-session changes pass repository formatting rules
vkehfdl1 Mar 22, 2026
3926ea7
Merge pull request #5 from NomaDamas/Feature/#1
vkehfdl1 Mar 22, 2026
1f62f91
Prevent duplicate review rounds from advancing the PR chain
vkehfdl1 Mar 22, 2026
ac1566d
Close review race in round dedupe and align final verdict verification
vkehfdl1 Mar 22, 2026
8270bf4
Merge pull request #6 from NomaDamas/Feature/#3
vkehfdl1 Mar 22, 2026
da95d86
Prevent orphaned tmux sessions after OMX jobs finish
vkehfdl1 Mar 22, 2026
578ec2f
Merge pull request #7 from NomaDamas/Feature/#4
vkehfdl1 Mar 22, 2026
de4432d
Clarify issue and verdict prompt evidence requirements
vkehfdl1 Mar 22, 2026
4e5cd51
Compress prompt requirements into checklist format
vkehfdl1 Mar 22, 2026
d58c4cf
Route OMX prompt actions through gh instead of PyGithub helper
vkehfdl1 Mar 22, 2026
da18d88
Refine PR update and review evidence prompt guidance
vkehfdl1 Mar 22, 2026
71a7566
Merge pull request #9 from NomaDamas/Feature/#2
vkehfdl1 Mar 22, 2026
49a8114
Switch Dani runtime to omx exec
vkehfdl1 Mar 22, 2026
0830ab4
Use global ~/.dani data dir by default
vkehfdl1 Mar 22, 2026
18f280d
Route PR review findings through signed implementation follow-ups
vkehfdl1 Mar 25, 2026
d0c680c
Automate main-to-dev synchronization without manual triage
vkehfdl1 Mar 25, 2026
55ec907
Preserve automated dev syncs when CI lacks git identity
vkehfdl1 Mar 26, 2026
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@ cython_debug/
# Ruff stuff:
.ruff_cache/
.omx/
.dani/

# PyPI configuration file
.pypirc
Expand Down
19 changes: 8 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ Simple GitHub webhook -> OMX automation loop.
- FastAPI webhook server
- Registered repos only
- Repo-serial / cross-repo parallel job handling
- `omx --madmax` tmux launches
- non-interactive `omx exec` / `omx exec resume` launches
- Separate prompt templates in `dani/prompts.py`
- Workflows for:
- issue request report
Expand All @@ -19,12 +19,14 @@ Simple GitHub webhook -> OMX automation loop.
Required local tools:
- `git`
- `omx`
- `tmux`

Required environment variables:
- `DANI_WEBHOOK_SECRET`
- `DANI_GITHUB_TOKEN` (preferred) or `GITHUB_TOKEN` / `GH_TOKEN` / `GITHUB_PAT`

## Codex/OMX trust prerequisite
Before dani can reliably launch or resume OMX/Codex sessions for a repository, that repository directory should be trusted by Codex at least once. In practice, run `omx exec 'hello'` or `codex exec 'hello'` once from the target repo and accept the trust prompt before using dani automation there. Otherwise a trust prompt can block session startup or resume.

## CLI
```bash
dani register-repo owner/name /absolute/path/to/repo
Expand All @@ -34,19 +36,14 @@ dani show-state
```

## Persistence
State is stored under `.dani/` by default:
State is stored under `~/.dani/` by default:
- `registry.json`
- `jobs.json`
- `sessions.json`
- `events.jsonl`
- `runs/` for generated OMX prompt/script artifacts


## GitHub helper for agents
Agents should use the bundled PyGithub helper instead of `gh` subprocess calls:

```bash
python /absolute/path/to/dani/github_helper.py issue-comment --repo owner/name --issue 123 --body-file comment.md
python /absolute/path/to/dani/github_helper.py pr-comment --repo owner/name --pr 456 --body-file review.md
python /absolute/path/to/dani/github_helper.py ensure-pr --repo owner/name --head feature/#123 --base dev --title "Feature/#123" --body-file pr-body.md
```
## GitHub surfaces
- OMX sessions should use `gh` for issue comments, PR comments, and PR creation/update.
- `dani/github.py` and `dani/github_helper.py` remain PyGithub-backed internal surfaces for dani runtime logic.
69 changes: 0 additions & 69 deletions SPEC.md

This file was deleted.

2 changes: 1 addition & 1 deletion dani/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from dani.service import DaniService

app = typer.Typer(help="Simple GitHub webhook -> OMX automation loop.")
DEFAULT_DATA_DIR = Path(".dani")
DEFAULT_DATA_DIR = Path.home() / ".dani"
DATA_DIR_OPTION = typer.Option(DEFAULT_DATA_DIR, help="Directory for dani state files.")
HOST_OPTION = typer.Option("127.0.0.1", help="Bind host.")
PORT_OPTION = typer.Option(8787, help="Bind port.")
Expand Down
182 changes: 182 additions & 0 deletions dani/git_sync.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
from __future__ import annotations

import os
import subprocess
from dataclasses import dataclass
from pathlib import Path
from typing import NoReturn

from dani.models import JobRecord, RepoConfig


@dataclass(slots=True)
class DevSyncContext:
repo_path: Path
worktree_path: Path
source_branch: str
target_branch: str
source_sha: str
temp_branch: str


@dataclass(slots=True)
class DevSyncOutcome:
status: str


class DevSyncConflictError(RuntimeError):
def __init__(self, context: DevSyncContext) -> None:
super().__init__("dev-sync-conflict")
self.context = context


class GitDevSyncer:
def __init__(self, run_dir: Path) -> None:
self.run_dir = run_dir
self.run_dir.mkdir(parents=True, exist_ok=True)

def sync(self, repo: RepoConfig, job: JobRecord) -> DevSyncOutcome:
source_sha = self._source_sha_for(job)
repo_path = Path(repo.local_path)
self._run_git(repo_path, "fetch", "origin", repo.main_branch, repo.dev_branch)
if self._is_ancestor(repo_path, source_sha, f"origin/{repo.dev_branch}"):
return DevSyncOutcome(status="already_up_to_date")

context = self._prepare_context(repo, job, source_sha)
try:
merge_result = self._run_git(
context.worktree_path,
"merge",
"--no-ff",
"--no-commit",
source_sha,
check=False,
env=self._automation_git_env(),
)
if merge_result.returncode == 0:
if self._has_pending_merge(context.worktree_path):
self._commit_merge(context, self.build_commit_message(repo, job))
self._push(context)
self.verify_remote_sync(context)
self.cleanup(context)
return DevSyncOutcome(status="merged")
self.verify_remote_sync(context)
self.cleanup(context)
return DevSyncOutcome(status="already_up_to_date")
if self._has_conflicts(context.worktree_path):
self._raise_conflict(context)
message = merge_result.stderr.strip() or merge_result.stdout.strip() or "git-merge-failed"
self._raise_runtime_error(message)
except DevSyncConflictError:
raise
except Exception:
self.cleanup(context)
raise

def build_commit_message(self, repo: RepoConfig, job: JobRecord) -> str:
source_sha = self._source_sha_for(job)
return "\n".join([
f"Keep {repo.dev_branch} aligned with {repo.main_branch} after upstream updates",
"",
f"Sync {repo.main_branch} commit {source_sha} into {repo.dev_branch} so the",
"development branch stays current with the latest mainline history.",
"",
f"Constraint: {repo.dev_branch} accepts direct pushes from dani automation",
"Constraint: merge commits must follow the Lore commit protocol",
"Rejected: Open a sync PR first | direct pushes are allowed for clean repository sync",
"Confidence: high",
"Scope-risk: narrow",
f"Directive: Resolve conflicts in the dedicated worktree before pushing to {repo.dev_branch}",
f"Tested: git merge {source_sha} into {repo.dev_branch} worktree and pushed when clean",
"Not-tested: Project test suite execution during automated branch synchronization",
])

def verify_remote_sync(self, context: DevSyncContext) -> None:
self._run_git(context.repo_path, "fetch", "origin", context.source_branch, context.target_branch)
self._run_git(context.worktree_path, "diff", "--name-only", "--diff-filter=U")
self._run_git(
context.repo_path, "merge-base", "--is-ancestor", context.source_sha, f"origin/{context.target_branch}"
)

def cleanup(self, context: DevSyncContext) -> None:
self._run_git(context.repo_path, "worktree", "remove", "--force", str(context.worktree_path), check=False)
self._run_git(context.repo_path, "worktree", "prune", check=False)

def _prepare_context(self, repo: RepoConfig, job: JobRecord, source_sha: str) -> DevSyncContext:
repo_path = Path(repo.local_path)
worktree_path = self.run_dir / f"dev-sync-{job.id}"
temp_branch = f"dani/dev-sync/{job.id}"
if worktree_path.exists():
self._run_git(repo_path, "worktree", "remove", "--force", str(worktree_path), check=False)
self._run_git(repo_path, "worktree", "add", "--detach", str(worktree_path), f"origin/{repo.dev_branch}")
self._run_git(worktree_path, "checkout", "-B", temp_branch, f"origin/{repo.dev_branch}")
return DevSyncContext(
repo_path=repo_path,
worktree_path=worktree_path,
source_branch=repo.main_branch,
target_branch=repo.dev_branch,
source_sha=source_sha,
temp_branch=temp_branch,
)

def _commit_merge(self, context: DevSyncContext, commit_message: str) -> None:
commit_message_path = context.worktree_path / ".dani-dev-sync-commit-message.txt"
commit_message_path.write_text(commit_message, encoding="utf-8")
self._run_git(
context.worktree_path,
"commit",
"--file",
str(commit_message_path),
env=self._automation_git_env(),
)

def _push(self, context: DevSyncContext) -> None:
self._run_git(context.worktree_path, "push", "origin", f"HEAD:refs/heads/{context.target_branch}")

def _has_pending_merge(self, repo_path: Path) -> bool:
result = self._run_git(repo_path, "rev-parse", "--verify", "MERGE_HEAD", check=False)
return result.returncode == 0

def _has_conflicts(self, repo_path: Path) -> bool:
result = self._run_git(repo_path, "diff", "--name-only", "--diff-filter=U", check=False)
return bool(result.stdout.strip())

def _is_ancestor(self, repo_path: Path, ancestor: str, descendant: str) -> bool:
result = self._run_git(repo_path, "merge-base", "--is-ancestor", ancestor, descendant, check=False)
return result.returncode == 0

def _source_sha_for(self, job: JobRecord) -> str:
source_sha = job.metadata.get("main_sha")
if isinstance(source_sha, str) and source_sha:
return source_sha
msg = "missing-main-sha"
raise RuntimeError(msg)

def _raise_conflict(self, context: DevSyncContext) -> NoReturn:
raise DevSyncConflictError(context)

def _raise_runtime_error(self, message: str) -> NoReturn:
raise RuntimeError(message)

def _automation_git_env(self) -> dict[str, str]:
return os.environ | {
"GIT_AUTHOR_NAME": "dani",
"GIT_AUTHOR_EMAIL": "dani@example.com",
"GIT_COMMITTER_NAME": "dani",
"GIT_COMMITTER_EMAIL": "dani@example.com",
}

def _run_git(
self,
repo_path: Path,
*args: str,
check: bool = True,
env: dict[str, str] | None = None,
) -> subprocess.CompletedProcess[str]:
return subprocess.run( # noqa: S603
["git", "-C", str(repo_path), *args], # noqa: S607
check=check,
capture_output=True,
text=True,
env=env,
)
8 changes: 8 additions & 0 deletions dani/github.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,14 @@ def latest_signature_comment(
return comment, parsed
return None

def find_comments_by_signature(
self, repo_full_name: str, number: int, *, kind: str, signature_fragment: str
) -> list[dict[str, Any]]:
comments = (
self.issue_comments(repo_full_name, number) if kind == "issue" else self.pr_comments(repo_full_name, number)
)
return [comment for comment in comments if signature_fragment in (comment.get("body") or "")]

def create_issue_comment(self, repo_full_name: str, issue_number: int, body: str) -> dict[str, Any]:
issue = self._repo(repo_full_name).get_issue(issue_number)
return issue.create_comment(body).raw_data
Expand Down
14 changes: 12 additions & 2 deletions dani/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,17 +46,21 @@ def to_dict(self) -> dict[str, Any]:
class SessionRecord:
repo_full_name: str
stage: str
tmux_session: str
runtime_handle: str
prompt_path: str
script_path: str
worktree_path: str
job_id: str
issue_number: int | None = None
pr_number: int | None = None
review_round: int | None = None
omx_session_id: str | None = None
stdout_path: str | None = None
stderr_path: str | None = None
id: str = field(default_factory=lambda: uuid4().hex)
pane_id: str | None = None
status: str = "launched"
ended_at: str | None = None
termination_reason: str | None = None
created_at: str = field(default_factory=utc_now)
updated_at: str = field(default_factory=utc_now)

Expand All @@ -76,6 +80,8 @@ class NormalizedEvent:
title: str | None = None
base_branch: str | None = None
head_branch: str | None = None
ref: str | None = None
commit_sha: str | None = None
is_pull_request: bool = False


Expand Down Expand Up @@ -103,6 +109,10 @@ def sessions_path(self) -> Path:
def events_path(self) -> Path:
return self.data_dir / "events.jsonl"

@property
def processed_events_path(self) -> Path:
return self.data_dir / "processed-events.json"

@property
def run_dir(self) -> Path:
return self.data_dir / "runs"
Loading
Loading