feat(ux): Consilium->Swarm (plan + fan-out), Deliverables container, project message ordering

3.1 Swarm: rename the Consilium one-shot pill to Swarm and upgrade the injection from plan-only to
plan-AND-fan-out — [SWARM_INITIATIVE] tells the agent to call plan_task to think deeply THEN fan out
subagents (acting where the work needs changes, owner toggle permitting) within the configured
child/worker caps, publishing a shared task-tree frame first when outputs must integrate. Full rename
(button/id/class, marker, force_plan_source='swarm', reason_code, css, tests + static-check fixture);
the ChatInbound.force_plan contract field is preserved (NO GATEWAY_CONTRACT_VERSION bump) and the
vestigial PLAN_PREFIX is removed. Pure prompt injection — no backend mutation.

3.2 Deliverables: a BARE user_files filename (no directory) now lands in the visible
~/Ouroboros/Deliverables/ container (config.get_deliverables_root, OUROBOROS_DELIVERABLES_ROOT)
instead of cluttering the home root; an explicit placement (Desktop/..., Downloads/..., any path with
a directory) is honored under home exactly as given. The container is allowed past the user_files
workspace-overlap guard — it is a sibling of, never an ancestor of, the protected data/repo drives;
the outside-home and credential/hidden-name guards still apply.

3.3 Projects: the mirrored owner request seeding a converted project thread is stamped with the task's
ORIGINAL request timestamp (not 'now') so it sorts to the TOP of the thread, ahead of the working
bubbles, instead of the bottom (history replay sorts by ts). (A concise model-coined title for
direct-chat project cards is a separate follow-up.)

Also extracts run_llm_loop's progress emission into _emit_round_progress to keep the function within
the method-size gate after the narration seam.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ouroboros 2026-06-19 08:55:10 +03:00
parent b5f492f03b
commit 872b0a31a2
17 changed files with 246 additions and 78 deletions

View file

