Skip to content
Open
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
87 changes: 86 additions & 1 deletion hermes_cli/kanban_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -7468,7 +7468,14 @@ def resolve_workspace(task: Task, *, board: Optional[str] = None) -> Path:
- ``scratch``: a fresh dir under ``<board-root>/workspaces/<id>/``,
where ``<board-root>`` is the active board's root. The path is the
same for the dispatcher and every profile worker, so handoff is
path-stable.
path-stable. If the task body signals a code-fix workflow (mentions
``src/``, ``tests/``, ``docs/audits/``, or a known project repo by
basename like ``Chiron``/``Jakobsson_Deluxe``), auto-promote the
workspace to a linked worktree on the matching registered project.
This is the dispatcher-side complement to ``kanban-github-sync``'s
``--project`` auto-detect and catches tasks created by older code
paths (legacy imports, manual dashboard creates) that lack the
project binding.
- ``dir:<path>``: the path stored in ``workspace_path``. Created
if missing. MUST be absolute — relative paths are rejected to
prevent confused-deputy traversal where ``../../../tmp/attacker``
Expand All @@ -7490,6 +7497,22 @@ def resolve_workspace(task: Task, *, board: Optional[str] = None) -> Path:
"""
kind = task.workspace_kind or "scratch"
if kind == "scratch":
# Dispatcher-side auto-promotion: catch tasks imported before the
# kanban-github-sync --project fix was deployed, or created via
# legacy manual paths that didn't bind a project. The body signals
# "this task needs source files" via a couple of well-known
# patterns; if any match and a registered project shares a
# basename (or substring) with one of those signals, promote to
# worktree on that project's primary_path.
promoted_repo = _maybe_promote_scratch_to_worktree(task, board=board)
if promoted_repo is not None:
# Mutate the in-memory copy so the rest of this dispatch
# pass sees workspace_kind='worktree' (avoids second
# resolution branch downstream).
task.workspace_kind = "worktree"
task.workspace_path = str(promoted_repo)
p, _branch = _resolve_worktree_workspace(task, board=board)
return p
if task.workspace_path:
# Legacy scratch tasks that were set to an explicit path get the
# same absolute-path guard as dir: — consistent with the
Expand Down Expand Up @@ -7524,6 +7547,68 @@ def resolve_workspace(task: Task, *, board: Optional[str] = None) -> Path:
raise ValueError(f"unknown workspace_kind: {kind}")


# Markers that strongly suggest "this task needs to land in a source tree".
# Anything matching one of these tokens in the task body triggers
# auto-promotion to a worktree on the matching registered project. Kept
# intentionally narrow — false positives waste a worktree; false negatives
# are recoverable (the worker can manually cd).
_CODE_TASK_MARKERS = (
"src/agents/",
"src/plan/",
"src/integrations/",
"src/qa/",
"tests/test_",
"docs/audits/",
"pytest ",
"ruff ",
"git worktree",
)


def _maybe_promote_scratch_to_worktree(
task: Task, *, board: Optional[str] = None
) -> Optional[Path]:
"""Return a registered project's primary_path if the task body
signals a code-fix workflow and a registered project matches. None
otherwise. Does NOT mutate the task; the caller does that based on
the return value.

The match is a substring search on the project basename (e.g.
'Chiron' matches body text containing 'Chiron'). This is loose but
safe: a false match wastes a worktree under a real repo, not under
an attacker's chosen path. The body never names an absolute path we
would trust, and ``_resolve_worktree_workspace`` still anchors on
the registered project's primary_path.
"""
body = (task.body or "") + " " + (task.title or "")
if not any(marker in body for marker in _CODE_TASK_MARKERS):
return None
from hermes_cli import projects_db as _pdb
try:
with _pdb.connect_closing() as conn:
rows = conn.execute(
"SELECT slug, name, primary_path FROM projects WHERE archived = 0"
).fetchall()
except Exception:
return None
best: Optional[Path] = None
best_score = 0
for row in rows:
slug = row["slug"] or ""
name = row["name"] or ""
primary = row["primary_path"]
if not primary:
continue
score = 0
for needle in (slug, name, Path(primary).name):
if needle and needle in body:
score += len(needle)
if score > best_score:
best_score = score
best = Path(primary)
return best


def set_workspace_path(
conn: sqlite3.Connection, task_id: str, path: Path | str
) -> None:
Expand Down
188 changes: 188 additions & 0 deletions tests/hermes_cli/test_scratch_to_worktree_promotion.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
"""Tests for the scratch→worktree auto-promotion helper.

When ``import_github_issues.py`` (or any other importer) creates a Kanban
task without ``--project``, the task lands with ``workspace_kind='scratch'``.
The dispatcher-side helper ``_maybe_promote_scratch_to_worktree`` catches
that case at resolve time: if the task body references source code
patterns AND a registered project matches, the workspace is promoted to
a linked worktree on the matching project's primary_path.

