feat(vault): Obsidian vault V1 — projection core, Auditor narrative, input loop (#458)

* fix(gateway): gate review diffs against the task's real parent branch (#444)

The in-path PR-review gate's evidence diff (claim_gate_review) and the
pr_pass conventions guard derived their diff base via parent_branch_for
string surgery, which reuses the child branch's own team segment — wrong
for every cross-team hop (a frontend child of a main_pm root derives a
ref that never existed) and silently falls back to the repo default
branch, so the reviewer judged the entire inherited base-branch content
as the task's own work and failed acceptance criteria the task never
touched. Bounced a live goals-tab fix three times, unfixable by branch
surgery.

The gate now resolves the base via resolve_parent_branch (the parent
task's recorded branch_name, cross-team correct) and threads it as a new
preferred_parent override through git.diff / list_changed_files /
conventions_check_for_task — consulted only when no explicit base is
given, so the pinned literal-base contract (base="HEAD~1") and every
other diff caller (QA, doc, content) are byte-identical. Parent lookup
fails open (derived-base fallback) like the other resolve_parent_branch
call sites, and is skipped entirely while the conventions flag is off.

Also excludes .uv-cache/ and .claude/ (agent worktrees, private uv
cache) from the markdown prose scanner — both are repo-local tool dirs
whose vendored/generated files tripped make reflow-check.

Co-authored-by: Renn F <rennf93@users.noreply.github.com>

* feat(vault): Obsidian vault V1 — projection core + input loop

The vault is a rebuildable projection of the DB (never a source of
truth), default-off behind ROBOCO_OBSIDIAN_VAULT_ENABLED + ROBOCO_VAULT_PATH.

Projection core: VaultWriter materializes tasks/journals/A2A/agents as
wikilinked markdown (id-suffixed stable filenames, alias-based links so
renames never break, is_private journals excluded like the RAG corpus);
event seams materialize on journal write and A2A send and touch task-note
frontmatter at the status-transition chokepoint — all best-effort, a
vault failure never blocks a verb. Shipped .obsidian config (Dataview,
Kanban, team/status graph groups) + _meta dashboards; python -m
roboco.vault rebuild/relocate (rebuild preserves the narrative section).

Auditor narrative duty: curate_vault content verb (auditor-only,
playbook-curation pattern) spawned by a dedicated root-completion hook
with its own cooldown — fully separate from _dispatch_audit_work, whose
scheduled-sweep/alert-producer revival belongs to the queued fleet task.

Input loop: VaultIntakeEngine (ROBOCO_VAULT_INTAKE_*) watches the
intake folder for #roboco-tagged notes and materializes each as ONE held
draft (confirmed_by_human=False, Secretary-owned, source=vault_note,
excluded by the dispatchers via _is_held_ceo_source) with local-model
extraction and a deterministic fallback; vault_seen_notes ledger
(migration 070) keyed on path+content-hash (the CEO-feedback callout is
stripped before hashing so the engine's own append never self-triggers);
per-cycle and open-draft caps. Nothing auto-starts.

* fix(vault): integrate with master — re-chain migration 070 onto 069, mypy-clean tests

The vault branch was cut from slave before the sequence-gate promotion,
so migration 070 chained from 068 while master already carried 069 —
two heads on merge. Master is now merged in and 070 revises 069.
Vault test files also get mypy-clean mock idioms (monkeypatch.setattr
over method assignment; await_args narrowed before access).

* fix(scripts): dedupe SKIP_DIRS again after the master merge

Master still carries the twin-merge duplicate (its dedupe hotfix is an
unmerged PR); the merge re-imported it here.

* fix(vault): reflow hard-wrapped prose in the vault asset templates

* chore(config): exclude .uv-cache and .claude from deptry's scan scope

Same repo-local tool dirs the prose scanner skips; the standalone deptry
target walks the repo root and drowned in the cache's unpacked wheels.

* fix(vault): board-review activation path for vault drafts + relocate graft

The input loop's held-artifact posture was a dead end: vault_note drafts
were unconditionally held by _is_held_ceo_source, owned by the verbless
Secretary, hidden from the panel's approval surfaces by team, and the
open-drafts cap counted them forever — the engine self-bricked after ten
notes. Vault drafts now ride the intake board-review path instead: a
tagged note becomes a PENDING Product-Owner-assigned Board draft (the
exact confirm_live_draft board shape), the board reviews it, and only
the CEO's approve_and_start makes it deliverable — never-auto-starts now
rests on the board gate, proven by tests against the real dispatchers.
The cap counts only drafts still awaiting the CEO (team==BOARD,
non-terminal), so approval and cancellation both free it.

relocate into an existing personal vault now grafts old_root/RoboCo as a
direct child (refusing loudly if RoboCo/ already exists there) and adds
only absent .obsidian/_meta files — a personal vault's config is never
clobbered. An absent destination keeps the whole-tree move.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-07-11 03:17:59 +02:00
committed by GitHub
co-authored by Renn F
parent 340fcebce2
commit 15a3e87a2f
39 changed files with 3155 additions and 14 deletions
+1
View File
@@ -19,5 +19,6 @@
| `approve_playbook` | `approve_playbook(playbook_id: UUID)` |
| `reject_playbook` | `reject_playbook(playbook_id: UUID, reason: str)` |
| `archive_playbook` | `archive_playbook(playbook_id: UUID)` |
| `curate_vault` | `curate_vault(task_id: UUID, narrative: str)` |
| `notify_list` | `notify_list(unread_only: bool = True, pending_ack_only: bool = False, limit: int = 20)` |
| `notify_get` | `notify_get(notification_id: UUID)` |
+1
View File
@@ -251,6 +251,7 @@ real tools live in their agent_sdk drivers, not role_config.
| `approve_playbook` | `approve_playbook(playbook_id: UUID)` |
| `reject_playbook` | `reject_playbook(playbook_id: UUID, reason: str)` |
| `archive_playbook` | `archive_playbook(playbook_id: UUID)` |
| `curate_vault` | `curate_vault(task_id: UUID, narrative: str)` |
| `notify_list` | `notify_list(unread_only: bool = True, pending_ack_only: bool = False, limit: int = 20)` |
| `notify_get` | `notify_get(notification_id: UUID)` |
+5
View File
@@ -29,3 +29,8 @@ You silently observe org activity and log anomalies. You do **not** communicate
## Principle
Observe, don't interfere. The CEO reads your reflect-notes when reviewing org health.
## Vault curation (Obsidian)
When a root task completes, you may be spawned specifically to curate its Obsidian-vault note (feature-flagged, no-op when disabled). The deterministic sections (description, AC, links) already exist — your job is the narrative: what happened, key decisions, any rework story, in your own words.
- `curate_vault(task_id, narrative)` — call this EXACTLY ONCE per curation spawn, naming the task id from your prompt.
- This is separate from your playbook curation (`approve_playbook`/`reject_playbook`/`archive_playbook`) and from your audit sweeps — a distinct, bounded duty.
+43
View File
@@ -0,0 +1,43 @@
"""Add vault_seen_notes table — the vault-intake watcher's dedup ledger.
Keyed by (vault-relative path, content hash) together: an unchanged note
never reprocesses, but an edited note (new hash) is eligible again. Mirrors
``x_seen_mentions``. Additive and inert while ``ROBOCO_VAULT_INTAKE_ENABLED``
is off.
Revision ID: 070_vault_seen_notes
Revises: 069_tasks_parent_task_id_idx
Create Date: 2026-07-11
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = "070_vault_seen_notes"
down_revision = "069_tasks_parent_task_id_idx"
branch_labels: dict[str, str] | None = None
depends_on: dict[str, str] | None = None
def upgrade() -> None:
op.create_table(
"vault_seen_notes",
sa.Column("id", sa.UUID(as_uuid=True), primary_key=True, nullable=False),
sa.Column("note_path", sa.String(length=512), nullable=False),
sa.Column("content_hash", sa.String(length=64), nullable=False),
sa.Column(
"processed_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("now()"),
),
sa.UniqueConstraint(
"note_path", "content_hash", name="uq_vault_seen_notes_path_hash"
),
)
def downgrade() -> None:
op.drop_table("vault_seen_notes")
+5 -1
View File
@@ -202,6 +202,10 @@ select = [
# imports so the prompt-composition utility stays decoupled from the heavy
# service graph (it loads at every agent spawn).
"roboco/agents/factories/_base.py" = ["PLC0415"]
# roboco/vault.py's cheap ensure_vault_assets() is imported at orchestrator
# startup; _rebuild()'s full service graph (Task/Journal/A2A/Agent services)
# stays deferred so that startup path never pays for it.
"roboco/vault.py" = ["PLC0415"]
# Test fixtures that reload modules to test env-var-at-import-time behavior.
# ARG002: ApiClient-subclassing fakes must keep the superclass parameter names
# for mypy's override check, so unused override-stub args can't be renamed.
@@ -380,7 +384,7 @@ ignore = []
# Deptry Configuration
# =============================================================================
[tool.deptry]
exclude = ["tests", ".venv", "vulture_whitelist.py", "alembic"]
exclude = ["tests", ".venv", ".uv-cache", ".claude", "vulture_whitelist.py", "alembic"]
extend_exclude = ["conftest.py", "setup.py"]
known_first_party = ["roboco"]
+19
View File
@@ -15,6 +15,7 @@ from roboco.api.schemas.v1.do import (
ApprovePlaybookRequest,
ArchivePlaybookRequest,
CommitRequest,
CurateVaultRequest,
DmRequest,
DraftPlaybookRequest,
EvidenceRequest,
@@ -460,3 +461,21 @@ async def do_archive_playbook(
agent_id=x_agent_id, playbook_id=body.playbook_id
)
return envelope_to_response(env, request)
@router.post("/curate_vault")
@guard_deco.rate_limit(requests=30, window=60)
@guard_deco.max_request_size(size_bytes=65536)
@guard_deco.custom_validation(secret_exfil_validator)
@guard_deco.content_type_filter(["application/json"])
@guard_deco.behavior_analysis(_RUNAWAY_RULES)
async def do_curate_vault(
request: Request,
body: CurateVaultRequest,
x_agent_id: _AgentIdHeader,
actions: _ContentActionsDep,
) -> dict:
env = await actions.curate_vault(
agent_id=x_agent_id, task_id=body.task_id, narrative=body.narrative
)
return envelope_to_response(env, request)
+7
View File
@@ -278,3 +278,10 @@ class ArchivePlaybookRequest(BaseModel):
"""Auditor archives a playbook (-> archived)."""
playbook_id: UUID
class CurateVaultRequest(BaseModel):
"""Auditor writes a root task-tree's vault narrative section."""
task_id: UUID
narrative: str = Field(..., min_length=1)
+55
View File
@@ -1604,6 +1604,61 @@ class Settings(BaseSettings):
description="Banned single-word commit subjects",
)
# ==========================================================================
# Obsidian vault projection (V1 — projection core) — DEFAULT OFF
# ==========================================================================
# The org's human-readable memory palace: tasks/journals/A2A conversations
# materialize as markdown notes with wikilinks, browsable in Obsidian
# (Dataview/Kanban/graph plugins). Off by default and fully inert — every
# seam (journal write, A2A send, task transition) no-ops when off.
obsidian_vault_enabled: bool = Field(
default=False,
description=(
"Master switch for the Obsidian vault projection. OFF by default; "
"when off no note is ever written and every event seam is a no-op."
),
)
vault_path: str = Field(
default="/data/vault",
description=(
"Root directory the vault materializes into (bind-mounted on the "
"NAS in production). Only consulted when obsidian_vault_enabled."
),
)
# Vault intake — the vexa-inspired input loop (V1 item 4): notes tagged
# #roboco in an opt-in vault folder become HELD intake drafts. Inert
# unless BOTH obsidian_vault_enabled AND vault_intake_enabled are on.
vault_intake_enabled: bool = Field(
default=False,
description=(
"Master switch for the vault intake watcher. OFF by default; when "
"off no note is ever scanned and nothing is drafted."
),
)
vault_intake_interval_seconds: int = Field(
default=300,
ge=30,
description="Seconds between vault-intake scan cycles.",
)
vault_intake_dir: str = Field(
default="RoboCo/Inbox",
description=("Vault-relative folder scanned for tagged notes (non-recursive)."),
)
vault_intake_max_per_cycle: int = Field(
default=3,
ge=1,
description="Max held drafts the intake watcher may originate in one cycle.",
)
vault_intake_max_open_drafts: int = Field(
default=10,
ge=1,
description=(
"Rolling cap on concurrently-open held vault-note drafts; the "
"watcher originates nothing more past it."
),
)
def resolve_uvicorn_loop_factory(
loop: Literal["asyncio", "uvloop"],
+24
View File
@@ -2308,6 +2308,30 @@ class XSeenFeatureTable(Base):
)
class VaultSeenNoteTable(Base):
"""Dedup ledger for the vault-intake watcher — one row per (vault-relative
path, content hash) pair already turned into a held draft. Unique on both
columns together (not just path): an unchanged note never reprocesses, but
an edited note (new hash) is eligible again. Mirrors XSeenMentionTable."""
__tablename__ = "vault_seen_notes"
id: Mapped[UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid4
)
note_path: Mapped[str] = mapped_column(String(512), nullable=False)
content_hash: Mapped[str] = mapped_column(String(64), nullable=False)
processed_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(UTC), nullable=False
)
__table_args__ = (
UniqueConstraint(
"note_path", "content_hash", name="uq_vault_seen_notes_path_hash"
),
)
# =============================================================================
# TIKTOK ACCOUNT TABLES
# =============================================================================
@@ -46,6 +46,8 @@ X_SPOTLIGHT_SKIP_REASON = "x_spotlight_skip_reason"
ROADMAP_CYCLE = "roadmap_cycle"
VIDEO_DRAFT = "video_draft"
VIDEO_REJECT_REASON = "video_reject_reason"
VAULT_CURATION_DISPATCHED = "vault_curation_dispatched"
VAULT_NOTE_REF = "vault_note_ref"
def get_marker(task: HasMarkers, key: str, default: Any = None) -> Any:
@@ -279,6 +281,36 @@ def set_video_reject_reason(task: HasMarkers, reason: str) -> None:
set_marker(task, VIDEO_REJECT_REASON, reason)
# --- vault curation ---------------------------------------------------------
# One-shot guard for the root-completion Auditor spawn (orchestrator): set the
# moment the spawn fires so a restart can't re-spawn a root another process
# instance already dispatched (in-memory `_board_dispatched` covers the
# same-process race; this covers cross-restart).
def is_vault_curation_dispatched(task: HasMarkers) -> bool:
return bool(get_marker(task, VAULT_CURATION_DISPATCHED, False))
def mark_vault_curation_dispatched(task: HasMarkers) -> None:
set_marker(task, VAULT_CURATION_DISPATCHED, True)
# --- vault note ref ----------------------------------------------------------
# The vault-intake watcher's origin ref on a held ``vault_note`` draft:
# {path, content_hash, action_items}. Set once at origination; read back by
# nothing else yet (phase 2 could use it to re-locate the source note).
def get_vault_note_ref(task: HasMarkers) -> dict[str, Any] | None:
val = get_marker(task, VAULT_NOTE_REF)
return val if isinstance(val, dict) else None
def set_vault_note_ref(task: HasMarkers, ref: dict[str, Any]) -> None:
set_marker(task, VAULT_NOTE_REF, ref)
# --- external PR head ------------------------------------------------------ #
+5
View File
@@ -37,6 +37,11 @@ SCOPE_TO_TYPE: dict[Scope, JournalEntryType] = {
Scope.STRUGGLE: JournalEntryType.STRUGGLE,
}
# Reverse of the above — used by the vault projection to render a journal
# entry's agent-facing scope label (not the raw SQLAlchemy enum name) into
# frontmatter.
TYPE_TO_SCOPE: dict[JournalEntryType, Scope] = {v: k for k, v in SCOPE_TO_TYPE.items()}
class ReadTier(StrEnum):
"""How widely a role can read other agents' journals."""
+11
View File
@@ -789,6 +789,16 @@ def archive_playbook(playbook_id: str) -> dict[str, Any]:
return _post("/api/v1/do/archive_playbook", {"playbook_id": playbook_id})
def curate_vault(task_id: str, narrative: str) -> dict[str, Any]:
"""Auditor only: write a root task-tree's Obsidian-vault narrative section
(what happened, decisions, rework story). No-op error if the vault flag
is off."""
return _post(
"/api/v1/do/curate_vault",
{"task_id": task_id, "narrative": narrative},
)
# ---------- Wave 1 — pre-gateway parity ----------
@@ -955,6 +965,7 @@ _TOOLS: dict[str, Any] = {
"approve_playbook": approve_playbook,
"reject_playbook": reject_playbook,
"archive_playbook": archive_playbook,
"curate_vault": curate_vault,
}
+164 -12
View File
@@ -785,7 +785,10 @@ def _is_held_ceo_source(task: dict[str, Any]) -> bool:
External-PR review (owned by the PR dispatcher), release proposals, X
posts/replies, and video-post drafts (all CEO-HELD, acted on only by
their own routes), and a self-heal fix task until the CEO's
approve_and_start flips ``confirmed_by_human``. Module-level (not a
approve_and_start flips ``confirmed_by_human``. A ``vault_note`` draft is
NOT held: it is a board-assigned intake draft, so this dispatcher's board
branch routes it to the board review (its start gate is the CEO's
approve_and_start, like any board-routed draft). Module-level (not a
method) so the dispatcher's unit tests, which drive it with a
wholesale-mocked ``self``, exercise the real skip logic rather than an
auto-mocked stub.
@@ -925,17 +928,7 @@ class AgentOrchestrator:
self._last_image_prune: datetime | None = None
# Rate-limit probe loop: 30-second interval, scans Redis for all
# rate-limited providers and resolves waiting agents on success.
self._rate_limit_probe_task: asyncio.Task | None = None
self._strategy_engine_task: asyncio.Task | None = None
self._external_pr_poll_task: asyncio.Task | None = None
self._self_heal_task: asyncio.Task | None = None
self._ci_watch_task: asyncio.Task | None = None
self._dep_update_task: asyncio.Task | None = None
self._release_manager_task: asyncio.Task | None = None
self._x_mentions_task: asyncio.Task | None = None
self._roadmap_engine_task: asyncio.Task | None = None
self._x_feature_spotlight_task: asyncio.Task | None = None
self._video_render_task: asyncio.Task | None = None
self._init_engine_loop_task_slots()
# per-engine-loop heartbeat (monotonic last-success, interval) so
# _check_loop_liveness can alert when a cycle task dies silently.
self._loop_heartbeats: dict[str, tuple[float, float]] = {}
@@ -1060,6 +1053,23 @@ class AgentOrchestrator:
self._grok_last_park_at: datetime | None = None
self._grok_repark_count: int = 0
def _init_engine_loop_task_slots(self) -> None:
"""Task handles for the default-off engine loops. Split out of
__init__ (rather than inlined) to keep it under the statement budget
as more engine loops accrete over time."""
self._rate_limit_probe_task: asyncio.Task | None = None
self._strategy_engine_task: asyncio.Task | None = None
self._external_pr_poll_task: asyncio.Task | None = None
self._self_heal_task: asyncio.Task | None = None
self._ci_watch_task: asyncio.Task | None = None
self._dep_update_task: asyncio.Task | None = None
self._release_manager_task: asyncio.Task | None = None
self._x_mentions_task: asyncio.Task | None = None
self._roadmap_engine_task: asyncio.Task | None = None
self._x_feature_spotlight_task: asyncio.Task | None = None
self._video_render_task: asyncio.Task | None = None
self._vault_intake_task: asyncio.Task | None = None
def _record_loop_heartbeat(self, name: str, interval: float) -> None:
self._loop_heartbeats[name] = (time.monotonic(), interval)
@@ -1132,6 +1142,11 @@ class AgentOrchestrator:
await sweep_orphan_release_locks()
# Obsidian vault: materialize the shipped .obsidian/ + _meta/ template
# assets on first enable (idempotent — never overwrites an operator's
# own edits). No-op when the flag is off.
self._ensure_vault_assets_on_startup()
# Start background tasks
self._health_task = asyncio.create_task(self._health_loop())
self._dispatcher_task = asyncio.create_task(self._dispatcher_loop())
@@ -1149,6 +1164,7 @@ class AgentOrchestrator:
self._x_feature_spotlight_loop()
)
self._video_render_task = asyncio.create_task(self._video_render_loop())
self._vault_intake_task = asyncio.create_task(self._vault_intake_loop())
logger.info(
"Orchestrator started",
@@ -1156,6 +1172,19 @@ class AgentOrchestrator:
internal_api_url=self._api_url,
)
def _ensure_vault_assets_on_startup(self) -> None:
"""Best-effort, gated: never blocks/fails startup on a vault error."""
if not settings.obsidian_vault_enabled:
return
try:
from pathlib import Path
from roboco.vault import ensure_vault_assets
ensure_vault_assets(Path(settings.vault_path))
except Exception as e:
logger.warning("Vault asset bootstrap failed at startup", error=str(e))
async def _cancel_background_task(self, task: asyncio.Task | None) -> None:
"""Cancel one background loop task and await its teardown (idempotent)."""
if task is None:
@@ -1247,6 +1276,7 @@ class AgentOrchestrator:
self._roadmap_engine_task,
self._x_feature_spotlight_task,
self._video_render_task,
self._vault_intake_task,
):
await self._cancel_background_task(task)
@@ -8035,6 +8065,36 @@ Start by:
await get_roadmap_engine(db).run_cycle()
await db.commit()
async def _vault_intake_loop(self) -> None:
"""Vault intake: on an interval, turn tagged notes into held drafts.
Dormant unless BOTH ``obsidian_vault_enabled`` AND
``vault_intake_enabled`` are on a standard deployment scans nothing.
Every draft is held for the CEO; this loop never starts anything.
"""
if not (settings.obsidian_vault_enabled and settings.vault_intake_enabled):
return
interval = settings.vault_intake_interval_seconds
self._record_loop_heartbeat("vault_intake", interval)
while self._running:
try:
await asyncio.sleep(interval)
await self._run_vault_intake_cycle()
self._record_loop_heartbeat("vault_intake", interval)
except asyncio.CancelledError:
break
except Exception:
logger.exception("vault-intake cycle failed")
async def _run_vault_intake_cycle(self) -> None:
"""One vault-intake pass: run the engine, commit. Testable w/o the sleep."""
from roboco.db import get_db_context
from roboco.services.vault_intake_engine import get_vault_intake_engine
async with get_db_context() as db:
await get_vault_intake_engine(db).run_cycle()
await db.commit()
async def _x_feature_spotlight_loop(self) -> None:
"""X engine: on an interval, open ONE held feature-spotlight exploration
for the Head of Marketing.
@@ -10855,6 +10915,7 @@ Start now: evidence(task_id="{task_id}")
("approval_work", self._dispatch_approval_work(client)),
("a2a_work", self._dispatch_a2a_work(client)),
("audit_work", self._dispatch_audit_work(client)),
("vault_curation_work", self._dispatch_vault_curation_work(client)),
("detect_stuck_tasks", self._detect_stuck_tasks(client)),
]
for name, coro in dispatchers:
@@ -11909,6 +11970,82 @@ Start now: evidence(task_id="{task_id}")
for task in tasks:
await self._maybe_spawn_pm_closure(client, task)
async def _dispatch_vault_curation_work(self, _client: httpx.AsyncClient) -> None:
"""Obsidian-vault root-completion hook: spawn the Auditor to write a
just-completed root task-tree's narrative.
Gated on ``ROBOCO_OBSIDIAN_VAULT_ENABLED``; a no-op scan otherwise.
Owns ONLY this vault-curation trigger distinct from
``_dispatch_audit_work`` (scheduled sweeps + alert producers, owned by
a separate, queued fleet task). ``_client`` is accepted for
dispatcher-tuple shape parity but unused this reads the DB
directly (see ``TaskService.list_completed_roots_pending_vault_curation``).
"""
if not settings.obsidian_vault_enabled:
return
from roboco.db.base import get_db_context
from roboco.services.task import TaskService
async with get_db_context() as db:
candidates = await TaskService(
db
).list_completed_roots_pending_vault_curation()
for task in candidates:
await self._maybe_spawn_vault_curation(str(task.id), task.title)
async def _maybe_spawn_vault_curation(self, task_id: str, title: str) -> None:
"""One-shot Auditor spawn for one completed root task.
Mirrors ``_dispatch_board_reviewer``'s guard shape: an in-memory
one-shot tracker (reuses ``_board_dispatched``) for the same-process
race, plus a durable ``vault_curation_dispatched`` marker so a
restart can't re-spawn a root another process instance already
handled. Spawned WITHOUT a bound task_id (mirrors
``_dispatch_audit_work``'s alert spawn) — the root task is
`completed`, so binding it would trip the readiness gate's
role-for-status check; the task id is named in the prompt instead,
and ``curate_vault`` takes it as an explicit argument.
"""
auditor_slug = "auditor"
if self._is_agent_active(auditor_slug):
return
key = (auditor_slug, task_id)
if key in self._board_dispatched:
return
self._board_dispatched.add(key)
await self._mark_vault_curation_dispatched(task_id)
logger.info("Spawning Auditor for vault curation", task_id=task_id)
await self.spawn_agent(
agent_id=auditor_slug,
initial_prompt=self._build_vault_curation_prompt(task_id, title),
spawned_by="_maybe_spawn_vault_curation",
)
@staticmethod
async def _mark_vault_curation_dispatched(task_id: str) -> None:
"""Persist the one-shot marker so a restart never re-spawns this
root. Best-effort: a failure here only risks a harmless duplicate
Auditor spawn (curate_vault's write is idempotent), never a crash."""
from uuid import UUID
from roboco.db.base import get_db_context
from roboco.foundation.policy.content import markers
from roboco.services.task import TaskService
try:
async with get_db_context() as db:
svc = TaskService(db)
task = await svc.get(UUID(task_id))
if task is not None:
markers.mark_vault_curation_dispatched(task)
await db.commit()
except Exception as exc:
logger.warning(
"Failed to persist vault_curation_dispatched marker",
task_id=task_id,
error=str(exc),
)
async def _fetch_subtasks(
self, client: httpx.AsyncClient, parent_id: str
) -> list[dict[str, Any]]:
@@ -13985,6 +14122,21 @@ Your job:
3. Identify any concerns or patterns
4. Compile audit report for CEO
5. Call i_am_idle() when complete
"""
def _build_vault_curation_prompt(self, task_id: str, title: str) -> str:
"""Build initial prompt for a root-completion Obsidian-vault curation."""
return f"""Root task COMPLETED — Obsidian-vault curation requested.
TASK: {title} ({task_id})
Your job:
1. Review this task's full tree (description, subtasks, PR, journal trail,
decisions, any rework story).
2. Call curate_vault(task_id="{task_id}", narrative=...) EXACTLY ONCE with a
concise narrative: what happened, key decisions, rework if any.
3. Call i_am_idle() when complete.
"""
def _build_a2a_prompt(self, notification: dict[str, Any]) -> str:
+37
View File
@@ -1343,8 +1343,45 @@ class A2AService:
await self._publish_a2a_message_sent(
model, task_id, from_agent, to_agent, skill
)
await self._materialize_vault_note(model, conv, from_agent, to_agent)
return model
@staticmethod
async def _materialize_vault_note(
msg: A2AChatMessage,
conv: A2AConversationTable,
from_agent: str,
to_agent: str,
) -> None:
"""Best-effort Obsidian-vault thread digest (event seam).
A vault failure never fails the A2A send logged and swallowed,
same posture as ``_publish_a2a_message_sent``.
"""
if not settings.obsidian_vault_enabled:
return
try:
from roboco.services.vault_writer import (
A2AMessageData,
TaskLinkRef,
get_vault_writer,
)
task_ref = TaskLinkRef(id=str(conv.task_id)) if conv.task_id else None
get_vault_writer().append_a2a_message(
A2AMessageData(
conversation_id=str(conv.id),
message_id=str(msg.id),
from_agent=from_agent,
to_agent=to_agent,
content=msg.content,
timestamp=msg.created_at or datetime.now(UTC),
task_ref=task_ref,
)
)
except Exception as e:
logger.warning("Vault A2A note materialization failed", error=str(e))
async def get_messages(
self,
conversation_id: UUID,
@@ -333,6 +333,10 @@ _DRAFT_PLAYBOOK_ROLES: frozenset[str] = frozenset(
)
_CURATE_PLAYBOOK_ROLES: frozenset[str] = frozenset({"auditor"})
# Vault-curation: only the Auditor writes a root task-tree's narrative
# (mirrors the playbook-curation bounded-expansion pattern above).
_CURATE_VAULT_ROLES: frozenset[str] = frozenset({"auditor"})
# propose_video's target-platform set + TikTok caption limit (the X caption
# reuses MAX_TWEET_CHARS). No role frozenset here, unlike the sets above:
# propose_video is gated on the caller's TEAM at runtime (_caller_team), not
@@ -985,6 +989,60 @@ class ContentActions:
},
)
async def curate_vault(
self, *, agent_id: UUID, task_id: UUID, narrative: str
) -> Envelope:
"""Auditor-only: write a root task-tree's vault narrative section.
Fully re-materializes the task's note (parent/subtasks/dependencies
resolved fresh) with ``narrative`` filling the ``## Narrative``
section a deterministic write otherwise leaves as a placeholder.
"""
role = await self._caller_role(agent_id)
if role not in _CURATE_VAULT_ROLES:
return Envelope.not_authorized(
message=f"role {role!r} may not curate the vault",
remediate="Only the Auditor writes vault narratives.",
context_briefing={},
)
if not settings.obsidian_vault_enabled:
return Envelope.invalid_state(
message="the Obsidian vault is disabled",
remediate="ROBOCO_OBSIDIAN_VAULT_ENABLED is off — nothing to curate.",
context_briefing={},
)
task = await self.task.get(task_id)
if task is None:
return Envelope.not_found(message=f"task {task_id} not found")
from roboco.services.project import get_project_service
from roboco.services.vault_assembly import assemble_task_note_data
from roboco.services.vault_writer import get_vault_writer
try:
data = await assemble_task_note_data(
self.task,
get_project_service(self.task.session),
task,
narrative=narrative,
)
get_vault_writer().write_task(data)
except Exception as exc:
logger.warning(
"vault curation write failed", task_id=str(task_id), error=str(exc)
)
return Envelope.invalid_state(
message=f"vault write failed: {exc}",
remediate="retry curate_vault; check ROBOCO_VAULT_PATH is writable",
context_briefing={},
)
return Envelope.ok(
status="vault_curated",
task_id=str(task_id),
next="continue",
context_briefing={},
)
async def _record_section_handoff(
self,
*,
+1
View File
@@ -143,6 +143,7 @@ _AUDITOR_DO = (
"approve_playbook",
"reject_playbook",
"archive_playbook",
"curate_vault",
"notify_list",
"notify_get",
)
+49
View File
@@ -301,6 +301,9 @@ class JournalService(BaseService):
),
is_private=entry_create.is_private,
)
await self._materialize_vault_note(
entry_row, agent_id_for_index, is_private=entry_create.is_private
)
return JournalEntry(
id=require_uuid(entry_row.id),
@@ -316,6 +319,52 @@ class JournalService(BaseService):
created_at=entry_row.created_at,
)
async def _materialize_vault_note(
self, entry_row: JournalEntryTable, agent_id: UUID, *, is_private: bool
) -> None:
"""Best-effort Obsidian-vault note for this entry (event seam).
Mirrors the RAG-index exclusion: a private entry never leaves the
agent's own journal, vault included. A vault failure NEVER fails the
journal write logged and swallowed, same posture as the RAG index.
"""
from roboco.config import settings
if is_private or not settings.obsidian_vault_enabled:
return
try:
from roboco.foundation.policy.journaling import TYPE_TO_SCOPE
from roboco.services.vault_writer import (
JournalNoteData,
TaskLinkRef,
get_vault_writer,
)
agent_slug = await self.get_agent_slug(agent_id)
if not agent_slug:
return
scope = TYPE_TO_SCOPE.get(entry_row.type)
task_ref = (
TaskLinkRef(id=str(entry_row.task_id)) if entry_row.task_id else None
)
get_vault_writer().write_journal_entry(
JournalNoteData(
entry_id=str(entry_row.id),
agent_slug=agent_slug,
scope=scope.value if scope else str(entry_row.type),
title=entry_row.title,
content=entry_row.content,
timestamp=entry_row.timestamp,
task_ref=task_ref,
)
)
except Exception as e:
self.log.warning(
"Vault note materialization failed (best-effort)",
entry_id=str(entry_row.id),
error=str(e),
)
def _schedule_rag_index(
self, params: IndexJournalEntryParams, *, is_private: bool
) -> None:
+2
View File
@@ -70,6 +70,8 @@ FEATURE_FLAGS: tuple[tuple[str, str], ...] = (
("video_on_spotlight", "Video on feature spotlight"),
("roadmap_engine_enabled", "Board roadmap engine"),
("fable_mode_enabled", "Fable + Ponytail doctrine (+ hooks)"),
("obsidian_vault_enabled", "Obsidian vault projection"),
("vault_intake_enabled", "Vault intake watcher (notes -> held drafts)"),
)
_FEATURE_FLAG_KEYS = tuple(key for key, _ in FEATURE_FLAGS)
+89 -1
View File
@@ -610,6 +610,14 @@ ROADMAP_SOURCE = "board_roadmap"
# (distinct from ROADMAP_SOURCE, which tags the held exploration cycle itself).
ROADMAP_ITEM_SOURCE = "roadmap"
# Source tag for an intake draft the vault-intake watcher originates from a
# #roboco-tagged vault note. Unlike X_SOURCES/VIDEO_HELD_SOURCES this IS
# dispatched — it rides the intake board-review path (PENDING, Product-Owner-
# assigned, team=board, confirmed_by_human=True, exactly the "Board review &
# Start" shape): the board reviews, the CEO's approve_and_start hands it to
# the Main PM. The board routing is the start gate; no held-source skip.
VAULT_NOTE_SOURCE = "vault_note"
def extract_self_heal_fingerprint(task: Any) -> str | None:
"""The self-heal dedupe fingerprint from a task's markers, or None.
@@ -854,6 +862,38 @@ class TaskService(BaseService):
details=dict(details),
)
)
self._touch_vault_frontmatter(task, to_status=to_status, team=details["team"])
def _touch_vault_frontmatter(
self, task: TaskTable, *, to_status: str, team: str
) -> None:
"""Best-effort, cheap Obsidian-vault frontmatter touch (event seam).
Only patches an EXISTING note's status/team/pr fields — no extra
queries (everything used here is already a plain column on ``task``).
A vault failure (or a not-yet-materialized note) never blocks the
transition; see ``VaultWriter.touch_task_frontmatter``.
"""
from roboco.config import settings
if not settings.obsidian_vault_enabled:
return
try:
from roboco.services.vault_writer import get_vault_writer
get_vault_writer().touch_task_frontmatter(
task_id=str(task.id),
status=to_status,
team=team,
pr_number=task.pr_number,
pr_url=task.pr_url,
)
except Exception as e:
self.log.warning(
"Vault frontmatter touch failed (best-effort)",
task_id=str(task.id),
error=str(e),
)
def _emit_escalation_audit(
self, task: TaskTable, *, escalator_slug: str, target_slug: str
@@ -1605,6 +1645,24 @@ class TaskService(BaseService):
)
return list(result.scalars().all())
async def list_open_vault_note_drafts(self) -> list[TaskTable]:
"""Vault-note drafts still awaiting board review / CEO approval — the
vault-intake watcher's open-cap basis. Keys on ``team == BOARD``:
``approve_and_start`` flips the team to MAIN_PM (draft leaves the cap)
and a cancelled/rejected draft goes terminal (leaves too), so the cap
counts only drafts actually pending a human decision it can never
permanently brick the engine. Ordered oldest-first."""
result = await self.session.execute(
select(TaskTable)
.where(
TaskTable.source == VAULT_NOTE_SOURCE,
TaskTable.team == Team.BOARD,
TaskTable.status.notin_([TaskStatus.COMPLETED, TaskStatus.CANCELLED]),
)
.order_by(TaskTable.created_at)
)
return list(result.scalars().all())
async def list_external_pr_reviews_awaiting_decision(self) -> list[TaskTable]:
"""Completed external-PR reviews still awaiting the CEO's decision.
@@ -6839,6 +6897,34 @@ class TaskService(BaseService):
)
return list(result.scalars().all())
async def list_completed_roots_pending_vault_curation(
self, *, limit: int = 20
) -> list[TaskTable]:
"""Root tasks (no parent) most recently completed, not yet vault-curated.
Backs the orchestrator's root-completion Auditor spawn. Ordered by
``completed_at`` DESC within a small window self-bounding without a
migration: ``orchestration_markers`` is a plain JSON column (no
server-side "key absent" filter), so the marker check runs client-side
over a small recency window instead of the whole completed-task table,
which would otherwise grow forever and could mask a fresh completion
behind older, already-curated rows.
"""
result = await self.session.execute(
select(TaskTable)
.where(
TaskTable.status == TaskStatus.COMPLETED,
TaskTable.parent_task_id.is_(None),
)
.order_by(TaskTable.completed_at.desc())
.limit(limit)
)
return [
t
for t in result.scalars().all()
if not markers.is_vault_curation_dispatched(t)
]
async def list_all(
self,
limit: int = 100,
@@ -8261,7 +8347,9 @@ class TaskService(BaseService):
reason. ``VIDEO_HELD_SOURCES`` (``video_post``) gets the identical
treatment; the video-authoring source (``video``) is NOT held it is
pre-assigned to a ux-dev and must still be offered like any other
delegated code task.
delegated code task. A ``vault_note`` draft is NOT held either like a
prompter board draft, its board assignment IS the gate (board roles have
no ``give_me_work``), so no source exclusion is needed.
Ordered by sequence asc, then priority asc, then created_at asc so
earlier-sequence tasks win.
+86
View File
@@ -0,0 +1,86 @@
"""Assembles ``VaultWriter`` dataclasses from live DB state.
Kept separate from ``vault_writer`` (which is a pure, DB-free materializer)
so that module stays trivially unit-testable with tmp_path. Shared by the
Auditor's ``curate_vault`` verb and the ``python -m roboco.vault rebuild`` CLI
both need "task + parent + subtasks + dependencies + project slug" turned
into a ``TaskNoteData``.
Services are passed in as ``Any`` (duck-typed) rather than imported by type,
so this module never needs ``roboco.services.task`` / ``roboco.services.project``
at import time avoids a cycle with ``TaskService`` (which itself calls into
the vault seam on status transitions).
"""
from __future__ import annotations
from typing import Any
from roboco.services.vault_writer import TaskLinkRef, TaskNoteData
def _enum_value(value: Any) -> str:
return value.value if hasattr(value, "value") else str(value)
async def _resolve_project_slug(project_service: Any, task: Any) -> str:
if task.project_id is None:
return "unassigned"
project = await project_service.get(task.project_id)
return project.slug if project is not None else "unassigned"
async def _resolve_parent(task_service: Any, task: Any) -> TaskLinkRef | None:
if task.parent_task_id is None:
return None
parent_task = await task_service.get(task.parent_task_id)
if parent_task is None:
return None
return TaskLinkRef(id=str(parent_task.id), title=parent_task.title)
async def _resolve_subtasks(task_service: Any, task: Any) -> tuple[TaskLinkRef, ...]:
return tuple(
TaskLinkRef(id=str(t.id), title=t.title)
for t in await task_service.get_subtasks(task.id)
)
async def _resolve_dependencies(
task_service: Any, task: Any
) -> tuple[TaskLinkRef, ...]:
dependencies: list[TaskLinkRef] = []
for dep_id in task.dependency_ids or []:
dep = await task_service.get(dep_id)
if dep is not None:
dependencies.append(TaskLinkRef(id=str(dep.id), title=dep.title))
return tuple(dependencies)
async def assemble_task_note_data(
task_service: Any,
project_service: Any,
task: Any,
*,
narrative: str | None = None,
) -> TaskNoteData:
"""Build the full ``TaskNoteData`` for one task, resolving its parent,
subtasks, dependencies, and project slug via the given services."""
return TaskNoteData(
id=str(task.id),
title=task.title,
project_slug=await _resolve_project_slug(project_service, task),
description=task.description or "",
status=_enum_value(task.status),
team=_enum_value(task.team),
priority=task.priority,
task_type=_enum_value(task.task_type),
acceptance_criteria=tuple(task.acceptance_criteria or ()),
pr_number=task.pr_number,
pr_url=task.pr_url,
parent=await _resolve_parent(task_service, task),
subtasks=await _resolve_subtasks(task_service, task),
dependencies=await _resolve_dependencies(task_service, task),
batch_id=str(task.batch_id) if task.batch_id else None,
narrative=narrative,
)
+359
View File
@@ -0,0 +1,359 @@
"""VaultIntakeEngine — vault notes tagged ``#roboco`` become board-review drafts.
The vexa-inspired input loop (V1 item 4). Detection/dedup mirrors the
XEngine mentions poll; the draft itself takes the intake **board-review
path** (exactly what a chat-confirmed "Board review & Start" draft is), not
the held-artifact pattern:
* **Default OFF.** Both ``obsidian_vault_enabled`` AND ``vault_intake_enabled``
must be on the engine is inert if either is off.
* **Never starts work.** Every draft is a PENDING, Product-Owner-assigned,
``team=board`` task (``source=vault_note``): the PM dispatcher routes it
to the two-reviewer board review (PO + Head of Marketing), which ends in a
CEO notification nothing enters delivery until the CEO's explicit
``approve_and_start`` hands it to the Main PM. Board dispatch only ever
spawns the advisory REVIEW; board roles have no verb to claim, plan, or
delegate, so the CEO gate is structural.
* **Local model only.** Extraction runs on the local LLM (MemoryDistiller
posture) with a deterministic fallback (first heading / raw body /
checkbox lines) on any failure never a cloud LLM in the hot path. The
raw note body reaches the local model unsanitized the same documented
prompt-injection surface x_engine accepts for mention text; the board +
CEO gates downstream are the containment, not the prompt.
* **Dedup ledger.** ``vault_seen_notes`` keys on (vault-relative path,
content hash) so an unchanged note is never reprocessed, but an edited one
is eligible again. The hash excludes RoboCo's own feedback callout so
appending it after processing does not itself trigger a reprocess.
"""
from __future__ import annotations
import hashlib
import json
import re
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
from typing import TYPE_CHECKING, Any, cast
import httpx
import yaml
from sqlalchemy import select
from roboco.config import settings
from roboco.db.tables import VaultSeenNoteTable
from roboco.foundation import identity as _foundation
from roboco.foundation.policy.content import markers
from roboco.models.base import Complexity, TaskNature, TaskStatus, TaskType, Team
from roboco.services.base import BaseService
from roboco.services.project import get_project_service
from roboco.services.task import VAULT_NOTE_SOURCE, TaskCreateRequest, get_task_service
if TYPE_CHECKING:
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from roboco.db.tables import ProjectTable, TaskTable
_CHAT_TIMEOUT_SECONDS = 60.0
_AC_MAX_ITEMS = 7
_AC_MAX_ITEM_CHARS = 200
_TITLE_MAX_CHARS = 200
_DEFAULT_AC = "CEO reviews and starts this drafted task"
# Frontmatter block at the start of the file (mirrors vault_writer's own
# helper — a local copy, since inbox notes are arbitrary CEO-authored
# markdown, a different trust/shape boundary than the projection core's own
# generated notes).
_FRONTMATTER_RE = re.compile(r"\A---\n(.*?)\n---\n?", re.DOTALL)
# A whole `#roboco` tag, not a prefix of a longer tag (`#roboco/idea`) or word.
_INLINE_TAG_RE = re.compile(r"(?<![\w/-])#roboco(?![\w/-])")
_HEADING_RE = re.compile(r"^#{1,6}\s+(.+?)\s*$", re.MULTILINE)
_CHECKBOX_RE = re.compile(r"^\s*-\s*\[ \]\s*(.+?)\s*$", re.MULTILINE)
# The feedback callout this engine appends (see _append_feedback_callout).
# Stripped before hashing so appending it doesn't change the ledger key.
_FEEDBACK_CALLOUT_RE = re.compile(r"\n?> \[!info\] RoboCo: drafted .*(?:\n|$)")
def _split_frontmatter(text: str) -> tuple[dict[str, Any], str]:
"""Frontmatter dict + body, or ({}, text) with no frontmatter block."""
m = _FRONTMATTER_RE.match(text)
if not m:
return {}, text
loaded = yaml.safe_load(m.group(1))
return (loaded if isinstance(loaded, dict) else {}), text[m.end() :]
def _has_roboco_tag(frontmatter: dict[str, Any], body: str) -> bool:
"""True if frontmatter ``tags`` (list or scalar) or the body carries a
whole ``#roboco`` tag."""
tags = frontmatter.get("tags")
if isinstance(tags, str):
tags = [tags]
if isinstance(tags, list) and any(
str(t).strip().lstrip("#") == "roboco" for t in tags
):
return True
return bool(_INLINE_TAG_RE.search(body))
def _content_hash(raw_text: str) -> str:
"""Sha256 of the note with RoboCo's own feedback callout stripped out."""
stable = _FEEDBACK_CALLOUT_RE.sub("", raw_text)
return hashlib.sha256(stable.encode("utf-8")).hexdigest()
def _clamp_action_items(items: list[str]) -> list[str]:
cleaned = [i.strip()[:_AC_MAX_ITEM_CHARS] for i in items if i.strip()]
return cleaned[:_AC_MAX_ITEMS]
@dataclass(frozen=True)
class _NoteExtraction:
"""title + description + action items pulled from one vault note —
bundled so ``_originate`` doesn't need one param per field."""
title: str
description: str
action_items: list[str]
def _deterministic_extract(body: str, fallback_title: str) -> _NoteExtraction:
"""Local-model-failure fallback: first heading, raw body, checkbox lines."""
heading = _HEADING_RE.search(body)
title = heading.group(1).strip() if heading else fallback_title
action_items = [m.group(1).strip() for m in _CHECKBOX_RE.finditer(body)]
description = body.strip() or f"Vault note: {title}"
return _NoteExtraction(title, description, action_items)
def _extraction_prompt(body: str) -> str:
return (
"Extract a task draft from this Obsidian vault note. Reply with ONLY "
'a JSON object: {"title": <short title>, "description": <1-3 '
'sentence summary>, "action_items": [<action item>, ...]}. No '
"markdown fences, no commentary.\n\n"
f"Note:\n{body.strip()}\n"
)
async def _chat(prompt: str) -> str | None:
"""One local-LLM chat call (OpenAI-compatible); None on a non-success."""
async with httpx.AsyncClient(timeout=_CHAT_TIMEOUT_SECONDS) as client:
resp = await client.post(
f"{settings.local_llm_base_url}/chat/completions",
json={
"model": settings.local_llm_model,
"messages": [{"role": "user", "content": prompt}],
"max_tokens": 400,
},
)
if not resp.is_success:
return None
data = resp.json()
choices = data.get("choices") or []
if not choices:
return None
content = choices[0].get("message", {}).get("content")
return content if isinstance(content, str) else None
def _parse_extraction(raw: str) -> _NoteExtraction | None:
"""Parse the local model's JSON reply; None on any malformed shape."""
cleaned = raw.strip()
if cleaned.startswith("```"):
cleaned = cleaned.strip("`").removeprefix("json").strip()
try:
data = json.loads(cleaned)
except (json.JSONDecodeError, ValueError):
return None
if not isinstance(data, dict):
return None
title = str(data.get("title") or "").strip()
description = str(data.get("description") or "").strip()
if not title or not description:
return None
raw_items = data.get("action_items")
items = [str(i) for i in raw_items] if isinstance(raw_items, list) else []
return _NoteExtraction(title, description, items)
class VaultIntakeEngine(BaseService):
"""Turn ``#roboco``-tagged vault notes into board-review intake drafts."""
service_name = "vault_intake_engine"
async def run_cycle(self) -> list[TaskTable]:
"""One intake pass: scan, dedup, extract, draft for board review.
Empty list unless both the vault AND intake flags are on, the intake
dir exists, the open-draft cap isn't already reached, and the RoboCo
project resolves."""
if not (settings.obsidian_vault_enabled and settings.vault_intake_enabled):
return []
intake_dir = Path(settings.vault_path) / settings.vault_intake_dir
if not intake_dir.is_dir():
return []
task_svc = get_task_service(self.session)
open_count = len(await task_svc.list_open_vault_note_drafts())
if open_count >= settings.vault_intake_max_open_drafts:
self.log.info("vault-intake: open-draft cap reached; skipping cycle")
return []
project = await self._roboco_project()
if project is None or project.id is None:
self.log.warning("vault-intake: RoboCo project not resolvable; skipping")
return []
return await self._process_notes(
sorted(intake_dir.glob("*.md")), cast("UUID", project.id), open_count
)
async def _roboco_project(self) -> ProjectTable | None:
slug = (settings.self_heal_project_slug or "roboco-api").strip()
return await get_project_service(self.session).get_by_slug(slug)
async def _process_notes(
self, note_paths: list[Path], project_id: UUID, open_count: int
) -> list[TaskTable]:
"""Per-note isolation: one bad note (unreadable, malformed) is
skipped and logged rather than aborting the rest of the cycle."""
originated: list[TaskTable] = []
for note_path in note_paths:
if len(originated) >= settings.vault_intake_max_per_cycle:
break
if open_count + len(originated) >= settings.vault_intake_max_open_drafts:
break
try:
task = await self._process_note(note_path, project_id)
except OSError as exc:
self.log.warning(
"vault-intake: note read failed (skipped)",
path=str(note_path),
error=str(exc),
)
continue
if task is not None:
originated.append(task)
return originated
async def _process_note(
self, note_path: Path, project_id: UUID
) -> TaskTable | None:
raw = note_path.read_text(encoding="utf-8")
frontmatter, body = _split_frontmatter(raw)
if not _has_roboco_tag(frontmatter, body):
return None
rel_path = str(note_path.relative_to(Path(settings.vault_path)))
content_hash = _content_hash(raw)
if await self._already_seen(rel_path, content_hash):
return None
extraction = await self._extract(body, note_path.stem)
extraction = _NoteExtraction(
extraction.title,
extraction.description,
_clamp_action_items(extraction.action_items),
)
task = await self._originate(
project_id=project_id,
rel_path=rel_path,
content_hash=content_hash,
extraction=extraction,
)
self.session.add(
VaultSeenNoteTable(note_path=rel_path, content_hash=content_hash)
)
await self.session.flush()
self._append_feedback_callout(note_path, task)
return task
async def _extract(self, body: str, fallback_title: str) -> _NoteExtraction:
try:
raw = await _chat(_extraction_prompt(body))
except Exception as exc:
self.log.warning(
"vault-intake: local-model extraction failed (fallback)",
error=str(exc),
)
raw = None
parsed = _parse_extraction(raw) if raw else None
return parsed or _deterministic_extract(body, fallback_title)
async def _already_seen(self, rel_path: str, content_hash: str) -> bool:
result = await self.session.execute(
select(VaultSeenNoteTable.id)
.where(
VaultSeenNoteTable.note_path == rel_path,
VaultSeenNoteTable.content_hash == content_hash,
)
.limit(1)
)
return result.scalar_one_or_none() is not None
async def _originate(
self,
*,
project_id: UUID,
rel_path: str,
content_hash: str,
extraction: _NoteExtraction,
) -> TaskTable:
"""Open ONE PENDING board-review draft (the intake "Board review &
Start" shape: Product-Owner-assigned, ``team=board``). The board
reviews it; delivery starts only at the CEO's ``approve_and_start``,
which flips team to MAIN_PM and hands it to the Main PM.
``confirmed_by_human=True`` matches the intake path the board
routing is the start gate, not the confirm flag. PLANNING-typed: the
post-approve owner is the Main PM, which never owns code (the type
``approve_and_start`` would coerce to anyway)."""
task_svc = get_task_service(self.session)
task = await task_svc.create(
TaskCreateRequest(
title=f"Vault note: {extraction.title}"[:_TITLE_MAX_CHARS],
description=extraction.description,
acceptance_criteria=extraction.action_items or [_DEFAULT_AC],
team=Team.BOARD,
assigned_to=_foundation.AGENTS["product-owner"].uuid,
created_by=_foundation.AGENTS["system"].uuid,
task_type=TaskType.PLANNING,
nature=TaskNature.TECHNICAL,
estimated_complexity=Complexity.MEDIUM,
project_id=project_id,
status=TaskStatus.PENDING,
source=VAULT_NOTE_SOURCE,
confirmed_by_human=True, # board-routed; approve_and_start is the gate
)
)
markers.set_vault_note_ref(
task,
{
"path": rel_path,
"content_hash": content_hash,
"action_items": extraction.action_items,
},
)
await self.session.flush()
self.log.info(
"vault-intake: board-review draft opened",
task_id=str(task.id),
path=rel_path,
)
return task
def _append_feedback_callout(self, note_path: Path, task: TaskTable) -> None:
"""Best-effort: a filesystem error here never fails the cycle."""
try:
id8 = str(task.id)[:8]
date_str = datetime.now(UTC).strftime("%Y-%m-%d")
line = f"\n> [!info] RoboCo: drafted {task.title} ({id8}) on {date_str}\n"
with note_path.open("a", encoding="utf-8") as fh:
fh.write(line)
except OSError as exc:
self.log.warning(
"vault-intake: feedback callout append failed",
path=str(note_path),
error=str(exc),
)
def get_vault_intake_engine(session: AsyncSession) -> VaultIntakeEngine:
"""Build a VaultIntakeEngine for ``session``."""
return VaultIntakeEngine(session)
+370
View File
@@ -0,0 +1,370 @@
"""VaultWriter — pure Obsidian-vault materializer (projection core, V1).
Entity -> markdown note (frontmatter + body). Idempotent per entity id: the
same input always yields the same file, and safe to re-run (rebuild/CLI,
retried seams). No DB/network access callers assemble plain dataclasses
from their own service layer and hand them to this module.
Layout (``docs/internal/specs/2026-07-09-obsidian-vault.md`` §Vault layout)::
RoboCo/
Tasks/<project-slug>/<title> (<id8>).md
Journals/<agent-slug>/<date> <title> (<id8>).md
A2A/<date> <agents> (<thread-id8>).md
Agents/<slug>.md
_meta/
Link stability: every note carries ``aliases: [<id8>]`` in frontmatter, so a
cross-link is always written as ``[[<id8>|<title>]]`` Obsidian resolves it
via the alias regardless of the target's CURRENT filename. A title edit
therefore updates the body's title line (and, for a full ``write_task``
re-render, the frontmatter) without ever renaming the file or breaking a
link elsewhere in the vault.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Any
import yaml
if TYPE_CHECKING:
from datetime import datetime
_ILLEGAL_FILENAME_CHARS = re.compile(r'[\\/:*?"<>|]')
_MAX_TITLE_LEN = 80
_NARRATIVE_PLACEHOLDER = "_Pending Auditor curation._"
_FRONTMATTER_RE = re.compile(r"\A---\n(.*?)\n---\n?", re.DOTALL)
def _safe_title(title: str) -> str:
"""Filesystem-safe, length-capped title for a filename component."""
cleaned = _ILLEGAL_FILENAME_CHARS.sub("", title or "").strip()
cleaned = re.sub(r"\s+", " ", cleaned)
return (cleaned or "untitled")[:_MAX_TITLE_LEN]
def _id8(entity_id: str) -> str:
return str(entity_id)[:8]
def _find_by_id8(directory: Path, id8: str) -> Path | None:
"""Locate an existing note by its stable id8 suffix, if the dir exists."""
if not directory.exists():
return None
matches = sorted(directory.glob(f"*({id8}).md"))
return matches[0] if matches else None
def _rfind_by_id8(root: Path, id8: str) -> Path | None:
"""Recursive variant of ``_find_by_id8`` for callers that don't know
which subfolder (e.g. project slug) a note lives under."""
if not root.exists():
return None
matches = sorted(root.rglob(f"*({id8}).md"))
return matches[0] if matches else None
def _drop_none(mapping: dict[str, Any]) -> dict[str, Any]:
return {k: v for k, v in mapping.items() if v is not None}
def _render_note(frontmatter: dict[str, Any], body: str) -> str:
fm = yaml.safe_dump(
_drop_none(frontmatter), sort_keys=False, default_flow_style=False
).strip()
return f"---\n{fm}\n---\n\n{body.rstrip()}\n"
def _split_frontmatter(text: str) -> tuple[dict[str, Any], str]:
m = _FRONTMATTER_RE.match(text)
if not m:
return {}, text
loaded = yaml.safe_load(m.group(1))
fm = loaded if isinstance(loaded, dict) else {}
return fm, text[m.end() :]
@dataclass(frozen=True)
class TaskLinkRef:
"""A wikilink target: id (any length; truncated to id8) + display title."""
id: str
title: str = ""
def _wikilink(ref: TaskLinkRef) -> str:
id8 = _id8(ref.id)
return f"[[{id8}|{ref.title}]]" if ref.title else f"[[{id8}]]"
@dataclass(frozen=True)
class TaskNoteData:
"""Deterministic content for one task note. ``narrative`` is None until
the Auditor curates it (a placeholder is rendered instead)."""
id: str
title: str
project_slug: str
description: str
status: str
team: str
priority: int
task_type: str
acceptance_criteria: tuple[str, ...] = ()
pr_number: int | None = None
pr_url: str | None = None
parent: TaskLinkRef | None = None
subtasks: tuple[TaskLinkRef, ...] = ()
dependencies: tuple[TaskLinkRef, ...] = ()
batch_id: str | None = None
narrative: str | None = None
def _task_frontmatter(data: TaskNoteData, id8: str) -> dict[str, Any]:
return {
"aliases": [id8],
"status": data.status,
"team": data.team,
"priority": data.priority,
"pr": data.pr_url or (f"#{data.pr_number}" if data.pr_number else None),
"parent": f"[[{_id8(data.parent.id)}]]" if data.parent else None,
"batch": _id8(data.batch_id) if data.batch_id else None,
"tags": [f"status/{data.status}", f"team/{data.team}"],
}
def _task_body(data: TaskNoteData) -> list[str]:
body: list[str] = [f"# {data.title}", "", (data.description or "").strip()]
if data.acceptance_criteria:
body += ["", "## Acceptance Criteria"]
body += [f"- [ ] {c}" for c in data.acceptance_criteria]
if data.parent:
body += ["", "## Parent", f"- {_wikilink(data.parent)}"]
if data.subtasks:
body += ["", "## Subtasks"]
body += [f"- {_wikilink(s)}" for s in data.subtasks]
if data.dependencies:
body += ["", "## Dependencies"]
body += [f"- {_wikilink(d)}" for d in data.dependencies]
body += ["", "## Narrative", (data.narrative or _NARRATIVE_PLACEHOLDER).strip()]
return body
@dataclass(frozen=True)
class JournalNoteData:
entry_id: str
agent_slug: str
scope: str
title: str
content: str
timestamp: datetime
task_ref: TaskLinkRef | None = None
@dataclass(frozen=True)
class A2AMessageData:
conversation_id: str
message_id: str
from_agent: str
to_agent: str
content: str
timestamp: datetime
task_ref: TaskLinkRef | None = None
@dataclass(frozen=True)
class AgentNoteData:
slug: str
name: str
role: str
team: str | None = None
class VaultWriter:
"""Pure file-system materializer, rooted at ``root`` (``ROBOCO_VAULT_PATH``)."""
def __init__(self, root: Path) -> None:
self.root = Path(root)
# --- paths ---------------------------------------------------------- #
def _tasks_root(self) -> Path:
return self.root / "RoboCo" / "Tasks"
def _journals_root(self) -> Path:
return self.root / "RoboCo" / "Journals"
def _a2a_root(self) -> Path:
return self.root / "RoboCo" / "A2A"
def _agents_root(self) -> Path:
return self.root / "RoboCo" / "Agents"
# --- tasks ------------------------------------------------------------ #
def write_task(self, data: TaskNoteData) -> Path:
"""Full deterministic materialize (create-or-overwrite). The
filename is stable across title renames an existing note is found
by id8 and its filename reused; only a brand-new note is named from
the current title."""
id8 = _id8(data.id)
directory = self._tasks_root() / (data.project_slug or "unassigned")
directory.mkdir(parents=True, exist_ok=True)
existing = _find_by_id8(directory, id8)
filename = (
existing.name if existing else f"{_safe_title(data.title)} ({id8}).md"
)
path = directory / filename
path.write_text(
_render_note(_task_frontmatter(data, id8), "\n".join(_task_body(data))),
encoding="utf-8",
)
return path
def existing_narrative(self, project_slug: str, task_id: str) -> str | None:
"""Read back an existing note's ``## Narrative`` section so a rebuild
never clobbers Auditor-authored prose (it isn't derivable from DB
state). None when the note doesn't exist yet or still carries the
deterministic placeholder."""
directory = self._tasks_root() / (project_slug or "unassigned")
existing = _find_by_id8(directory, _id8(task_id))
if existing is None:
return None
_, body = _split_frontmatter(existing.read_text(encoding="utf-8"))
marker = "## Narrative"
idx = body.find(marker)
if idx == -1:
return None
narrative = body[idx + len(marker) :].strip()
return narrative if narrative and narrative != _NARRATIVE_PLACEHOLDER else None
def touch_task_frontmatter(
self,
*,
task_id: str,
status: str,
team: str,
pr_number: int | None,
pr_url: str | None,
) -> bool:
"""Cheap status-transition touch: patch frontmatter keys in place on
an EXISTING note, never rewriting the body/links. No-op (returns
False) when the note hasn't been materialized yet — full
materialization happens at Auditor curation / CLI rebuild, per the
vault's event-driven freshness model, so this never invents content
it doesn't have (no extra queries — just the fields on the row)."""
id8 = _id8(task_id)
path = _rfind_by_id8(self._tasks_root(), id8)
if path is None:
return False
fm, body = _split_frontmatter(path.read_text(encoding="utf-8"))
fm["status"] = status
fm["team"] = team
fm["pr"] = pr_url or (f"#{pr_number}" if pr_number else None)
fm["tags"] = [f"status/{status}", f"team/{team}"]
path.write_text(_render_note(fm, body), encoding="utf-8")
return True
# --- journals ----------------------------------------------------------- #
def write_journal_entry(self, data: JournalNoteData) -> Path:
"""One file per entry (immutable once written — always safe to
re-render the same entry id in place)."""
directory = self._journals_root() / data.agent_slug
directory.mkdir(parents=True, exist_ok=True)
id8 = _id8(data.entry_id)
date_str = data.timestamp.strftime("%Y-%m-%d")
existing = _find_by_id8(directory, id8)
filename = (
existing.name
if existing
else f"{date_str} {_safe_title(data.title)} ({id8}).md"
)
path = directory / filename
frontmatter = {
"aliases": [id8],
"agent": data.agent_slug,
"scope": data.scope,
"date": date_str,
}
body = [f"# {data.title}", ""]
if data.task_ref:
body += [f"Task: {_wikilink(data.task_ref)}", ""]
body.append((data.content or "").strip())
path.write_text(_render_note(frontmatter, "\n".join(body)), encoding="utf-8")
return path
# --- A2A ---------------------------------------------------------------- #
def append_a2a_message(self, data: A2AMessageData) -> Path:
"""Per-thread digest file, appended to on every message. Idempotent
per message id (a marker comment guards against a double-append on
retry)."""
directory = self._a2a_root()
directory.mkdir(parents=True, exist_ok=True)
id8 = _id8(data.conversation_id)
participants = sorted({data.from_agent, data.to_agent})
existing = _find_by_id8(directory, id8)
if existing is None:
date_str = data.timestamp.strftime("%Y-%m-%d")
label = "-".join(participants)
filename = f"{date_str} {_safe_title(label)} ({id8}).md"
path = directory / filename
frontmatter = {
"aliases": [id8],
"participants": participants,
"task": _wikilink(data.task_ref) if data.task_ref else None,
}
header = [
f"# A2A: {label}",
"",
"Participants: " + ", ".join(f"[[{p}]]" for p in participants),
]
if data.task_ref:
header += [f"Task: {_wikilink(data.task_ref)}"]
path.write_text(
_render_note(frontmatter, "\n".join(header)), encoding="utf-8"
)
else:
path = existing
marker = f"<!-- msg:{data.message_id} -->"
text = path.read_text(encoding="utf-8")
if marker in text:
return path
ts = data.timestamp.strftime("%H:%M:%S")
block = (
f"\n**{data.from_agent}** ({ts}) {marker}\n{(data.content or '').strip()}\n"
)
with path.open("a", encoding="utf-8") as fh:
fh.write(block)
return path
# --- agents --------------------------------------------------------------- #
def write_agent(self, data: AgentNoteData) -> Path:
"""Identity hub note; backlinks (Obsidian-native) collect this
agent's tasks/journal-entries/A2A threads — no explicit index kept
here."""
directory = self._agents_root()
directory.mkdir(parents=True, exist_ok=True)
path = directory / f"{data.slug}.md"
frontmatter = {"role": data.role, "team": data.team}
body = f"# {data.name}\n\nRole: {data.role}\nTeam: {data.team or '(none)'}\n"
path.write_text(_render_note(frontmatter, body), encoding="utf-8")
return path
def get_vault_writer() -> VaultWriter:
"""Factory reading ``settings.vault_path``. Callers gate on
``settings.obsidian_vault_enabled`` themselves (this module has no
opinion on the flag it's a pure materializer)."""
from roboco.config import settings
return VaultWriter(Path(settings.vault_path))
+236
View File
@@ -0,0 +1,236 @@
"""``python -m roboco.vault {rebuild|relocate <new-path>}``.
``rebuild``: full re-projection of every live entity (agents, tasks, journal
entries, A2A threads) from the DB into the vault, plus materializing the
shipped ``.obsidian/`` config + ``RoboCo/_meta/`` dashboards from packaged
templates (``roboco/vault_assets/``) if not already present. A task's
``## Narrative`` (Auditor-authored, not derivable from DB state) is read back
from the existing note and preserved across the rebuild.
``relocate <new-path>``: move the vault tree to a new location. Notes use
relative/alias-based wikilinks, so nothing inside them needs rewriting. An
already-existing destination (a personal vault) receives only the ``RoboCo/``
subtree plus any absent shipped assets its own ``.obsidian`` is never
touched.
Both are inert unless ``ROBOCO_OBSIDIAN_VAULT_ENABLED`` is on (rebuild would
otherwise materialize a vault nobody reads).
"""
from __future__ import annotations
import argparse
import asyncio
import shutil
import sys
from datetime import UTC, datetime
from importlib import resources
from pathlib import Path
from typing import Any
from roboco.config import settings
def ensure_vault_assets(vault_root: Path) -> None:
"""Materialize ``.obsidian/`` + ``RoboCo/_meta/`` from packaged templates.
Never overwrites a file that already exists an operator's own edits to
the shipped config/dashboards survive both a later rebuild and repeated
startup calls (idempotent, cheap when everything's already there).
"""
assets = resources.files("roboco.vault_assets")
_copy_tree(assets.joinpath("obsidian"), vault_root / ".obsidian")
_copy_tree(assets.joinpath("meta"), vault_root / "RoboCo" / "_meta")
def _copy_tree(src: Any, dest: Path) -> None:
for entry in src.iterdir():
target = dest / entry.name
if entry.is_dir():
_copy_tree(entry, target)
elif not target.exists():
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(entry.read_bytes())
async def _rebuild_agents(writer: Any, agent_service: Any) -> list[Any]:
from roboco.services.vault_writer import AgentNoteData
agents = await agent_service.list_agents()
for agent in agents:
writer.write_agent(
AgentNoteData(
slug=agent.slug,
name=agent.name,
role=str(getattr(agent.role, "value", agent.role)),
team=str(agent.team.value) if agent.team else None,
)
)
return list(agents)
async def _rebuild_tasks(writer: Any, task_service: Any, project_service: Any) -> None:
from roboco.services.vault_assembly import assemble_task_note_data
offset = 0
while True:
tasks = await task_service.list_all(limit=100, offset=offset)
if not tasks:
break
for task in tasks:
project_slug = "unassigned"
if task.project_id is not None:
project = await project_service.get(task.project_id)
if project is not None:
project_slug = project.slug
narrative = writer.existing_narrative(project_slug, str(task.id))
data = await assemble_task_note_data(
task_service, project_service, task, narrative=narrative
)
writer.write_task(data)
offset += len(tasks)
async def _rebuild_journals(
writer: Any, journal_service: Any, agents: list[Any]
) -> None:
from roboco.foundation.policy.journaling import TYPE_TO_SCOPE
from roboco.models.journal import ListEntriesFilter
from roboco.services.vault_writer import JournalNoteData, TaskLinkRef
for agent in agents:
journal = await journal_service.get_or_create_journal(agent.id)
offset = 0
while True:
entries = await journal_service.list_entries(
journal.id, ListEntriesFilter(limit=100, offset=offset)
)
if not entries:
break
for entry in entries:
scope = TYPE_TO_SCOPE.get(entry.type)
task_ref = TaskLinkRef(id=str(entry.task_id)) if entry.task_id else None
writer.write_journal_entry(
JournalNoteData(
entry_id=str(entry.id),
agent_slug=agent.slug,
scope=scope.value if scope else str(entry.type),
title=entry.title,
content=entry.content,
timestamp=entry.timestamp,
task_ref=task_ref,
)
)
offset += len(entries)
async def _rebuild_a2a(writer: Any, db: Any) -> None:
from sqlalchemy import select
from roboco.db.tables import A2AConversationTable
from roboco.services.a2a import A2AService
from roboco.services.vault_writer import A2AMessageData, TaskLinkRef
a2a_service = A2AService(db)
conv_rows = (await db.execute(select(A2AConversationTable))).scalars().all()
epoch = datetime.min.replace(tzinfo=UTC)
for conv in conv_rows:
messages = await a2a_service.get_messages(conv.id, conv.agent_a, limit=500)
task_ref = TaskLinkRef(id=str(conv.task_id)) if conv.task_id else None
for msg in sorted(messages, key=lambda m: m.created_at or epoch):
writer.append_a2a_message(
A2AMessageData(
conversation_id=str(conv.id),
message_id=str(msg.id),
from_agent=msg.from_agent,
to_agent=(
conv.agent_b if msg.from_agent == conv.agent_a else conv.agent_a
),
content=msg.content,
timestamp=msg.created_at or epoch,
task_ref=task_ref,
)
)
async def _rebuild(vault_root: Path) -> None:
from roboco.db.base import get_db_context
from roboco.services.agent import AgentService
from roboco.services.journal import JournalService
from roboco.services.project import get_project_service
from roboco.services.task import TaskService
from roboco.services.vault_writer import VaultWriter
writer = VaultWriter(vault_root)
async with get_db_context() as db:
agent_service = AgentService(db)
task_service = TaskService(db)
journal_service = JournalService(db)
project_service = get_project_service(db)
agents = await _rebuild_agents(writer, agent_service)
await _rebuild_tasks(writer, task_service, project_service)
await _rebuild_journals(writer, journal_service, agents)
await _rebuild_a2a(writer, db)
ensure_vault_assets(vault_root)
def _relocate(new_path: Path) -> int:
"""Move the vault to ``new_path``; 0 on success, 1 on refusal.
An EXISTING destination is a personal vault: graft only the ``RoboCo/``
subtree into it (``shutil.move`` of the whole root would nest the old
dirname inside it) and materialize the shipped ``.obsidian``/``_meta``
assets only where absent never clobbering the vault's own config. An
absent destination gets the whole-tree move.
"""
old_root = Path(settings.vault_path)
if not old_root.exists() or old_root == new_path:
new_path.mkdir(parents=True, exist_ok=True)
elif new_path.exists():
dest_tree = new_path / "RoboCo"
if dest_tree.exists():
print(
f"Refusing to relocate: {dest_tree} already exists. Move or "
"remove it first.",
file=sys.stderr,
)
return 1
old_tree = old_root / "RoboCo"
if old_tree.exists():
shutil.move(str(old_tree), str(dest_tree))
ensure_vault_assets(new_path)
else:
new_path.parent.mkdir(parents=True, exist_ok=True)
shutil.move(str(old_root), str(new_path))
print(
f"Vault moved to {new_path}. Set ROBOCO_VAULT_PATH={new_path} in the "
"environment for this to persist across restarts."
)
return 0
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(prog="python -m roboco.vault")
subcommands = parser.add_subparsers(dest="command", required=True)
subcommands.add_parser("rebuild", help="full re-projection from the DB")
relocate = subcommands.add_parser("relocate", help="move the vault tree")
relocate.add_argument("new_path", type=Path)
args = parser.parse_args(argv)
if not settings.obsidian_vault_enabled:
print(
"ROBOCO_OBSIDIAN_VAULT_ENABLED is off — nothing to do.",
file=sys.stderr,
)
return 1
if args.command == "rebuild":
asyncio.run(_rebuild(Path(settings.vault_path)))
print(f"Vault rebuilt at {settings.vault_path}")
return 0
return _relocate(args.new_path)
if __name__ == "__main__":
raise SystemExit(main())
+8
View File
@@ -0,0 +1,8 @@
"""Packaged Obsidian-vault template assets (.obsidian/ config + _meta/ dashboards).
Static, non-Python files only. Copied into the live vault by
``roboco.vault.ensure_vault_assets`` (first enable / rebuild); never imported
as code. Kept as a real package (not a loose repo-root dir) so
``importlib.resources`` resolves them regardless of how ``roboco`` is
installed/packaged.
"""
+11
View File
@@ -0,0 +1,11 @@
# RoboCo vault
This vault is a projection of RoboCo's live task/journal/A2A state — read-only from the org's point of view (edits here never flow back into RoboCo; see `docs.roboco.tech` for the full model).
- **Tasks/** — one note per task, grouped by project. Frontmatter carries `status`/`team`/`priority`/`pr`/`parent`/`batch`; the `## Narrative` section is filled in by the Auditor once the task's root completes.
- **Journals/** — one note per journal entry, grouped by agent.
- **A2A/** — one digest note per agent-to-agent conversation thread.
- **Agents/** — an identity hub per agent; Obsidian's backlinks panel collects everything that note links to it.
- **_meta/** — this folder: dashboards and static "commands" (`dashboard.md` for Dataview, `kanban-board.md` for the Kanban plugin).
Regenerate at any time with `python -m roboco.vault rebuild` (safe — a task's Auditor-authored narrative is preserved across a rebuild).
+31
View File
@@ -0,0 +1,31 @@
# Task board (Dataview)
Live queries over `RoboCo/Tasks/**` — requires the Dataview community plugin (already named in `.obsidian/community-plugins.json`; install it via Settings -> Community plugins if it isn't downloaded yet).
## Open work, by status
```dataview
TABLE status, team, priority, pr AS "PR"
FROM "RoboCo/Tasks"
WHERE status != "completed" AND status != "cancelled"
SORT priority ASC, status ASC
```
## Blocked
```dataview
TABLE team, pr AS "PR"
FROM "RoboCo/Tasks"
WHERE status = "blocked"
SORT team ASC
```
## Recently completed
```dataview
TABLE team, pr AS "PR"
FROM "RoboCo/Tasks"
WHERE status = "completed"
SORT file.mtime DESC
LIMIT 20
```
+25
View File
@@ -0,0 +1,25 @@
---
kanban-plugin: basic
---
## Backlog
## In Progress
## Review
## Done
%% kanban:settings
```
{"kanban-plugin":"basic"}
```
%%
@@ -0,0 +1,4 @@
[
"dataview",
"obsidian-kanban"
]
+33
View File
@@ -0,0 +1,33 @@
{
"collapse-filter": true,
"search": "",
"showTags": false,
"showAttachments": false,
"hideUnresolved": false,
"showOrphans": true,
"collapse-color-groups": false,
"colorGroups": [
{ "query": "tag:#team/backend", "color": { "a": 1, "rgb": 2455239 } },
{ "query": "tag:#team/frontend", "color": { "a": 1, "rgb": 16738740 } },
{ "query": "tag:#team/ux_ui", "color": { "a": 1, "rgb": 10181046 } },
{ "query": "tag:#team/main_pm", "color": { "a": 1, "rgb": 16755763 } },
{ "query": "tag:#team/board", "color": { "a": 1, "rgb": 4886754 } },
{ "query": "tag:#status/completed", "color": { "a": 1, "rgb": 3381606 } },
{ "query": "tag:#status/blocked", "color": { "a": 1, "rgb": 15277667 } },
{ "query": "path:RoboCo/Journals", "color": { "a": 1, "rgb": 9807270 } },
{ "query": "path:RoboCo/A2A", "color": { "a": 1, "rgb": 12634495 } },
{ "query": "path:RoboCo/Agents", "color": { "a": 1, "rgb": 6250335 } }
],
"collapse-display": true,
"showArrow": false,
"textFadeMultiplier": 0,
"nodeSizeMultiplier": 1,
"lineSizeMultiplier": 1,
"collapse-forces": true,
"centerStrength": 0.5,
"repelStrength": 10,
"linkStrength": 1,
"linkDistance": 250,
"scale": 1,
"close": false
}
@@ -0,0 +1,34 @@
"""The Obsidian vault projection is gated by default-off config flags
(mirrors the roadmap engine / video engine idiom)."""
from __future__ import annotations
import os
from unittest import mock
from roboco.config import Settings
from roboco.services.settings import FEATURE_FLAGS, validate_setting
def test_obsidian_vault_disabled_by_default() -> None:
s = Settings()
assert s.obsidian_vault_enabled is False
assert s.vault_path == "/data/vault"
def test_obsidian_vault_reads_env_var() -> None:
with mock.patch.dict(os.environ, {"ROBOCO_OBSIDIAN_VAULT_ENABLED": "true"}):
assert Settings().obsidian_vault_enabled is True
def test_vault_path_reads_env_var() -> None:
with mock.patch.dict(os.environ, {"ROBOCO_VAULT_PATH": "/mnt/my-vault"}):
assert Settings().vault_path == "/mnt/my-vault"
def test_obsidian_vault_flag_registered_in_feature_flags() -> None:
assert "obsidian_vault_enabled" in [key for key, _ in FEATURE_FLAGS]
def test_obsidian_vault_flag_validates_as_bool() -> None:
validate_setting("obsidian_vault_enabled", "true")
@@ -0,0 +1,41 @@
"""Vault intake watcher — gated by default-off config flags (mirrors the
roadmap engine / X engine idiom)."""
from __future__ import annotations
import os
from unittest import mock
from roboco.config import Settings
from roboco.services.settings import FEATURE_FLAGS, validate_setting
_DEFAULT_INTERVAL = 300
_DEFAULT_MAX_PER_CYCLE = 3
_DEFAULT_MAX_OPEN_DRAFTS = 10
def test_vault_intake_disabled_by_default() -> None:
s = Settings()
assert s.vault_intake_enabled is False
assert s.vault_intake_interval_seconds == _DEFAULT_INTERVAL
assert s.vault_intake_dir == "RoboCo/Inbox"
assert s.vault_intake_max_per_cycle == _DEFAULT_MAX_PER_CYCLE
assert s.vault_intake_max_open_drafts == _DEFAULT_MAX_OPEN_DRAFTS
def test_vault_intake_reads_env_var() -> None:
with mock.patch.dict(os.environ, {"ROBOCO_VAULT_INTAKE_ENABLED": "true"}):
assert Settings().vault_intake_enabled is True
def test_vault_intake_dir_reads_env_var() -> None:
with mock.patch.dict(os.environ, {"ROBOCO_VAULT_INTAKE_DIR": "Foo/Bar"}):
assert Settings().vault_intake_dir == "Foo/Bar"
def test_vault_intake_flag_registered_in_feature_flags() -> None:
assert "vault_intake_enabled" in [key for key, _ in FEATURE_FLAGS]
def test_vault_intake_flag_validates_as_bool() -> None:
validate_setting("vault_intake_enabled", "true")
+123
View File
@@ -0,0 +1,123 @@
"""curate_vault content verb — role grant + ContentActions RBAC + flag gating.
Mirrors test_playbook_verbs.py's shape: only the Auditor curates; a
delivery role is refused; the flag off short-circuits before any write.
"""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import uuid4
import pytest
from roboco.config import settings
from roboco.services.gateway.content_actions import ContentActions, ContentActionsDeps
from roboco.services.gateway.role_config import get_role_config
def _actions(role: str) -> ContentActions:
task = MagicMock()
agent = MagicMock()
agent.role = role
task.agent_for = AsyncMock(return_value=agent)
task.session = AsyncMock()
deps = ContentActionsDeps(
task=task,
git=MagicMock(),
a2a=MagicMock(),
journal=MagicMock(),
workspace=MagicMock(),
notifications=MagicMock(),
)
return ContentActions(deps)
def test_auditor_can_curate_vault() -> None:
assert "curate_vault" in get_role_config("auditor").do_tools
def test_developer_cannot_curate_vault() -> None:
assert "curate_vault" not in get_role_config("developer").do_tools
@pytest.mark.asyncio
async def test_curate_vault_forbidden_for_developer(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(settings, "obsidian_vault_enabled", True)
env = await _actions("developer").curate_vault(
agent_id=uuid4(), task_id=uuid4(), narrative="did stuff"
)
assert env.error == "not_authorized"
@pytest.mark.asyncio
async def test_curate_vault_disabled_when_flag_off(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(settings, "obsidian_vault_enabled", False)
env = await _actions("auditor").curate_vault(
agent_id=uuid4(), task_id=uuid4(), narrative="did stuff"
)
assert env.error == "invalid_state"
@pytest.mark.asyncio
async def test_curate_vault_not_found(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "obsidian_vault_enabled", True)
actions = _actions("auditor")
actions.task.get = AsyncMock(return_value=None)
env = await actions.curate_vault(
agent_id=uuid4(), task_id=uuid4(), narrative="did stuff"
)
assert env.error == "not_found"
@pytest.mark.asyncio
async def test_curate_vault_writes_narrative_for_auditor(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(settings, "obsidian_vault_enabled", True)
actions = _actions("auditor")
task_id = uuid4()
task = MagicMock()
actions.task.get = AsyncMock(return_value=task)
writer = MagicMock()
data = MagicMock()
with (
patch(
"roboco.services.vault_assembly.assemble_task_note_data",
AsyncMock(return_value=data),
) as assemble,
patch("roboco.services.vault_writer.get_vault_writer", return_value=writer),
patch("roboco.services.project.get_project_service"),
):
env = await actions.curate_vault(
agent_id=uuid4(), task_id=task_id, narrative="Shipped after one rework."
)
assert env.error is None
assert env.status == "vault_curated"
assemble.assert_awaited_once()
assert assemble.await_args is not None
assert assemble.await_args.kwargs["narrative"] == "Shipped after one rework."
writer.write_task.assert_called_once_with(data)
@pytest.mark.asyncio
async def test_curate_vault_write_failure_returns_invalid_state(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(settings, "obsidian_vault_enabled", True)
actions = _actions("auditor")
actions.task.get = AsyncMock(return_value=MagicMock())
with (
patch(
"roboco.services.vault_assembly.assemble_task_note_data",
AsyncMock(side_effect=OSError("disk full")),
),
patch("roboco.services.project.get_project_service"),
):
env = await actions.curate_vault(
agent_id=uuid4(), task_id=uuid4(), narrative="x"
)
assert env.error == "invalid_state"
@@ -55,6 +55,7 @@ def _make_orchestrator() -> AgentOrchestrator:
"_roadmap_engine_task",
"_x_feature_spotlight_task",
"_video_render_task",
"_vault_intake_task",
):
setattr(orch, attr, None)
return orch
@@ -0,0 +1,75 @@
"""Obsidian-vault root-completion Auditor spawn: gated on the flag, one-shot
per task (mirrors _dispatch_board_reviewer's guard shape), spawned WITHOUT a
bound task_id (mirrors _dispatch_audit_work's alert spawn — a `completed`
task_id would trip the readiness gate's role-for-status check).
"""
from __future__ import annotations
from typing import Any, cast
from unittest.mock import AsyncMock, patch
from uuid import uuid4
import pytest
from roboco.config import settings
from roboco.runtime.orchestrator import AgentOrchestrator
def _make_orch() -> AgentOrchestrator:
orch = AgentOrchestrator.__new__(AgentOrchestrator)
orch._instances = {}
orch._board_dispatched = set()
return orch
@pytest.mark.asyncio
async def test_dispatch_noop_when_flag_off(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "obsidian_vault_enabled", False)
orch = _make_orch()
with patch("roboco.db.base.get_db_context") as get_ctx:
await orch._dispatch_vault_curation_work(cast("Any", object()))
get_ctx.assert_not_called()
@pytest.mark.asyncio
async def test_maybe_spawn_vault_curation_spawns_auditor_without_task_id() -> None:
orch = _make_orch()
task_id = str(uuid4())
with (
patch.object(orch, "_is_agent_active", return_value=False),
patch.object(orch, "_mark_vault_curation_dispatched", new=AsyncMock()),
patch.object(orch, "spawn_agent", new=AsyncMock()) as spawn,
):
await orch._maybe_spawn_vault_curation(task_id, "A completed root")
spawn.assert_awaited_once()
assert spawn.await_args is not None
assert spawn.await_args.kwargs["agent_id"] == "auditor"
assert "task_id" not in spawn.await_args.kwargs
assert task_id in spawn.await_args.kwargs["initial_prompt"]
@pytest.mark.asyncio
async def test_maybe_spawn_vault_curation_one_shot() -> None:
orch = _make_orch()
task_id = str(uuid4())
with (
patch.object(orch, "_is_agent_active", return_value=False),
patch.object(orch, "_mark_vault_curation_dispatched", new=AsyncMock()),
patch.object(orch, "spawn_agent", new=AsyncMock()) as spawn,
):
await orch._maybe_spawn_vault_curation(task_id, "A completed root")
await orch._maybe_spawn_vault_curation(task_id, "A completed root")
spawn.assert_awaited_once()
@pytest.mark.asyncio
async def test_maybe_spawn_vault_curation_skips_when_auditor_active() -> None:
orch = _make_orch()
task_id = str(uuid4())
with (
patch.object(orch, "_is_agent_active", return_value=True),
patch.object(orch, "spawn_agent", new=AsyncMock()) as spawn,
):
await orch._maybe_spawn_vault_curation(task_id, "A completed root")
spawn.assert_not_awaited()
assert ("auditor", task_id) not in orch._board_dispatched
@@ -0,0 +1,47 @@
"""The vault-intake orchestrator loop is fully dormant unless BOTH the vault
AND intake flags are on (default).
With either flag off, ``_vault_intake_loop`` must return immediately no
sleep, no DB, no filesystem scan so a standard deployment behaves exactly
as today.
"""
from __future__ import annotations
import asyncio
import types
from typing import cast
import pytest
from roboco.config import settings as cfg
from roboco.runtime.orchestrator import AgentOrchestrator
@pytest.mark.asyncio
async def test_vault_intake_loop_returns_immediately_when_both_flags_off(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(cfg, "obsidian_vault_enabled", False)
monkeypatch.setattr(cfg, "vault_intake_enabled", False)
stub = cast("AgentOrchestrator", types.SimpleNamespace(_running=True))
await asyncio.wait_for(AgentOrchestrator._vault_intake_loop(stub), timeout=1.0)
@pytest.mark.asyncio
async def test_vault_intake_loop_returns_immediately_when_intake_flag_off(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(cfg, "obsidian_vault_enabled", True)
monkeypatch.setattr(cfg, "vault_intake_enabled", False)
stub = cast("AgentOrchestrator", types.SimpleNamespace(_running=True))
await asyncio.wait_for(AgentOrchestrator._vault_intake_loop(stub), timeout=1.0)
@pytest.mark.asyncio
async def test_vault_intake_loop_returns_immediately_when_vault_flag_off(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(cfg, "obsidian_vault_enabled", False)
monkeypatch.setattr(cfg, "vault_intake_enabled", True)
stub = cast("AgentOrchestrator", types.SimpleNamespace(_running=True))
await asyncio.wait_for(AgentOrchestrator._vault_intake_loop(stub), timeout=1.0)
@@ -0,0 +1,439 @@
"""Vault intake watcher: #roboco-tagged notes become board-review drafts.
Mirrors the X-engine / roadmap-engine test shape. The engine opens a PENDING,
Product-Owner-assigned, team=board draft (source=vault_note) the intake
"Board review & Start" shape. Nothing enters delivery until the CEO's
approve_and_start: the dispatch tests prove the draft routes ONLY to the
board review, and the cap tests prove approval/cancellation free the cap.
Asserted against a real Postgres DB.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any, cast
from unittest.mock import AsyncMock, MagicMock
import pytest
from roboco.config import settings as cfg
from roboco.db.tables import AgentTable, ProjectTable, VaultSeenNoteTable
from roboco.foundation import identity as _foundation
from roboco.models.base import (
AgentRole,
AgentStatus,
TaskType,
Team,
)
from roboco.models.base import TaskStatus as TS
from roboco.runtime.orchestrator import AgentOrchestrator, _is_held_ceo_source
from roboco.services import vault_intake_engine as vie_module
from roboco.services.task import VAULT_NOTE_SOURCE, get_task_service
from roboco.services.vault_intake_engine import VaultIntakeEngine
from sqlalchemy import select
if TYPE_CHECKING:
from pathlib import Path
from sqlalchemy.ext.asyncio import AsyncSession
SYSTEM_UUID = _foundation.AGENTS["system"].uuid
PO_UUID = _foundation.AGENTS["product-owner"].uuid
MAIN_PM_UUID = _foundation.AGENTS["main-pm"].uuid
SLUG = "roboco"
ONE = 1
TWO = 2
async def _seed(session: AsyncSession) -> None:
for uuid, slug, role, team in (
(SYSTEM_UUID, "system", AgentRole.SYSTEM, None),
(PO_UUID, "product-owner", AgentRole.PRODUCT_OWNER, Team.BOARD),
(MAIN_PM_UUID, "main-pm", AgentRole.MAIN_PM, Team.MAIN_PM),
):
if await session.get(AgentTable, uuid) is None:
session.add(
AgentTable(
id=uuid,
name=slug,
slug=slug,
role=role,
team=team,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="x",
capabilities=[],
permissions={},
metrics={},
)
)
await session.flush()
session.add(
ProjectTable(
name="RoboCo",
slug=SLUG,
git_url="https://github.com/x/roboco.git",
default_branch="master",
protected_branches=["master"],
assigned_cell=Team.BACKEND,
created_by=SYSTEM_UUID,
is_active=True,
)
)
await session.flush()
def _enable(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, **overrides: object
) -> Path:
monkeypatch.setattr(cfg, "obsidian_vault_enabled", True)
monkeypatch.setattr(cfg, "vault_intake_enabled", True)
monkeypatch.setattr(cfg, "self_heal_project_slug", SLUG)
monkeypatch.setattr(cfg, "vault_path", str(tmp_path))
monkeypatch.setattr(cfg, "vault_intake_dir", "RoboCo/Inbox")
monkeypatch.setattr(cfg, "vault_intake_max_per_cycle", 3)
monkeypatch.setattr(cfg, "vault_intake_max_open_drafts", 10)
for key, value in overrides.items():
monkeypatch.setattr(cfg, key, value)
inbox = tmp_path / "RoboCo" / "Inbox"
inbox.mkdir(parents=True)
return inbox
def _mock_local_model(monkeypatch: pytest.MonkeyPatch, reply: str | None) -> AsyncMock:
mock = AsyncMock(return_value=reply)
monkeypatch.setattr(vie_module, "_chat", mock)
return mock
def _write(inbox: Path, name: str, content: str) -> Path:
path = inbox / name
path.write_text(content, encoding="utf-8")
return path
_FRONTMATTER_TAGGED = "---\ntags: [roboco]\n---\n\n# Buy milk\n\nGet 2% milk.\n"
_INLINE_TAGGED = "# Fix the fence\n\n#roboco the fence is leaning.\n"
_UNTAGGED = "# Just a note\n\nNothing to see here.\n"
_WITH_CHECKBOXES = (
"---\ntags: [roboco]\n---\n\n# Weekend chores\n\n"
"- [ ] Mow the lawn\n- [ ] Wash the car\n"
)
# --------------------------------------------------------------------------- #
# Tag detection
# --------------------------------------------------------------------------- #
@pytest.mark.asyncio
async def test_frontmatter_tag_is_processed(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
await _seed(db_session)
inbox = _enable(monkeypatch, tmp_path)
_write(inbox, "a.md", _FRONTMATTER_TAGGED)
_mock_local_model(monkeypatch, None)
drafts = await VaultIntakeEngine(db_session).run_cycle()
assert len(drafts) == ONE
@pytest.mark.asyncio
async def test_inline_tag_is_processed(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
await _seed(db_session)
inbox = _enable(monkeypatch, tmp_path)
_write(inbox, "b.md", _INLINE_TAGGED)
_mock_local_model(monkeypatch, None)
drafts = await VaultIntakeEngine(db_session).run_cycle()
assert len(drafts) == ONE
@pytest.mark.asyncio
async def test_untagged_note_is_ignored(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
await _seed(db_session)
inbox = _enable(monkeypatch, tmp_path)
_write(inbox, "c.md", _UNTAGGED)
_mock_local_model(monkeypatch, None)
drafts = await VaultIntakeEngine(db_session).run_cycle()
assert drafts == []
ledger = (await db_session.execute(select(VaultSeenNoteTable))).scalars().all()
assert ledger == []
# --------------------------------------------------------------------------- #
# Ledger dedup
# --------------------------------------------------------------------------- #
@pytest.mark.asyncio
async def test_unchanged_note_is_never_reprocessed(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
await _seed(db_session)
inbox = _enable(monkeypatch, tmp_path)
_write(inbox, "d.md", _FRONTMATTER_TAGGED)
_mock_local_model(monkeypatch, None)
engine = VaultIntakeEngine(db_session)
first = await engine.run_cycle()
assert len(first) == ONE
second = await engine.run_cycle()
assert second == []
@pytest.mark.asyncio
async def test_edited_note_is_eligible_again(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
await _seed(db_session)
inbox = _enable(monkeypatch, tmp_path)
path = _write(inbox, "e.md", _FRONTMATTER_TAGGED)
_mock_local_model(monkeypatch, None)
engine = VaultIntakeEngine(db_session)
first = await engine.run_cycle()
assert len(first) == ONE
# Edit the note's real content (not just appending the callout) — a new
# ledger key, so it's eligible again.
path.write_text(
path.read_text(encoding="utf-8") + "\nAlso get some bread.\n",
encoding="utf-8",
)
second = await engine.run_cycle()
assert len(second) == ONE
# --------------------------------------------------------------------------- #
# Caps
# --------------------------------------------------------------------------- #
@pytest.mark.asyncio
async def test_max_per_cycle_cap(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
await _seed(db_session)
inbox = _enable(monkeypatch, tmp_path, vault_intake_max_per_cycle=2)
for i in range(4):
_write(inbox, f"note{i}.md", _FRONTMATTER_TAGGED)
_mock_local_model(monkeypatch, None)
engine = VaultIntakeEngine(db_session)
first = await engine.run_cycle()
assert len(first) == TWO
second = await engine.run_cycle()
assert len(second) == TWO
third = await engine.run_cycle()
assert third == []
@pytest.mark.asyncio
async def test_open_drafts_cap_skips_cycle(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
await _seed(db_session)
inbox = _enable(monkeypatch, tmp_path, vault_intake_max_open_drafts=1)
_mock_local_model(monkeypatch, None)
engine = VaultIntakeEngine(db_session)
_write(inbox, "first.md", _FRONTMATTER_TAGGED)
assert len(await engine.run_cycle()) == ONE # fills the cap
_write(inbox, "second.md", _INLINE_TAGGED)
drafts = await engine.run_cycle()
assert drafts == []
# The unprocessed note is NOT marked seen — eligible once the cap frees.
ledger = (await db_session.execute(select(VaultSeenNoteTable))).scalars().all()
assert len(ledger) == ONE
@pytest.mark.asyncio
async def test_cap_frees_after_approve_and_start(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""approve_and_start flips team to MAIN_PM — the draft leaves the cap AND
only then enters delivery (assigned to the Main PM)."""
await _seed(db_session)
inbox = _enable(monkeypatch, tmp_path, vault_intake_max_open_drafts=1)
_mock_local_model(monkeypatch, None)
engine = VaultIntakeEngine(db_session)
_write(inbox, "a.md", _FRONTMATTER_TAGGED)
task = (await engine.run_cycle())[0]
task_svc = get_task_service(db_session)
assert len(await task_svc.list_open_vault_note_drafts()) == ONE
task.board_review_complete = True # both reviewers done (server-side gate)
approved = await task_svc.approve_and_start(cast("Any", task.id))
assert approved is not None
assert approved.team == Team.MAIN_PM
assert approved.assigned_to == MAIN_PM_UUID # delivery starts HERE, not before
assert await task_svc.list_open_vault_note_drafts() == []
_write(inbox, "b.md", _INLINE_TAGGED)
assert len(await engine.run_cycle()) == ONE # cap freed
@pytest.mark.asyncio
async def test_cap_frees_after_cancellation(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
await _seed(db_session)
inbox = _enable(monkeypatch, tmp_path, vault_intake_max_open_drafts=1)
_mock_local_model(monkeypatch, None)
engine = VaultIntakeEngine(db_session)
_write(inbox, "a.md", _FRONTMATTER_TAGGED)
task = (await engine.run_cycle())[0]
task_svc = get_task_service(db_session)
assert len(await task_svc.list_open_vault_note_drafts()) == ONE
task.status = TS.CANCELLED
await db_session.flush()
assert await task_svc.list_open_vault_note_drafts() == []
_write(inbox, "b.md", _INLINE_TAGGED)
assert len(await engine.run_cycle()) == ONE # cap freed
# --------------------------------------------------------------------------- #
# Board-draft shape + dispatch routing (never auto-starts)
# --------------------------------------------------------------------------- #
@pytest.mark.asyncio
async def test_board_draft_shape(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
await _seed(db_session)
inbox = _enable(monkeypatch, tmp_path)
_write(inbox, "g.md", _FRONTMATTER_TAGGED)
_mock_local_model(monkeypatch, None)
drafts = await VaultIntakeEngine(db_session).run_cycle()
task = drafts[0]
assert task.status == TS.PENDING
assert task.source == VAULT_NOTE_SOURCE
assert task.assigned_to == PO_UUID
assert task.team == Team.BOARD
assert task.task_type == TaskType.PLANNING # Main PM never owns code
# Board-routed intake shape: confirmed like a chat-confirmed draft — the
# board assignment + approve_and_start are the start gate, not this flag.
assert task.confirmed_by_human is True
def _vault_task_dict(**overrides: Any) -> dict[str, Any]:
task: dict[str, Any] = {
"id": "11111111-2222-3333-4444-555555555555",
"status": "pending",
"team": "board",
"title": "Vault note: buy milk",
"assigned_to": str(PO_UUID),
"source": VAULT_NOTE_SOURCE,
"orchestration_markers": None,
}
task.update(overrides)
return task
def test_vault_note_is_not_a_held_ceo_source() -> None:
"""The draft must DISPATCH (to board review) — a held-source skip would
strand it forever (no role can pull it, no review route exists)."""
assert _is_held_ceo_source(_vault_task_dict()) is False
@pytest.mark.asyncio
async def test_dispatch_pm_work_routes_vault_draft_to_board_review_only() -> None:
"""The PM dispatcher must hand a vault draft to the two-reviewer board
review and NOTHING else no PM spawn, no delivery routing."""
stub = MagicMock()
stub._fetch_tasks = AsyncMock(return_value=[_vault_task_dict()])
stub._is_task_handled_this_tick = MagicMock(return_value=False)
stub._resolve_agent_slug = MagicMock(return_value="product-owner")
stub._BOARD_AGENTS = frozenset({"product-owner", "head-marketing"})
stub._handle_board_assigned_task = AsyncMock()
stub._handle_pm_assigned_task = AsyncMock()
stub._route_unassigned_pm_task = AsyncMock()
client: Any = MagicMock()
await AgentOrchestrator._dispatch_pm_work(cast("AgentOrchestrator", stub), client)
stub._handle_board_assigned_task.assert_awaited_once()
stub._handle_pm_assigned_task.assert_not_awaited()
stub._route_unassigned_pm_task.assert_not_awaited()
@pytest.mark.asyncio
async def test_vault_draft_never_dev_dispatched() -> None:
"""team=board fails _dev_dispatch_one's cell-team gate — a vault draft can
never spawn a developer, with or without any source-based skip."""
stub = MagicMock()
stub._spawn_pending_dev = AsyncMock()
stub._handle_dev_existing_owner = AsyncMock()
client: Any = MagicMock()
await AgentOrchestrator._dev_dispatch_one(
cast("AgentOrchestrator", stub), client, _vault_task_dict()
)
stub._spawn_pending_dev.assert_not_awaited()
stub._handle_dev_existing_owner.assert_not_awaited()
# --------------------------------------------------------------------------- #
# Local-model fallback
# --------------------------------------------------------------------------- #
@pytest.mark.asyncio
async def test_local_model_failure_falls_back_to_deterministic(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
await _seed(db_session)
inbox = _enable(monkeypatch, tmp_path)
_write(inbox, "h.md", _WITH_CHECKBOXES)
monkeypatch.setattr(
vie_module, "_chat", AsyncMock(side_effect=RuntimeError("local model down"))
)
drafts = await VaultIntakeEngine(db_session).run_cycle()
task = drafts[0]
assert "Weekend chores" in task.title
assert "Mow the lawn" in task.acceptance_criteria
assert "Wash the car" in task.acceptance_criteria
@pytest.mark.asyncio
async def test_local_model_success_is_used(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
await _seed(db_session)
inbox = _enable(monkeypatch, tmp_path)
_write(inbox, "i.md", _FRONTMATTER_TAGGED)
_mock_local_model(
monkeypatch,
'{"title": "Milk run", "description": "Pick up milk on the way home.", '
'"action_items": ["Buy 2% milk"]}',
)
drafts = await VaultIntakeEngine(db_session).run_cycle()
task = drafts[0]
assert "Milk run" in task.title
assert task.description == "Pick up milk on the way home."
assert task.acceptance_criteria == ["Buy 2% milk"]
# --------------------------------------------------------------------------- #
# Feedback callout
# --------------------------------------------------------------------------- #
@pytest.mark.asyncio
async def test_feedback_callout_appended_once(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
await _seed(db_session)
inbox = _enable(monkeypatch, tmp_path)
path = _write(inbox, "j.md", _FRONTMATTER_TAGGED)
_mock_local_model(monkeypatch, None)
engine = VaultIntakeEngine(db_session)
await engine.run_cycle()
text = path.read_text(encoding="utf-8")
assert text.count("RoboCo: drafted") == ONE
# A second cycle over the now-callout-bearing (but otherwise unchanged)
# note must not reprocess it or double the callout.
second = await engine.run_cycle()
assert second == []
assert path.read_text(encoding="utf-8").count("RoboCo: drafted") == ONE
+187
View File
@@ -0,0 +1,187 @@
"""Vault event seams (journal write / A2A send / task status transition):
best-effort isolation. A raised VaultWriter error must NEVER fail the
underlying verb; the flag off must short-circuit before any writer call;
``is_private`` journal entries must be excluded (same rule as the RAG corpus).
"""
from __future__ import annotations
from datetime import UTC, datetime
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import uuid4
import pytest
from roboco.config import settings
from roboco.models.base import JournalEntryType
from roboco.services.a2a import A2AService
from roboco.services.journal import JournalService
from roboco.services.task import TaskService
def _entry_row(*, is_private: bool = False, task_id: object | None = None) -> MagicMock:
row = MagicMock()
row.id = uuid4()
row.task_id = task_id
row.title = "Some entry"
row.content = "Body text"
row.timestamp = datetime.now(UTC)
row.type = JournalEntryType.GENERAL
row.is_private = is_private
return row
# --- journal seam --------------------------------------------------------- #
@pytest.mark.asyncio
async def test_journal_seam_noop_when_flag_off(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "obsidian_vault_enabled", False)
svc = JournalService(MagicMock())
monkeypatch.setattr(svc, "get_agent_slug", AsyncMock(return_value="be-dev-1"))
with patch("roboco.services.vault_writer.get_vault_writer") as get_writer:
await svc._materialize_vault_note(_entry_row(), uuid4(), is_private=False)
get_writer.assert_not_called()
@pytest.mark.asyncio
async def test_journal_seam_excludes_private_entries(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Mirrors the RAG-index exclusion: a private entry never reaches the vault."""
monkeypatch.setattr(settings, "obsidian_vault_enabled", True)
svc = JournalService(MagicMock())
monkeypatch.setattr(svc, "get_agent_slug", AsyncMock(return_value="be-dev-1"))
with patch("roboco.services.vault_writer.get_vault_writer") as get_writer:
await svc._materialize_vault_note(
_entry_row(is_private=True), uuid4(), is_private=True
)
get_writer.assert_not_called()
@pytest.mark.asyncio
async def test_journal_seam_writer_failure_does_not_raise(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(settings, "obsidian_vault_enabled", True)
svc = JournalService(MagicMock())
monkeypatch.setattr(svc, "get_agent_slug", AsyncMock(return_value="be-dev-1"))
writer = MagicMock()
writer.write_journal_entry.side_effect = OSError("disk full")
with patch("roboco.services.vault_writer.get_vault_writer", return_value=writer):
await svc._materialize_vault_note(_entry_row(), uuid4(), is_private=False)
writer.write_journal_entry.assert_called_once()
@pytest.mark.asyncio
async def test_journal_seam_writes_when_enabled_and_public(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(settings, "obsidian_vault_enabled", True)
svc = JournalService(MagicMock())
monkeypatch.setattr(svc, "get_agent_slug", AsyncMock(return_value="be-dev-1"))
writer = MagicMock()
with patch("roboco.services.vault_writer.get_vault_writer", return_value=writer):
await svc._materialize_vault_note(_entry_row(), uuid4(), is_private=False)
writer.write_journal_entry.assert_called_once()
# --- A2A seam --------------------------------------------------------------- #
def _a2a_msg() -> MagicMock:
msg = MagicMock()
msg.id = uuid4()
msg.content = "hello"
msg.created_at = datetime.now(UTC)
return msg
def _a2a_conv(task_id: object | None = None) -> MagicMock:
conv = MagicMock()
conv.id = uuid4()
conv.task_id = task_id
return conv
@pytest.mark.asyncio
async def test_a2a_seam_noop_when_flag_off(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "obsidian_vault_enabled", False)
with patch("roboco.services.vault_writer.get_vault_writer") as get_writer:
await A2AService._materialize_vault_note(
_a2a_msg(), _a2a_conv(), "be-dev-1", "be-pm"
)
get_writer.assert_not_called()
@pytest.mark.asyncio
async def test_a2a_seam_writer_failure_does_not_raise(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(settings, "obsidian_vault_enabled", True)
writer = MagicMock()
writer.append_a2a_message.side_effect = RuntimeError("boom")
with patch("roboco.services.vault_writer.get_vault_writer", return_value=writer):
await A2AService._materialize_vault_note(
_a2a_msg(), _a2a_conv(), "be-dev-1", "be-pm"
)
writer.append_a2a_message.assert_called_once()
# --- task status-transition seam -------------------------------------------- #
def _task_row() -> MagicMock:
task = MagicMock()
task.id = uuid4()
task.pr_number = None
task.pr_url = None
return task
def test_task_transition_seam_noop_when_flag_off(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(settings, "obsidian_vault_enabled", False)
svc = TaskService.__new__(TaskService)
svc.log = MagicMock()
with patch("roboco.services.vault_writer.get_vault_writer") as get_writer:
svc._touch_vault_frontmatter(
_task_row(), to_status="in_progress", team="backend"
)
get_writer.assert_not_called()
def test_task_transition_seam_writer_failure_does_not_raise(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(settings, "obsidian_vault_enabled", True)
svc = TaskService.__new__(TaskService)
svc.log = MagicMock()
writer = MagicMock()
writer.touch_task_frontmatter.side_effect = OSError("nope")
with patch("roboco.services.vault_writer.get_vault_writer", return_value=writer):
svc._touch_vault_frontmatter(
_task_row(), to_status="in_progress", team="backend"
)
writer.touch_task_frontmatter.assert_called_once()
def test_task_transition_seam_touches_status_team_pr(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(settings, "obsidian_vault_enabled", True)
svc = TaskService.__new__(TaskService)
svc.log = MagicMock()
task = _task_row()
task.pr_number = 7
task.pr_url = "https://github.com/x/y/pull/7"
writer = MagicMock()
with patch("roboco.services.vault_writer.get_vault_writer", return_value=writer):
svc._touch_vault_frontmatter(task, to_status="awaiting_qa", team="backend")
writer.touch_task_frontmatter.assert_called_once_with(
task_id=str(task.id),
status="awaiting_qa",
team="backend",
pr_number=7,
pr_url="https://github.com/x/y/pull/7",
)
+235
View File
@@ -0,0 +1,235 @@
"""VaultWriter — pure materializer: layout, idempotency, wikilink shape.
No DB, no flag checks (those are the seam callers' job) — just entity ->
markdown, given a tmp_path vault root.
"""
from __future__ import annotations
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any
import yaml
from roboco.services.vault_writer import (
A2AMessageData,
AgentNoteData,
JournalNoteData,
TaskLinkRef,
TaskNoteData,
VaultWriter,
)
if TYPE_CHECKING:
from pathlib import Path
_PRIORITY = 2
def _task_data(**overrides: Any) -> TaskNoteData:
base: dict[str, Any] = {
"id": "11112222-3333-4444-5555-666677778888",
"title": "Add user authentication endpoint",
"project_slug": "roboco-api",
"description": "Implement the login endpoint.",
"status": "in_progress",
"team": "backend",
"priority": _PRIORITY,
"task_type": "code",
}
base.update(overrides)
return TaskNoteData(**base)
# --- layout ------------------------------------------------------------- #
def test_write_task_layout_and_frontmatter(tmp_path: Path) -> None:
writer = VaultWriter(tmp_path)
path = writer.write_task(_task_data())
assert path == (
tmp_path
/ "RoboCo"
/ "Tasks"
/ "roboco-api"
/ "Add user authentication endpoint (11112222).md"
)
text = path.read_text(encoding="utf-8")
assert text.startswith("---\n")
fm, _, _ = text.removeprefix("---\n").partition("\n---\n")
frontmatter = yaml.safe_load(fm)
assert frontmatter["aliases"] == ["11112222"]
assert frontmatter["status"] == "in_progress"
assert frontmatter["team"] == "backend"
assert frontmatter["priority"] == _PRIORITY
assert "# Add user authentication endpoint" in text
assert "Implement the login endpoint." in text
assert "## Narrative" in text
assert "_Pending Auditor curation._" in text
def test_write_journal_entry_layout(tmp_path: Path) -> None:
writer = VaultWriter(tmp_path)
path = writer.write_journal_entry(
JournalNoteData(
entry_id="aaaa1111-0000-0000-0000-000000000000",
agent_slug="be-dev-1",
scope="learning",
title="Retry flaky pg connections",
content="Backoff worked.",
timestamp=datetime(2026, 7, 10, tzinfo=UTC),
)
)
assert path == (
tmp_path
/ "RoboCo"
/ "Journals"
/ "be-dev-1"
/ "2026-07-10 Retry flaky pg connections (aaaa1111).md"
)
text = path.read_text(encoding="utf-8")
assert "agent: be-dev-1" in text
assert "scope: learning" in text
assert "Backoff worked." in text
def test_write_agent_layout(tmp_path: Path) -> None:
writer = VaultWriter(tmp_path)
path = writer.write_agent(
AgentNoteData(
slug="be-dev-1", name="BE Dev 1", role="developer", team="backend"
)
)
assert path == tmp_path / "RoboCo" / "Agents" / "be-dev-1.md"
text = path.read_text(encoding="utf-8")
assert "role: developer" in text
assert "BE Dev 1" in text
# --- idempotency / rename stability -------------------------------------- #
def test_write_task_idempotent_same_input(tmp_path: Path) -> None:
writer = VaultWriter(tmp_path)
p1 = writer.write_task(_task_data())
p2 = writer.write_task(_task_data())
assert p1 == p2
assert p1.read_text(encoding="utf-8") == p2.read_text(encoding="utf-8")
def test_write_task_title_rename_keeps_filename(tmp_path: Path) -> None:
"""A rename updates the title line, not the filename (link stability)."""
writer = VaultWriter(tmp_path)
original = writer.write_task(_task_data())
renamed = writer.write_task(_task_data(title="Add auth endpoint (renamed)"))
assert original == renamed
text = renamed.read_text(encoding="utf-8")
assert "# Add auth endpoint (renamed)" in text
def test_append_a2a_message_idempotent_per_message_id(tmp_path: Path) -> None:
writer = VaultWriter(tmp_path)
data = A2AMessageData(
conversation_id="cccc0000-0000-0000-0000-000000000000",
message_id="msg-0001",
from_agent="be-dev-1",
to_agent="be-pm",
content="ping",
timestamp=datetime(2026, 7, 10, 9, 0, 0, tzinfo=UTC),
)
path1 = writer.append_a2a_message(data)
path2 = writer.append_a2a_message(data) # same message id — retry
assert path1 == path2
text = path1.read_text(encoding="utf-8")
assert text.count("<!-- msg:msg-0001 -->") == 1
# --- wikilink shape ------------------------------------------------------- #
def test_task_wikilinks_include_parent_subtasks_dependencies(tmp_path: Path) -> None:
writer = VaultWriter(tmp_path)
parent = TaskLinkRef(id="99998888-0000-0000-0000-000000000000", title="Parent task")
sub = TaskLinkRef(id="77776666-0000-0000-0000-000000000000", title="Subtask one")
dep = TaskLinkRef(id="55554444-0000-0000-0000-000000000000", title="Dep task")
path = writer.write_task(
_task_data(parent=parent, subtasks=(sub,), dependencies=(dep,))
)
text = path.read_text(encoding="utf-8")
assert "[[99998888|Parent task]]" in text
assert "[[77776666|Subtask one]]" in text
assert "[[55554444|Dep task]]" in text
def test_journal_task_ref_link_without_title(tmp_path: Path) -> None:
"""Journal seam links a task without fetching its title (id-only ok)."""
writer = VaultWriter(tmp_path)
task_ref = TaskLinkRef(id="12341234-0000-0000-0000-000000000000")
path = writer.write_journal_entry(
JournalNoteData(
entry_id="bbbb2222-0000-0000-0000-000000000000",
agent_slug="be-dev-1",
scope="note",
title="Quick note",
content="body",
timestamp=datetime(2026, 7, 10, tzinfo=UTC),
task_ref=task_ref,
)
)
text = path.read_text(encoding="utf-8")
assert "[[12341234]]" in text
# --- cheap frontmatter touch ---------------------------------------------- #
def test_touch_task_frontmatter_noop_when_note_missing(tmp_path: Path) -> None:
writer = VaultWriter(tmp_path)
touched = writer.touch_task_frontmatter(
task_id="00001111-0000-0000-0000-000000000000",
status="claimed",
team="backend",
pr_number=None,
pr_url=None,
)
assert touched is False
def test_touch_task_frontmatter_patches_existing_note(tmp_path: Path) -> None:
writer = VaultWriter(tmp_path)
path = writer.write_task(_task_data(status="pending"))
touched = writer.touch_task_frontmatter(
task_id="11112222-3333-4444-5555-666677778888",
status="in_progress",
team="backend",
pr_number=42,
pr_url="https://github.com/x/y/pull/42",
)
assert touched is True
text = path.read_text(encoding="utf-8")
assert "status: in_progress" in text
assert "pr: https://github.com/x/y/pull/42" in text
# body/narrative untouched by the cheap touch
assert "## Narrative" in text
assert "Implement the login endpoint." in text
# --- rebuild-preserves-narrative helper ------------------------------------ #
def test_existing_narrative_none_for_placeholder(tmp_path: Path) -> None:
writer = VaultWriter(tmp_path)
writer.write_task(_task_data())
assert (
writer.existing_narrative("roboco-api", "11112222-3333-4444-5555-666677778888")
is None
)
def test_existing_narrative_preserved_when_curated(tmp_path: Path) -> None:
writer = VaultWriter(tmp_path)
writer.write_task(_task_data(narrative="Shipped cleanly, one rework cycle."))
assert (
writer.existing_narrative("roboco-api", "11112222-3333-4444-5555-666677778888")
== "Shipped cleanly, one rework cycle."
)
+202
View File
@@ -0,0 +1,202 @@
"""``python -m roboco.vault`` — rebuild / relocate, on a tmp vault.
Flag-gated: both subcommands refuse when ROBOCO_OBSIDIAN_VAULT_ENABLED is off.
"""
from __future__ import annotations
from contextlib import asynccontextmanager
from datetime import UTC, datetime
from types import SimpleNamespace
from typing import TYPE_CHECKING, Any
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import uuid4
from roboco.config import settings
from roboco.services.vault_writer import TaskNoteData
from roboco.vault import ensure_vault_assets, main
if TYPE_CHECKING:
from pathlib import Path
import pytest
def test_main_refuses_rebuild_when_flag_off(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "obsidian_vault_enabled", False)
assert main(["rebuild"]) == 1
def test_main_refuses_relocate_when_flag_off(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "obsidian_vault_enabled", False)
assert main(["relocate", "/tmp/somewhere"]) == 1
def test_relocate_moves_existing_tree(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
old_root = tmp_path / "old-vault"
old_root.mkdir()
(old_root / "RoboCo").mkdir()
(old_root / "RoboCo" / "marker.md").write_text("hi", encoding="utf-8")
new_root = tmp_path / "new-vault"
monkeypatch.setattr(settings, "obsidian_vault_enabled", True)
monkeypatch.setattr(settings, "vault_path", str(old_root))
assert main(["relocate", str(new_root)]) == 0
assert not old_root.exists()
assert (new_root / "RoboCo" / "marker.md").read_text(encoding="utf-8") == "hi"
def test_relocate_into_existing_vault_grafts_roboco_subtree(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""An existing destination is a personal vault: only RoboCo/ moves into it
(never nesting the old dirname), the personal .obsidian is never
clobbered, and absent shipped assets are added."""
old_root = tmp_path / "old-vault"
(old_root / "RoboCo").mkdir(parents=True)
(old_root / "RoboCo" / "marker.md").write_text("hi", encoding="utf-8")
personal = tmp_path / "personal-vault"
(personal / ".obsidian").mkdir(parents=True)
(personal / ".obsidian" / "app.json").write_text("personal", encoding="utf-8")
(personal / "My Notes").mkdir()
monkeypatch.setattr(settings, "obsidian_vault_enabled", True)
monkeypatch.setattr(settings, "vault_path", str(old_root))
assert main(["relocate", str(personal)]) == 0
assert (personal / "RoboCo" / "marker.md").read_text(encoding="utf-8") == "hi"
assert not (personal / "old-vault").exists() # no nested old dirname
assert not (old_root / "RoboCo").exists() # subtree moved, not copied
# Personal config untouched; absent shipped assets materialized.
assert (personal / ".obsidian" / "app.json").read_text(
encoding="utf-8"
) == "personal"
assert (personal / ".obsidian" / "community-plugins.json").exists()
assert (personal / "My Notes").exists()
def test_relocate_refuses_when_destination_roboco_exists(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
old_root = tmp_path / "old-vault"
(old_root / "RoboCo").mkdir(parents=True)
(old_root / "RoboCo" / "marker.md").write_text("hi", encoding="utf-8")
personal = tmp_path / "personal-vault"
(personal / "RoboCo").mkdir(parents=True)
monkeypatch.setattr(settings, "obsidian_vault_enabled", True)
monkeypatch.setattr(settings, "vault_path", str(old_root))
assert main(["relocate", str(personal)]) == 1
# Nothing moved on refusal.
assert (old_root / "RoboCo" / "marker.md").exists()
def test_ensure_vault_assets_materializes_templates_idempotently(
tmp_path: Path,
) -> None:
ensure_vault_assets(tmp_path)
plugins = tmp_path / ".obsidian" / "community-plugins.json"
dashboard = tmp_path / "RoboCo" / "_meta" / "dashboard.md"
kanban = tmp_path / "RoboCo" / "_meta" / "kanban-board.md"
assert plugins.exists()
assert dashboard.exists()
assert kanban.exists()
assert "dataview" in plugins.read_text(encoding="utf-8")
# An operator's own edit survives a second call (never overwritten).
dashboard.write_text("operator edit", encoding="utf-8")
ensure_vault_assets(tmp_path)
assert dashboard.read_text(encoding="utf-8") == "operator edit"
def _db_ctx(db: Any) -> Any:
@asynccontextmanager
async def _ctx() -> Any:
yield db
return _ctx
def test_rebuild_writes_agent_task_journal_and_a2a(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""``main()`` drives ``_rebuild`` via ``asyncio.run`` — this test must
stay a plain (non-async) function, or pytest-asyncio's own running loop
collides with it."""
monkeypatch.setattr(settings, "obsidian_vault_enabled", True)
monkeypatch.setattr(settings, "vault_path", str(tmp_path))
agent = MagicMock(
id=uuid4(),
slug="be-dev-1",
name="BE Dev 1",
role="developer",
team=SimpleNamespace(value="backend"),
)
agent_service = MagicMock()
agent_service.list_agents = AsyncMock(return_value=[agent])
task = MagicMock(id=uuid4(), project_id=None)
task_service = MagicMock()
task_service.list_all = AsyncMock(side_effect=[[task], []])
journal = MagicMock(id=uuid4())
entry = MagicMock(
id=uuid4(),
task_id=None,
title="Learned something",
content="body",
timestamp=datetime.now(UTC),
type="learning",
)
journal_service = MagicMock()
journal_service.get_or_create_journal = AsyncMock(return_value=journal)
journal_service.list_entries = AsyncMock(side_effect=[[entry], []])
conv = MagicMock(id=uuid4(), agent_a="be-dev-1", agent_b="be-pm", task_id=None)
conv_result = MagicMock()
conv_result.scalars.return_value.all.return_value = [conv]
db = MagicMock()
db.execute = AsyncMock(return_value=conv_result)
a2a_msg = MagicMock(
id=uuid4(), from_agent="be-dev-1", content="hi", created_at=datetime.now(UTC)
)
a2a_service = MagicMock()
a2a_service.get_messages = AsyncMock(return_value=[a2a_msg])
task_note_data = TaskNoteData(
id=str(task.id),
title="A task",
project_slug="unassigned",
description="desc",
status="completed",
team="backend",
priority=2,
task_type="code",
)
with (
patch("roboco.db.base.get_db_context", _db_ctx(db)),
patch("roboco.services.agent.AgentService", return_value=agent_service),
patch("roboco.services.task.TaskService", return_value=task_service),
patch("roboco.services.journal.JournalService", return_value=journal_service),
patch("roboco.services.project.get_project_service", return_value=MagicMock()),
patch("roboco.services.a2a.A2AService", return_value=a2a_service),
patch(
"roboco.services.vault_assembly.assemble_task_note_data",
AsyncMock(return_value=task_note_data),
),
):
assert main(["rebuild"]) == 0
assert (tmp_path / "RoboCo" / "Agents" / "be-dev-1.md").exists()
assert any((tmp_path / "RoboCo" / "Tasks" / "unassigned").glob("*.md"))
assert any((tmp_path / "RoboCo" / "Journals" / "be-dev-1").glob("*.md"))
assert any((tmp_path / "RoboCo" / "A2A").glob("*.md"))
# asset bootstrap ran too
assert (tmp_path / ".obsidian" / "community-plugins.json").exists()