@ -642,6 +642,7 @@ finalization states.
│ │ └── skills/ ← Optional skill/companion runtime logs
│ ├── archive/ ← Rotated logs, rescue snapshots
│ └── uploads/ ← Chat file attachments (uploaded via paperclip button)
├── Deliverables/ ← (v6.38.0) Visible user-deliverables container: a BARE user_files filename (no directory) lands here instead of the home root (OUROBOROS_DELIVERABLES_ROOT; sibling of projects/, outside repo/ and data/, never GC-pruned)
└── ouroboros.pid ← PID lock file (platform lock — auto-released on crash)
```
@ -824,7 +825,7 @@ Rationale: frontend work should not require understanding supervisor, worker, ma
### Chat
`web/modules/chat.js` owns the message timeline, input, attachment staging, input recall, budget pill, runtime controls, and live task cards. It loads persisted history from `/api/chat/history`, merges echoed local messages by `client_message_id`, and collapses task/progress/tool chatter into expandable cards rather than transcript spam. Chat attachments are staged client-side from paperclip, paste, and chat-wide file drag/drop, capped at 10 files, 50 MB per file, and 100 MB total per message; upload happens only immediately before send, attachment messages bypass the offline WebSocket queue, and partial upload/send failures best-effort DELETE already uploaded temporary files while preserving the staged batch for retry. The composer uses a responsive glass layout: desktop keeps Consilium, Low/Max, and Send inside the frosted text-entry surface; mobile lifts Consilium and Low/Max into a compact control row above the textarea while Send stays inside the field. Subagent progress uses separate child cards keyed by `subagent_task_id`/`task_id`; parent cards receive lineage references (`parent_task_id`, `root_task_id`, child id, role) without duplicating child bubbles on reload/reconnect, and nested child cards stay visible but collapsed by default with role-first headings so deep trees remain scannable. Mobile keyboard handling lives in `web/app.js` + CSS `keyboard-open` classes so only the message pane scrolls while the visual viewport changes.
`web/modules/chat.js` owns the message timeline, input, attachment staging, input recall, budget pill, runtime controls, and live task cards. It loads persisted history from `/api/chat/history`, merges echoed local messages by `client_message_id`, and collapses task/progress/tool chatter into expandable cards rather than transcript spam. Chat attachments are staged client-side from paperclip, paste, and chat-wide file drag/drop, capped at 10 files, 50 MB per file, and 100 MB total per message; upload happens only immediately before send, attachment messages bypass the offline WebSocket queue, and partial upload/send failures best-effort DELETE already uploaded temporary files while preserving the staged batch for retry. The composer uses a responsive glass layout: desktop keeps Swarm, Low/Max, and Send inside the frosted text-entry surface; mobile lifts Swarm and Low/Max into a compact control row above the textarea while Send stays inside the field. Subagent progress uses separate child cards keyed by `subagent_task_id`/`task_id`; parent cards receive lineage references (`parent_task_id`, `root_task_id`, child id, role) without duplicating child bubbles on reload/reconnect, and nested child cards stay visible but collapsed by default with role-first headings so deep trees remain scannable. Mobile keyboard handling lives in `web/app.js` + CSS `keyboard-open` classes so only the message pane scrolls while the visual viewport changes.
History sync is intentionally two-pass: progress/system entries are replayed first to build live-card timelines, then regular user/assistant messages call `finishLiveCard`. This prevents `taskState.completed` from being set before progress events apply, which previously discarded thinking-bubble/live-card state.
@ -1452,6 +1453,7 @@ Runtime floors:
| OUROBOROS_ALLOW_MUTATIVE_SUBAGENTS | (empty) | Allow mutative (acting) subagents. Empty = follow runtime mode (ON in advanced/pro, OFF in light); explicit true/false overrides. Owner-controlled. Settings exposes explicit On/Off only; the empty runtime-default state is backend/default behavior, not a third owner-facing mode. |
| OUROBOROS_SUBAGENT_WORKTREE_ROOT | (empty) | Filesystem root for acting self_worktree checkouts; empty = ~/Ouroboros/subagent_worktrees (kept outside repo/ and data/) |
| OUROBOROS_SUBAGENT_PROJECTS_ROOT | (empty) | Durable root for genesis ("from scratch") subagent projects; empty = ~/Ouroboros/projects (outside repo/ and data/). Never age-pruned. |
| OUROBOROS_DELIVERABLES_ROOT | (empty) | (v6.38.0) Visible container for UNNAMED user deliverables; empty = ~/Ouroboros/Deliverables (sibling of the projects root, outside repo/ and data/, never GC-pruned). A BARE `user_files` filename (no directory) lands here instead of cluttering the home root; an explicit placement (`Desktop/…`, `Downloads/…`, any path WITH a directory) is honored under home as given. `user_files_path_block_reason` allows this container past the workspace-overlap guard only while it stays a genuine sibling of (never overlapping/containing) the hard data/repo/budget drives. |
| OUROBOROS_GC_RETENTION_DAYS | 7 | Unified age (days) for startup garbage collection of ALL disposable runtime artifacts: acting worktrees, terminal task drives, and leftover service logs (hard max 365; math SSOT in `ouroboros/retention.py`). Deprecated per-subsystem retention keys are migrated into this on settings load. |
| OUROBOROS_PLAN_TASK_SWARM_TIMEOUT_SEC | 120 | Poll-slice wait for required `plan_task` planning subagents; the wait extends progress-aware up to the max-wait ceiling |
| OUROBOROS_PLAN_TASK_SWARM_MAX_WAIT_SEC | 900 | Generous ceiling for progress-aware planning-swarm waiting; keeps extending while a scout is RUNNING with a fresh heartbeat. Capacity-class endings (`saturated`/`ceiling`, or <2 workers) degrade to ONE inline light-lane critique pass explicitly labeled DEGRADED (v6.30.0); worker-health failures (`stalled`) and infra errors stay fail-closed. Lower values apply as-is; values above the default are clamped to the `plan_task` tool/wrapper budget (raise those module constants to extend the real ceiling). |

View file

@ -772,7 +772,7 @@ preserves scroll stickiness only; it must not mutate DOM padding.
grammar: translucent dark background, subtle border, blur, and bounded radius.
Do not add transparent text-only pills for primary actions.
- Desktop chat composer controls stay inside the single frosted text-entry
surface. On mobile, Consilium and Low/Max move above the textarea so text
surface. On mobile, Swarm and Low/Max move above the textarea so text
width remains usable, while Send stays inside the field.
- Button and segmented-control labels use `letter-spacing: 0` and stable
dimensions. If a label does not fit on mobile, shrink the control group or

View file

@ -102,6 +102,7 @@ SETTINGS_DEFAULTS = {
# outside repo/ and data/). genesis projects are durable and never GC'd.
"OUROBOROS_SUBAGENT_WORKTREE_ROOT": "",
"OUROBOROS_SUBAGENT_PROJECTS_ROOT": "",
"OUROBOROS_DELIVERABLES_ROOT": "",
# Unified age-based GC retention (days) for ALL disposable runtime artifacts:
# subagent worktrees, headless/direct task drives, and leftover service logs.
# Single owner-facing knob (math SSOT in ouroboros/retention.py); deprecated
@ -673,6 +674,18 @@ def get_subagent_projects_root() -> str:
return raw or os.path.expanduser(os.path.join("~", "Ouroboros", "projects"))
def get_deliverables_root() -> str:
"""Visible container for UNNAMED user deliverables: a bare filename (no directory) lands here
instead of cluttering the home root. Sibling of the genesis projects root under ~/Ouroboros,
outside data/, and never GC-pruned. An explicit placement (Desktop/..., Downloads/..., or any
path WITH a directory) is always honored as given. Override with OUROBOROS_DELIVERABLES_ROOT."""
raw = str(
os.environ.get("OUROBOROS_DELIVERABLES_ROOT", "")
or SETTINGS_DEFAULTS.get("OUROBOROS_DELIVERABLES_ROOT", "")
).strip()
return raw or os.path.expanduser(os.path.join("~", "Ouroboros", "Deliverables"))
def get_task_review_mode() -> str:
default_val = str(SETTINGS_DEFAULTS["OUROBOROS_TASK_REVIEW_MODE"])
raw = (os.environ.get("OUROBOROS_TASK_REVIEW_MODE", default_val) or default_val).strip().lower()
@ -1181,6 +1194,7 @@ def apply_settings_to_env(settings: dict) -> None:
# Acting (mutative) subagents: owner toggle + worktree/projects roots.
"OUROBOROS_ALLOW_MUTATIVE_SUBAGENTS", "OUROBOROS_SUBAGENT_WORKTREE_ROOT",
"OUROBOROS_SUBAGENT_PROJECTS_ROOT",
"OUROBOROS_DELIVERABLES_ROOT",
# ClawHub marketplace registry URL.
"OUROBOROS_CLAWHUB_REGISTRY_URL",
"MCP_ENABLED", "MCP_TOOL_TIMEOUT_SEC",

View file

@ -50,12 +50,16 @@ def build_user_content(task: Dict[str, Any]) -> Any:
if metadata.get("force_plan"):
source = str(metadata.get("force_plan_source") or "operator").strip() or "operator"
plan_notice = (
"[CONSILIUM_FORCE_PLAN]\n"
"[SWARM_INITIATIVE]\n"
f"Source: {source}.\n"
"Before answering or editing, call plan_task with an explicit context_level "
"appropriate to this task. Treat this as a planning requirement for this "
"task, not as user-authored content.\n"
"[/CONSILIUM_FORCE_PLAN]\n\n"
"First call plan_task with an explicit context_level appropriate to this task to think "
"deeply about the approach. THEN, when the work decomposes into parts that can progress "
"in parallel, fan out subagents with schedule_subagent (acting/mutative where the work "
"needs changes and the owner toggle permits it) within the configured child/worker caps, "
"and reconcile their results; publish the shared frame to the task-tree ledger first if "
"their outputs must integrate. If the task is genuinely atomic, a deep plan alone is fine. "
"Treat this as a planning+delegation initiative for this task, not as user-authored content.\n"
"[/SWARM_INITIATIVE]\n\n"
)
text = plan_notice + str(text or "")
image_b64 = task.get("image_base64")

View file

@ -90,10 +90,33 @@ def _mirror_owner_request_to_project_chat(
from ouroboros.utils import append_jsonl, utc_now_iso
# Stamp the mirror with the task's ORIGINAL request timestamp (its creation ts, preserved
# across result updates) so the owner's message sorts to the TOP of the project thread —
# ahead of the working bubbles emitted while the task ran — instead of being stamped 'now'
# at the bottom. History replay sorts by ts (gateway/history.py). Fall back to now if absent.
original_ts = ""
try:
from ouroboros.task_results import load_task_result
# Consult the persisted result first, then the LIVE queue snapshot — an in-flight
# conversion can have the original timestamp only in the live task (the result may not
# carry `ts` yet), so reading only the result would still mirror with `now` for it.
result = load_task_result(drive_root, task_id) or {}
live = _task_from_live_queue(drive_root, task_id) or {}
for src in (result, live):
for field in ("ts", "created_at", "started_at"):
val = str((src or {}).get(field) or "").strip()
if val:
original_ts = val
break
if original_ts:
break
except Exception:
original_ts = ""
append_jsonl(
pathlib.Path(str(drive_root)) / "logs" / "chat.jsonl",
{
"ts": utc_now_iso(),
"ts": original_ts or utc_now_iso(),
"direction": "in",
"chat_id": int(project_chat_id),
"user_id": 1,

View file

@ -280,7 +280,7 @@ async def ws_endpoint(websocket: WebSocket) -> None:
image_caption=image_caption,
task_metadata={
"force_plan": force_plan,
"force_plan_source": "consilium" if force_plan else "",
"force_plan_source": "swarm" if force_plan else "",
},
chat_id=thread_id,
project_id=str(msg.get("project_id", "") or ""),

View file

@ -225,7 +225,7 @@ def _force_plan_completed(llm_trace: Dict[str, Any]) -> bool:
Reads the structured ``plan_review_aggregate`` flag captured from the FULL
tool result at execution time (loop_tool_execution); the old substring
check against the 700-char trace preview could never see the aggregate
marker at the end of a long plan output, wedging Consilium tasks in the
marker at the end of a long plan output, wedging swarm tasks in the
force-plan reminder loop.
"""
for call in llm_trace.get("tool_calls") or []:
@ -1538,6 +1538,22 @@ def _visible_round_text(content: Any) -> str:
return ""
def _emit_round_progress(content: Any, msg: Dict[str, Any], emit_progress, llm_trace: Dict[str, Any]) -> None:
"""Emit the round's progress bubble: the visible assistant text, or — for a pure tool-call round
with no visible text readable reasoning the provider already returned. The reasoning fallback
is DISPLAY-ONLY: emitted to the UI bubble but NOT recorded in ``reasoning_notes`` (which feeds
build_trace_summary / task summaries) and never appended to the transcript, so it cannot leak out
of the display path into the durable trace or back to a provider. Gated by OUROBOROS_REASONING_SUMMARY."""
visible_text = _visible_round_text(content)
if visible_text:
emit_progress(visible_text)
llm_trace["reasoning_notes"].append(visible_text)
elif str(os.environ.get("OUROBOROS_REASONING_SUMMARY", "auto")).strip().lower() != "off":
display_reasoning = LLMClient.extract_display_reasoning(msg)
if display_reasoning:
emit_progress(display_reasoning)
def run_llm_loop(
messages: List[Dict[str, Any]],
tools: ToolRegistry,
@ -1719,9 +1735,9 @@ def run_llm_loop(
attempts = int(getattr(tools._ctx, "_force_plan_reminder_count", 0) or 0)
if attempts >= 2:
accumulated_usage["execution_status"] = "failed"
accumulated_usage["reason_code"] = "consilium_force_plan_not_called"
accumulated_usage["reason_code"] = "swarm_force_plan_not_called"
return (
"⚠️ CONSILIUM_FORCE_PLAN_BLOCKED: plan_task was required for this Consilium task but was not called.",
"⚠️ SWARM_INITIATIVE_BLOCKED: plan_task was required for this swarm task but was not called.",
accumulated_usage,
llm_trace,
)
@ -1730,11 +1746,11 @@ def run_llm_loop(
messages.append({"role": "assistant", "content": content})
_append_or_merge_user_message(
messages,
"[CONSILIUM_FORCE_PLAN] plan_task is required before finalizing this task. "
"[SWARM_INITIATIVE] plan_task is required before finalizing this task. "
"Call plan_task now with an appropriate context_level, then continue.",
)
emit_progress("Consilium force-plan reminder injected before final response.")
llm_trace["reasoning_notes"].append("Consilium force-plan reminder injected before final response.")
emit_progress("Swarm force-plan reminder injected before final response.")
llm_trace["reasoning_notes"].append("Swarm force-plan reminder injected before final response.")
continue
handoff_msg = _compute_subagent_handoff(tools, drive_root, task_id, content)
if handoff_msg:
@ -1772,20 +1788,7 @@ def run_llm_loop(
assistant_msg.setdefault("role", "assistant")
messages.append(assistant_msg)
visible_text = _visible_round_text(content)
if visible_text:
emit_progress(visible_text)
llm_trace["reasoning_notes"].append(visible_text)
elif str(os.environ.get("OUROBOROS_REASONING_SUMMARY", "auto")).strip().lower() != "off":
# Narration: a pure tool-call round had no visible text — surface readable reasoning
# the provider already returned (shape-based, opaque skipped) so the bubble is not
# blank. DISPLAY-ONLY: emitted to the UI bubble but NOT recorded in reasoning_notes
# (which feeds build_trace_summary / task summaries) and never appended to the
# transcript (assistant_msg above is the raw msg) — so it cannot leak out of the
# display path into the durable trace or back to a provider.
display_reasoning = LLMClient.extract_display_reasoning(msg)
if display_reasoning:
emit_progress(display_reasoning)
_emit_round_progress(content, msg, emit_progress, llm_trace)
handle_tool_calls(
tool_calls, tools, drive_logs, task_id, stateful_executor,

View file

@ -348,7 +348,7 @@ def _extract_result_metadata(fn_name: str, result: Any, is_error: bool) -> Dict[
# late ARTIFACT_OUTPUTS marker (e.g. a stopped service after a long log tail).
if not is_error and "ARTIFACT_OUTPUTS" in text:
meta["artifact_registered"] = True
# Same full-result capture for the Consilium force-plan gate: the review
# Same full-result capture for the swarm force-plan gate: the review
# aggregate marker sits at the END of a long plan_task result, far past the
# 700-char trace preview the gate used to substring-match against.
if fn_name == "plan_task" and not is_error and "## Plan Review Results" in text and "AGGREGATE:" in text:

View file

@ -444,26 +444,51 @@ def user_files_path_block_reason(
if meta.get(key):
protected_values.append(meta.get(key))
protected_roots: list[pathlib.Path] = []
hard_protected_roots: list[pathlib.Path] = [] # the data/repo/budget drives THEMSELVES
for value in protected_values:
try:
root = pathlib.Path(value).resolve(strict=False)
except (OSError, TypeError, ValueError):
continue
protected_roots.append(root)
hard_protected_roots.append(root)
parent = root.parent.resolve(strict=False)
if root.name in {"repo", "data"} and path_is_relative_to(parent, home):
# The workspace PARENT is a SOFT boundary (keeps user_files out of ~/Ouroboros at large);
# it is deliberately NOT a hard root, so the Deliverables sibling under it stays allowed.
protected_roots.append(parent)
for protected in protected_roots:
overlaps_protected = path_is_relative_to(resolved, protected) or _path_is_relative_to_casefold(resolved, protected)
contains_protected = path_is_relative_to(protected, resolved) or _path_is_relative_to_casefold(protected, resolved)
if overlaps_protected or (
not allow_protected_descendants and contains_protected
# The configured Deliverables container is an INTENDED user-output root, allowed past the
# workspace-overlap guard — but ONLY when it is a genuine sibling: a misconfigured
# OUROBOROS_DELIVERABLES_ROOT that overlaps or contains a HARD data/repo/budget drive must NOT
# open a bypass. The outside-home, credential, and hidden-name checks still apply regardless.
in_deliverables = False
try:
from ouroboros.config import get_deliverables_root
_deliverables = pathlib.Path(get_deliverables_root()).expanduser().resolve(strict=False)
_deliverables_safe = not any(
path_is_relative_to(_deliverables, pr) or _path_is_relative_to_casefold(_deliverables, pr)
or path_is_relative_to(pr, _deliverables) or _path_is_relative_to_casefold(pr, _deliverables)
for pr in hard_protected_roots
)
if _deliverables_safe and (
path_is_relative_to(resolved, _deliverables) or _path_is_relative_to_casefold(resolved, _deliverables)
):
return (
"path overlaps the Ouroboros repo/runtime workspace; use "
"root=active_workspace, root=task_drive, root=artifact_store, "
"or root=skill_payload instead"
)
in_deliverables = True
except Exception:
in_deliverables = False
if not in_deliverables:
for protected in protected_roots:
overlaps_protected = path_is_relative_to(resolved, protected) or _path_is_relative_to_casefold(resolved, protected)
contains_protected = path_is_relative_to(protected, resolved) or _path_is_relative_to_casefold(protected, resolved)
if overlaps_protected or (
not allow_protected_descendants and contains_protected
):
return (
"path overlaps the Ouroboros repo/runtime workspace; use "
"root=active_workspace, root=task_drive, root=artifact_store, "
"or root=skill_payload instead"
)
try:
parts = resolved.relative_to(home).parts
@ -506,7 +531,24 @@ def resolve_user_file_path(
elif raw_text.startswith("~"):
candidate = raw.resolve(strict=False)
else:
candidate = (home / safe_relpath(raw_text)).resolve(strict=False)
# safe_relpath has already normalized any Windows backslash to a POSIX '/', so the
# directory test below is separator-correct on every platform.
rel = safe_relpath(raw_text)
home_candidate = home / rel
if "/" in rel.strip("/") or home_candidate.exists():
# An explicit placement (a path WITH a directory — Desktop/..., Downloads/..., a subdir)
# OR a bare name that ALREADY EXISTS under home (an existing file or directory such as
# `Desktop`) is honored under the owner home exactly as given. This keeps read/list/search
# of existing user files and directory names home-relative — only a genuinely NEW unnamed
# output is containerized.
candidate = home_candidate.resolve(strict=False)
else:
# A bare name with no directory that does NOT already exist under home is an unnamed NEW
# deliverable: route it into the visible Deliverables container instead of cluttering the
# home root (a later read of the same bare name resolves there too, staying consistent).
from ouroboros.config import get_deliverables_root
candidate = (pathlib.Path(get_deliverables_root()).expanduser() / rel).resolve(strict=False)
reason = user_files_path_block_reason(
ctx,
candidate,

View file

@ -459,7 +459,7 @@ Tool choice is part of reasoning. Prefer exact scoped tools over shell. Use `rea
Canonical Tool API v2 names are neutral and root-aware: files/context use `read_file`, `list_files`, `search_code`, `query_code`, `write_file`, `edit_text`; process/service work uses `run_command`, `run_script`, `claude_code_edit`, `start_service`, `service_status`, `service_logs`, `stop_service`; VCS/review/delegation use `vcs_status`, `vcs_diff`, `commit_reviewed`, `advisory_review`, `review_status`, `skill_review`, `task_acceptance_review`, `schedule_subagent`, `wait_task`, `wait_tasks`, and `get_task_result`. Legacy public tool names were removed as a breaking Tool API v2 rename; if old memory mentions a pre-v2 name, translate the intent to the canonical v2 name instead of calling it.
Resource roots are semantic, not path trivia. Use `active_workspace` for the current repo/workspace, `system_repo` only when explicitly working on Ouroboros, `runtime_data` for explicit runtime state/memory work when the active profile permits it, `task_drive` for task scratch, `artifact_store` for canonical deliverables, `skill_payload` for reviewed skill payloads, and `user_files` for user-visible files under the owner's home such as `Desktop/report.html`. In `runtime_mode=light`, external deliverables are still allowed: write to `root=user_files` for the visible copy and rely on the automatic task artifact copy, or write directly to `root=artifact_store` when no Desktop copy is needed. Do not use `runtime_data/uploads` or skill payloads as generic artifact transport.
Resource roots are semantic, not path trivia. Use `active_workspace` for the current repo/workspace, `system_repo` only when explicitly working on Ouroboros, `runtime_data` for explicit runtime state/memory work when the active profile permits it, `task_drive` for task scratch, `artifact_store` for canonical deliverables, `skill_payload` for reviewed skill payloads, and `user_files` for user-visible files under the owner's home such as `Desktop/report.html`. A `user_files` write with an explicit directory (`Desktop/…`, `Downloads/…`, any path with a folder) is honored under the owner home as given; a BARE filename with no directory lands in the visible `~/Ouroboros/Deliverables/` container (configurable via `OUROBOROS_DELIVERABLES_ROOT`) instead of cluttering the home root. In `runtime_mode=light`, external deliverables are still allowed: write to `root=user_files` for the visible copy and rely on the automatic task artifact copy, or write directly to `root=artifact_store` when no Desktop copy is needed. Do not use `runtime_data/uploads` or skill payloads as generic artifact transport.
My cognitive memory has its own first-class tools, not generic file writes: `update_identity` for `identity.md`, `update_scratchpad` for the scratchpad, and `knowledge_write` for knowledge topics. I never reach for `write_file`/`edit_text` on `memory/identity.md`, `memory/scratchpad.md`, or `memory/knowledge/*` — those tools carry the right structure (journaling, timestamped blocks, index maintenance) and stay available in light mode. I update identity/scratchpad only after substantive reflection or real experience, never on a greeting or a trivial turn, and I read the current state before writing (P12: writing without reading is overwrite, not creation).