These tests pin both the matching logic and the no-match path.
"""
from __future__ import annotations

from dataclasses import dataclass, field
from pathlib import Path

import pytest

from hermes_cli import kanban_db as kb


@dataclass
class FakeTask:
"""Minimal stand-in for ``hermes_cli.kanban_db.Task``.

The helper only reads ``title``, ``body`` and ``workspace_kind``,
so a slim dataclass is enough — no need to drag in the full Task
constructor or its DB row decoder.
"""

title: str = ""
body: str = ""
workspace_kind: str = "scratch"
workspace_path: str | None = None
branch_name: str | None = None
id: str = "t_fake"


@pytest.fixture
def chiron_project(tmp_path, monkeypatch):
"""Register a fake 'Chiron' project pointing at a tmp git repo.

The helper looks up projects by their primary_path via projects_db;
we override that lookup to a controlled table instead of touching
the user's real projects.db.
"""
fake_repo = tmp_path / "Chiron"
fake_repo.mkdir()
# .git dir so _git_toplevel returns something — not strictly needed
# for the helper, but keeps the test path realistic.
(fake_repo / ".git").mkdir()

# Patch projects_db.connect_closing to return our fake row.
from hermes_cli import projects_db as _pdb

@dataclass
class FakeRow:
slug: str
name: str
primary_path: str

def __getitem__(self, key: str):
return getattr(self, key)

rows = [FakeRow(slug="chiron", name="Chiron", primary_path=str(fake_repo))]

class FakeConn:
def execute(self, *_args, **_kwargs):
class _Cur:
def fetchall(_self):
return rows

return _Cur()

def close(self):
pass

@dataclass
class FakeCM:
rows: list

def __enter__(self):
return FakeConn()

def __exit__(self, *args):
return False

monkeypatch.setattr(_pdb, "connect_closing", lambda: FakeCM(rows=rows))
return fake_repo


class TestScratchToWorktreePromotion:
def test_promotes_when_body_re_ferences_src_agents(self, chiron_project):
"""Issue body with src/agents/ + project basename → promote."""
task = FakeTask(
title="#339 — Pattern 42 fix",
body="Fix the gate logic in src/agents/architect_agent.py for Chiron",
)
result = kb._maybe_promote_scratch_to_worktree(task)
assert result == chiron_project, (
f"expected promotion to {chiron_project}, got {result}"
)

def test_promotes_when_body_re_ferences_tests(self, chiron_project):
task = FakeTask(
title="add tests",
body="Add a test for the new behaviour in tests/test_x.py (Chiron)",
)
assert kb._maybe_promote_scratch_to_worktree(task) == chiron_project

def test_promotes_when_body_re_ferences_pytest(self, chiron_project):
task = FakeTask(
title="run tests",
body="pytest tests/ should pass after the fix — Chiron vårdcentral",
)
assert kb._maybe_promote_scratch_to_worktree(task) == chiron_project

def test_no_promotion_for_non_code_task(self, chiron_project):
"""Tasks without source-code markers must NOT trigger promotion."""
task = FakeTask(
title="follow up on email",
body="Send the spec to the team and schedule a meeting.",
)
assert kb._maybe_promote_scratch_to_worktree(task) is None

def test_no_promotion_when_project_does_not_match(self, tmp_path, monkeypatch):
"""Body has code markers but no registered project basename matches."""
unrelated_repo = tmp_path / "SomeOther"
unrelated_repo.mkdir()

from hermes_cli import projects_db as _pdb

@dataclass
class FakeRow:
slug: str
name: str
primary_path: str

def __getitem__(self, key: str):
return getattr(self, key)

rows = [FakeRow(slug="other", name="Other", primary_path=str(unrelated_repo))]

class FakeConn:
def execute(self, *_a, **_kw):
class _Cur:
def fetchall(_s):
return rows

return _Cur()

def close(self):
pass

@dataclass
class FakeCM:
rows: list

def __enter__(self):
return FakeConn()

def __exit__(self, *a):
return False

monkeypatch.setattr(_pdb, "connect_closing", lambda: FakeCM(rows=rows))

task = FakeTask(
title="#339 — Pattern 42 fix",
body="Fix src/agents/architect_agent.py — this is for Chiron",
)
# Body says "Chiron" but no project with that name registered →
# helper returns None (no false positive onto Other repo).
assert kb._maybe_promote_scratch_to_worktree(task) is None

def test_no_promotion_when_projects_db_lookup_fails(self, monkeypatch):
"""If projects_db is unreachable, helper must fail closed (None)."""
from hermes_cli import projects_db as _pdb

def boom():
raise RuntimeError("simulated DB outage")

monkeypatch.setattr(_pdb, "connect_closing", boom)

task = FakeTask(
title="fix",
body="Fix src/agents/foo.py",
)
assert kb._maybe_promote_scratch_to_worktree(task) is None