File diff suppressed because one or more lines are too long

View file

@ -11,12 +11,12 @@ def test_force_plan_metadata_adds_structured_notice_without_rewriting_user_text(
content = build_user_content(
{
"text": "Fix the marketplace retry flow.",
"metadata": {"force_plan": True, "force_plan_source": "consilium"},
"metadata": {"force_plan": True, "force_plan_source": "swarm"},
}
)
assert content.startswith("[CONSILIUM_FORCE_PLAN]")
assert "Source: consilium." in content
assert content.startswith("[SWARM_INITIATIVE]")
assert "Source: swarm." in content
assert content.rstrip().endswith("Fix the marketplace retry flow.")

View file

@ -0,0 +1,82 @@
"""Deliverables layout: a BARE user_files filename lands in the visible ~/Ouroboros/Deliverables
container instead of cluttering the home root, while an explicit placement (Desktop/..., a path with
a directory) is honored under home exactly as given."""
import pathlib
def _ctx(home: pathlib.Path):
class Ctx:
drive_root = home / "Ouroboros" / "data"
repo_dir = home / "Ouroboros" / "repo"
task_metadata: dict = {}
return Ctx()
def test_bare_name_routes_to_deliverables_container(tmp_path, monkeypatch):
from ouroboros import tool_access
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("USERPROFILE", str(tmp_path)) # Path.home() uses USERPROFILE on Windows CI
monkeypatch.setenv("OUROBOROS_DELIVERABLES_ROOT", "") # default ~/Ouroboros/Deliverables
resolved = tool_access.resolve_user_file_path(_ctx(tmp_path), "report.html")
assert pathlib.Path(resolved) == tmp_path / "Ouroboros" / "Deliverables" / "report.html"
# NOT cluttering the home root.
assert pathlib.Path(resolved) != tmp_path / "report.html"
def test_explicit_placement_is_honored_under_home(tmp_path, monkeypatch):
from ouroboros import tool_access
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("USERPROFILE", str(tmp_path)) # Path.home() uses USERPROFILE on Windows CI
for placed in ("Desktop/report.html", "Downloads/out.csv", "sub/dir/notes.md"):
resolved = tool_access.resolve_user_file_path(_ctx(tmp_path), placed)
assert pathlib.Path(resolved) == tmp_path / placed, placed
def test_deliverables_root_is_overridable(tmp_path, monkeypatch):
from ouroboros import tool_access
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("USERPROFILE", str(tmp_path)) # Path.home() uses USERPROFILE on Windows CI
custom = tmp_path / "myout"
monkeypatch.setenv("OUROBOROS_DELIVERABLES_ROOT", str(custom))
# an override under the home is honored (outside-home roots still trip the home-confinement guard).
monkeypatch.setenv("OUROBOROS_DELIVERABLES_ROOT", str(tmp_path / "Custom" / "Out"))
resolved = tool_access.resolve_user_file_path(_ctx(tmp_path), "thing.txt")
assert pathlib.Path(resolved) == tmp_path / "Custom" / "Out" / "thing.txt"
def test_existing_home_dir_or_file_stays_home_relative(tmp_path, monkeypatch):
"""Regression guard: a bare name that ALREADY EXISTS under home (an existing directory like
Desktop, or an existing file) is honored under home only a genuinely NEW unnamed output is
containerized. Keeps read/list/search of existing user files and directory names home-relative."""
from ouroboros import tool_access
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("USERPROFILE", str(tmp_path)) # Path.home() uses USERPROFILE on Windows CI
(tmp_path / "Desktop").mkdir()
(tmp_path / "existing.txt").write_text("x", encoding="utf-8")
assert pathlib.Path(tool_access.resolve_user_file_path(_ctx(tmp_path), "Desktop")) == tmp_path / "Desktop"
assert pathlib.Path(tool_access.resolve_user_file_path(_ctx(tmp_path), "existing.txt")) == tmp_path / "existing.txt"
# a genuinely-new bare name still containerizes.
assert pathlib.Path(tool_access.resolve_user_file_path(_ctx(tmp_path), "fresh.html")) == \
tmp_path / "Ouroboros" / "Deliverables" / "fresh.html"
def test_misconfigured_deliverables_inside_data_does_not_bypass_guard(tmp_path, monkeypatch):
"""Security: a misconfigured OUROBOROS_DELIVERABLES_ROOT pointing INSIDE the protected data drive
must NOT open a user_files bypass the carve-out only applies to a GENUINE sibling, so a bare
name routed there is still blocked by the workspace-overlap guard."""
import pytest
from ouroboros import tool_access
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("USERPROFILE", str(tmp_path)) # Path.home() uses USERPROFILE on Windows CI
monkeypatch.setenv("OUROBOROS_DELIVERABLES_ROOT", str(tmp_path / "Ouroboros" / "data" / "out"))
with pytest.raises(ValueError):
tool_access.resolve_user_file_path(_ctx(tmp_path), "leak.txt")

View file

@ -625,7 +625,7 @@ def test_run_llm_loop_keeps_task_model_override_across_tool_rounds(tmp_path, mon
assert seen_use_local == [True, True]
def test_run_llm_loop_enforces_consilium_force_plan_before_final(tmp_path, monkeypatch):
def test_run_llm_loop_enforces_swarm_force_plan_before_final(tmp_path, monkeypatch):
from ouroboros.tools.registry import ToolRegistry
messages = [{"role": "user", "content": "ship"}]
@ -667,7 +667,7 @@ def test_run_llm_loop_enforces_consilium_force_plan_before_final(tmp_path, monke
return 0
registry = ToolRegistry(repo_dir=tmp_path, drive_root=tmp_path)
registry._ctx.task_metadata = {"force_plan": True, "force_plan_source": "consilium"}
registry._ctx.task_metadata = {"force_plan": True, "force_plan_source": "swarm"}
monkeypatch.setattr(loop_mod, "call_llm_with_retry", fake_call_llm_with_retry)
monkeypatch.setattr(loop_mod, "handle_tool_calls", fake_handle_tool_calls)
@ -688,7 +688,7 @@ def test_run_llm_loop_enforces_consilium_force_plan_before_final(tmp_path, monke
assert trace["tool_calls"][0]["tool"] == "plan_task"
def test_run_llm_loop_does_not_accept_failed_plan_task_for_consilium_force_plan(tmp_path, monkeypatch):
def test_run_llm_loop_does_not_accept_failed_plan_task_for_swarm_force_plan(tmp_path, monkeypatch):
from ouroboros.tools.registry import ToolRegistry
messages = [{"role": "user", "content": "ship"}]
@ -725,7 +725,7 @@ def test_run_llm_loop_does_not_accept_failed_plan_task_for_consilium_force_plan(
return 0
registry = ToolRegistry(repo_dir=tmp_path, drive_root=tmp_path)
registry._ctx.task_metadata = {"force_plan": True, "force_plan_source": "consilium"}
registry._ctx.task_metadata = {"force_plan": True, "force_plan_source": "swarm"}
monkeypatch.setattr(loop_mod, "call_llm_with_retry", fake_call_llm_with_retry)
monkeypatch.setattr(loop_mod, "handle_tool_calls", fake_handle_tool_calls)
@ -740,9 +740,9 @@ def test_run_llm_loop_does_not_accept_failed_plan_task_for_consilium_force_plan(
drive_root=tmp_path,
)
assert result.startswith("⚠️ CONSILIUM_FORCE_PLAN_BLOCKED")
assert result.startswith("⚠️ SWARM_INITIATIVE_BLOCKED")
assert calls["count"] == 4
assert usage["reason_code"] == "consilium_force_plan_not_called"
assert usage["reason_code"] == "swarm_force_plan_not_called"
assert trace["tool_calls"][0]["tool"] == "plan_task"

View file

@ -482,18 +482,18 @@ def test_ui_smoke_desktop_composer_chips_above_input_send_inside(direct_server):
toolbar: rect('.chat-toolbar-row'),
send: rect('.chat-send-group'),
sendButton: rect('.chat-send-inline'),
consilium: rect('.chat-consilium'),
swarm: rect('.chat-swarm'),
contextMode: rect('.chat-context-mode'),
};
}"""
)
# v6.32.0 composer redesign (owner: "чипы правильнее НАД полем ввода"):
# the chips row (Consilium + Low/Max) sits ABOVE the text input...
# the chips row (Swarm + Low/Max) sits ABOVE the text input...
assert metrics["toolbar"]["bottom"] <= metrics["input"]["top"] + 4, metrics
assert metrics["consilium"]["bottom"] <= metrics["input"]["top"] + 4, metrics
assert metrics["swarm"]["bottom"] <= metrics["input"]["top"] + 4, metrics
assert metrics["contextMode"]["bottom"] <= metrics["input"]["top"] + 4, metrics
# ...the two chips share that row (aligned tops)...
assert abs(metrics["consilium"]["top"] - metrics["contextMode"]["top"]) <= 2, metrics
assert abs(metrics["swarm"]["top"] - metrics["contextMode"]["top"]) <= 2, metrics
# ...and the Send button stays INSIDE the input's vertical band (same text row).
assert metrics["send"]["top"] >= metrics["input"]["top"] - 4, metrics
assert metrics["send"]["bottom"] <= metrics["input"]["bottom"] + 4, metrics
@ -532,7 +532,7 @@ def test_ui_smoke_mobile_composer_toolbar_does_not_overlap_input(direct_server):
pills: rect('.chat-composer-pills'),
send: rect('.chat-send-group'),
sendButton: rect('.chat-send-inline'),
consilium: rect('.chat-consilium'),
swarm: rect('.chat-swarm'),
contextMode: rect('.chat-context-mode'),
paddingRight: inputStyle.paddingRight,
};
@ -548,7 +548,7 @@ def test_ui_smoke_mobile_composer_toolbar_does_not_overlap_input(direct_server):
assert metrics["toolbar"]["bottom"] <= metrics["input"]["top"] + 1, metrics
assert metrics["send"]["top"] >= metrics["input"]["top"] - 1, metrics
assert metrics["send"]["bottom"] <= metrics["input"]["bottom"] + 1, metrics
assert abs(metrics["consilium"]["height"] - metrics["sendButton"]["height"]) <= 1, metrics
assert abs(metrics["swarm"]["height"] - metrics["sendButton"]["height"]) <= 1, metrics
assert abs(metrics["contextMode"]["height"] - metrics["sendButton"]["height"]) <= 1, metrics
assert metrics["paddingRight"] != "256px", metrics
finally:

View file

@ -13,7 +13,6 @@ import {
const CHAT_STORAGE_KEY = 'ouro_chat';
const CHAT_INPUT_HISTORY_KEY = 'ouro_chat_input_history';
const CHAT_SESSION_ID_KEY = 'ouro_chat_session_id';
const PLAN_PREFIX = 'Please do multi-model planning (plan_task tool) and web-search before answering or starting this task:\n\n';
const MAX_PENDING_ATTACHMENTS = 10;
const MAX_ATTACHMENT_FILE_BYTES = 50 * 1024 * 1024;
const MAX_PENDING_ATTACHMENT_BYTES = 100 * 1024 * 1024;
@ -116,7 +115,7 @@ export function createChatInstance({
<div class="chat-input-wrap">
<div class="chat-toolbar-row">
<div class="chat-composer-pills" id="chat-composer-pills">
<button class="chat-consilium" id="chat-consilium" type="button" data-armed="false" title="Consilium: arm a one-shot multi-subagent brainstorm/plan (plan_task + web search) for your next message. Auto-disarms after sending.">Consilium</button>
<button class="chat-swarm" id="chat-swarm" type="button" data-armed="false" title="Swarm: arm a one-shot deep plan + multi-subagent fan-out (plan_task + web search, then delegate) for your next message. Auto-disarms after sending.">Swarm</button>
<div class="chat-context-mode" id="chat-context-mode" data-context-mode="max" role="group" aria-label="Context size mode" title="Context mode (owner setting). Low fits ~200K / local models; Max is full. Applies on the next task.">
<button class="chat-seg" type="button" data-mode="low">Low</button>
<button class="chat-seg" type="button" data-mode="max">Max</button>
@ -1945,7 +1944,6 @@ export function createChatInstance({
for (const msg of messages) {
if (msg.role !== 'user') continue;
let text = (msg.text || '').trim();
if (text.startsWith(PLAN_PREFIX)) text = text.slice(PLAN_PREFIX.length).trimStart();
if (text) serverTexts.push(text);
}
const combined = [...serverTexts, ...inputHistory];
@ -2124,8 +2122,8 @@ export function createChatInstance({
showToast('Connection lost before send. Reconnect and try again.', 'error');
return;
}
// One-shot: disarm Consilium now that the message is sent.
if (planMode) setConsilium(false);
// One-shot: disarm Swarm now that the message is sent.
if (planMode) setSwarm(false);
// Hand the objective to the NEXT main-chat live card this message spawns.
if (isMain && objectiveText) _pendingCardObjective = objectiveText;
if (hasAttachments) {
@ -2148,14 +2146,14 @@ export function createChatInstance({
// Send mode lives on DOM so CSS and click/Enter share one source.
const sendGroup = page.querySelector('.chat-send-group');
// Consilium is a one-shot arm: the next send goes through plan_task multi-model
// Swarm is a one-shot arm: the next send goes through plan_task multi-model
// brainstorm/planning, then the pill auto-disarms so it never sticks.
const consiliumBtn = byId('consilium');
function consiliumArmed() {
return consiliumBtn?.dataset.armed === 'true';
const swarmBtn = byId('swarm');
function swarmArmed() {
return swarmBtn?.dataset.armed === 'true';
}
function setConsilium(armed) {
if (consiliumBtn) consiliumBtn.dataset.armed = armed ? 'true' : 'false';
function setSwarm(armed) {
if (swarmBtn) swarmBtn.dataset.armed = armed ? 'true' : 'false';
}
function setSendBusy(busy, label = '') {
@ -2170,7 +2168,7 @@ export function createChatInstance({
}
}
consiliumBtn?.addEventListener('click', () => setConsilium(!consiliumArmed()));
swarmBtn?.addEventListener('click', () => setSwarm(!swarmArmed()));
// Context-mode quick toggle (owner-only; applies on the next task). Posts to
// the owner endpoint and reflects the current value from /api/state.
@ -2238,11 +2236,11 @@ export function createChatInstance({
});
// Arrow wrappers avoid MouseEvent leaking into sendMessage(planMode).
sendBtn.addEventListener('click', () => sendMessage(consiliumArmed()));
sendBtn.addEventListener('click', () => sendMessage(swarmArmed()));
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage(consiliumArmed());
sendMessage(swarmArmed());
return;
}
if (e.key === 'ArrowUp' && !e.shiftKey) {

View file

@ -2312,7 +2312,7 @@ textarea.chat-input {
#chat-input::placeholder,
textarea.chat-input::placeholder { color: var(--text-muted); }
/* Composer pills (Consilium + Low|Max), left of the absolute send button. */
/* Composer pills (Swarm + Low|Max), left of the absolute send button. */
.chat-composer-pills {
display: flex;
align-items: center;
@ -2320,8 +2320,8 @@ textarea.chat-input::placeholder { color: var(--text-muted); }
min-width: 0;
}
/* One-shot Consilium arm pill: dim when idle, accent when armed. */
.chat-consilium {
/* One-shot Swarm arm pill: dim when idle, accent when armed. */
.chat-swarm {
height: var(--composer-control-height);
padding: 0 12px;
border: 1px solid rgba(226, 232, 240, 0.12);
@ -2339,12 +2339,12 @@ textarea.chat-input::placeholder { color: var(--text-muted); }
white-space: nowrap;
transition: color 0.18s, background 0.18s, border-color 0.18s, box-shadow 0.18s;
}
.chat-consilium:hover {
.chat-swarm:hover {
color: var(--accent);
border-color: rgba(232, 93, 111, 0.40);
background: rgba(38, 25, 34, 0.58);
}
.chat-consilium[data-armed="true"] {
.chat-swarm[data-armed="true"] {
color: var(--accent);
border-color: rgba(232, 93, 111, 0.44);
background: rgba(232, 93, 111, 0.14);
@ -4509,7 +4509,7 @@ textarea.chat-input {
display: none;
}
.chat-consilium,
.chat-swarm,
.chat-context-mode .chat-seg {
padding-left: 10px;
padding-right: 10px;