feat(fleet): opus-fable adoption — doctrine + discipline hooks (v0.18.0 A)

Fleet behaves more like Fable 5 on existing model tiers, behind
ROBOCO_FABLE_MODE_ENABLED (config default off; armed :-true on the NAS compose,
absent from the registry compose).

- Doctrine: vendored agents/prompts/doctrine/fable.md composed into every
  agent's system prompt via fable_doctrine_layer() after base.md.
- Hooks (Claude Code): 4 non-overlapping hooks (stop-gate/bash-discipline/
  honesty-nudge/precompact) appended per-agent via _fable_hook_groups(). The
  make-quality + lint-suppression duplicates are deliberately NOT added (already
  gate-enforced); session-start skipped.
- Hooks (grok): conservative V1 — only the non-denying honesty-nudge, since a
  grok hook deny cancels the whole run.
- Flag on the feature-flags card; hook scripts shipped into the agent image.

Flag-off spawn path proven byte-identical (worktree diff, sha256 match); full
suite green (2074 unit + e2e-smoke + hook harness), mypy/xenon/ruff clean.
Fixed a real stdin bug in the vendored stop-gate hook (heredoc + pipe both
claimed stdin). Distilled from rennf93/opus-fable-playbook (MIT).
This commit is contained in:
Renn F
2026-07-04 06:44:40 +02:00
parent 30289333da
commit 7716830322
24 changed files with 881 additions and 5 deletions
+42
View File
@@ -10,6 +10,7 @@ envelope prints verbatim so a seam regression names itself.
from __future__ import annotations
from typing import TYPE_CHECKING
from unittest.mock import patch
from tests.e2e_smoke.arcs import (
dev_arc,
@@ -55,3 +56,44 @@ def test_leaf_dev_task_reaches_pm_review(e2e_stack: E2EStack) -> None:
final = task_state(stack, task_id)
assert final["status"] == "awaiting_pm_review", final
assert final["docs_complete"] is True, final
def test_leaf_dev_task_reaches_pm_review_with_fable_mode_on(
e2e_stack: E2EStack,
) -> None:
"""Non-interference regression check for fable_mode_enabled=True.
This harness cannot exercise compose_prompt / _generate_agent_settings —
those live entirely in the orchestrator's spawn-prep path, which the
harness bypasses by design (see tests/integration/test_fable_mode_spawn_prep.py
for that half). What it CAN prove is that arming the flag doesn't perturb
the gateway/lifecycle arc itself: the same scenario must reach the same
outcome with the flag on as with it off.
"""
with patch("roboco.config.settings.fable_mode_enabled", True):
stack = e2e_stack
company = seed_company(stack)
project_id, project_slug = seed_project(stack, company)
task_id = seed_task(
stack,
title="Add the greeting module (fable-mode non-interference check)",
description=(
"Create greeting.txt with a friendly greeting so the smoke "
"harness has a real file change to commit, push, and merge."
),
acceptance_criteria=[
"greeting.txt exists at the repo root",
"its content greets the reader",
],
project_id=project_id,
created_by=company.cell_pm_id,
assigned_to=company.dev_id,
)
dev_arc(stack, company, project_slug, task_id)
qa_arc(stack, company, task_id)
doc_arc(stack, company, task_id, filename="greeting.txt")
final = task_state(stack, task_id)
assert final["status"] == "awaiting_pm_review", final
assert final["docs_complete"] is True, final
@@ -0,0 +1,61 @@
"""Fable-mode ships doctrine + hooks together at a single agent spawn.
Task 3's and Task 6's unit tests each prove their own half in isolation:
compose_prompt includes the doctrine layer; _generate_agent_settings injects
the hook groups. Neither proves the two halves land together for the SAME
spawn. This is that proof, using the orchestrator's own
_generate_composed_prompt + _generate_agent_settings directly — the plan's
named lighter-weight alternative to _prepare_agent_spawn, which needs a live
DB session, docker, and a real workspace/worktree and so is not a fit for a
fast integration test (see the plan doc under docs/superpowers/plans/,
Task 11).
"""
from __future__ import annotations
import json
from unittest.mock import patch
from roboco.runtime.orchestrator import AgentOrchestrator
_WS = "/data/workspaces/roboco-api/backend/be-dev-1"
_CELL = "/data/workspaces/roboco-api/backend"
def _orch() -> AgentOrchestrator:
with patch.object(AgentOrchestrator, "__init__", return_value=None):
return AgentOrchestrator.__new__(AgentOrchestrator)
def test_flag_on_ships_doctrine_and_hooks_together() -> None:
with patch("roboco.config.settings.fable_mode_enabled", True):
orch = _orch()
prompt_path = orch._generate_composed_prompt("be-dev-1")
settings_path = orch._generate_agent_settings(
agent_id="be-dev-1",
role="developer",
workspace_path=_WS,
cell_workspace_path=_CELL,
)
prompt = prompt_path.read_text()
hooks = json.loads(settings_path.read_text())["hooks"]
assert "# Fable Doctrine" in prompt
stop_cmds = [h["command"] for g in hooks["Stop"] for h in g["hooks"]]
assert "/app/scripts/fable-stop-gate-hook.sh" in stop_cmds
def test_flag_off_ships_neither() -> None:
with patch("roboco.config.settings.fable_mode_enabled", False):
orch = _orch()
prompt_path = orch._generate_composed_prompt("be-dev-1")
settings_path = orch._generate_agent_settings(
agent_id="be-dev-1",
role="developer",
workspace_path=_WS,
cell_workspace_path=_CELL,
)
prompt = prompt_path.read_text()
hooks = json.loads(settings_path.read_text())["hooks"]
assert "# Fable Doctrine" not in prompt
stop_cmds = [h["command"] for g in hooks["Stop"] for h in g["hooks"]]
assert not any("fable" in c for c in stop_cmds)
@@ -0,0 +1,34 @@
"""compose_prompt includes the Fable doctrine layer when the flag is on."""
from __future__ import annotations
from unittest.mock import patch
from roboco.agents.factories._base import compose_prompt
from roboco.models import AgentRole, Team
def test_doctrine_included_when_flag_enabled() -> None:
with patch("roboco.config.settings.fable_mode_enabled", True):
prompt = compose_prompt(AgentRole.DEVELOPER, Team.BACKEND, "be-dev-1")
assert "# Fable Doctrine" in prompt
assert "Turn discipline" in prompt
def test_doctrine_absent_when_flag_disabled() -> None:
with patch("roboco.config.settings.fable_mode_enabled", False):
prompt = compose_prompt(AgentRole.DEVELOPER, Team.BACKEND, "be-dev-1")
assert "# Fable Doctrine" not in prompt
def test_doctrine_frontmatter_not_leaked() -> None:
with patch("roboco.config.settings.fable_mode_enabled", True):
prompt = compose_prompt(AgentRole.DEVELOPER, Team.BACKEND, "be-dev-1")
assert "keep-coding-instructions" not in prompt
def test_doctrine_applies_to_every_role() -> None:
with patch("roboco.config.settings.fable_mode_enabled", True):
for role in AgentRole:
prompt = compose_prompt(role, None, f"probe-{role.value}")
assert "# Fable Doctrine" in prompt, f"missing for role={role.value}"
@@ -5,6 +5,7 @@ from __future__ import annotations
import json
import tomllib
from typing import TYPE_CHECKING
from unittest.mock import patch
from roboco.llm.providers import grok_cli_config as gc
@@ -175,3 +176,29 @@ def test_write_grok_hooks_noops_when_script_absent(tmp_path: Path) -> None:
is False
)
assert not hooks_dir.exists()
def test_write_grok_fable_hooks_writes_honesty_nudge_when_enabled(
tmp_path: Path,
) -> None:
hooks_dir = tmp_path / "hooks"
with (
patch("roboco.config.settings.fable_mode_enabled", True),
patch(
"roboco.llm.providers.grok_cli_config.FABLE_HONESTY_NUDGE_HOOK",
"/app/scripts/fable-honesty-nudge-hook.sh",
),
patch("pathlib.Path.is_file", return_value=True),
):
result = gc.write_grok_fable_hooks(hooks_dir=hooks_dir)
assert result is True
written = json.loads((hooks_dir / "roboco-fable-honesty-nudge.json").read_text())
assert written["hooks"]["PostToolUse"][0]["matcher"] == "Bash"
def test_write_grok_fable_hooks_noop_when_disabled(tmp_path: Path) -> None:
hooks_dir = tmp_path / "hooks"
with patch("roboco.config.settings.fable_mode_enabled", False):
result = gc.write_grok_fable_hooks(hooks_dir=hooks_dir)
assert result is False
assert not hooks_dir.exists()
+72
View File
@@ -123,3 +123,75 @@ class TestSlashCommandsDisabled:
assert "--tools" in cmd
idx = cmd.index("--tools")
assert cmd[idx + 1] == "Read,Write,Edit,Bash,Grep,Glob,TodoWrite"
class TestFableModeHooksInjection:
"""Fable-mode hooks are additive to settings.json, gated by the flag."""
def test_fable_hooks_absent_when_flag_disabled(self) -> None:
with patch("roboco.config.settings.fable_mode_enabled", False):
orch = _orch()
path = orch._generate_agent_settings(
agent_id="be-dev-1",
role="developer",
workspace_path=_WS,
cell_workspace_path=_CELL,
)
hooks = json.loads(Path(path).read_text())["hooks"]
assert "SubagentStop" not in hooks
stop_cmds = [h["command"] for g in hooks["Stop"] for h in g["hooks"]]
assert not any("fable" in c for c in stop_cmds)
def test_fable_hooks_present_when_flag_enabled(self) -> None:
with patch("roboco.config.settings.fable_mode_enabled", True):
orch = _orch()
path = orch._generate_agent_settings(
agent_id="be-dev-1",
role="developer",
workspace_path=_WS,
cell_workspace_path=_CELL,
)
hooks = json.loads(Path(path).read_text())["hooks"]
stop_cmds = [h["command"] for g in hooks["Stop"] for h in g["hooks"]]
assert stop_cmds[-1] == "/app/scripts/fable-stop-gate-hook.sh" # appended last
assert stop_cmds[0] == "/app/scripts/stop-hook.sh" # RoboCo's check still first
subagent_cmds = [
h["command"] for g in hooks["SubagentStop"] for h in g["hooks"]
]
assert subagent_cmds == ["/app/scripts/fable-stop-gate-hook.sh subagent"]
pretool_bash = [
h["command"]
for g in hooks["PreToolUse"]
if g.get("matcher") == "Bash"
for h in g["hooks"]
]
assert "/app/scripts/bash-guard-hook.sh" in pretool_bash # existing guard kept
assert "/app/scripts/fable-bash-discipline-hook.sh" in pretool_bash
posttool_bash = [
h["command"]
for g in hooks["PostToolUse"]
if g.get("matcher") == "Bash"
for h in g["hooks"]
]
assert posttool_bash == ["/app/scripts/fable-honesty-nudge-hook.sh"] # new
def test_fable_hooks_off_leaves_hooks_dict_unchanged(self) -> None:
"""Regression guard: flag-off output equals a captured pre-Phase-2 baseline."""
with patch("roboco.config.settings.fable_mode_enabled", False):
orch = _orch()
path = orch._generate_agent_settings(
agent_id="be-dev-1",
role="developer",
workspace_path=_WS,
cell_workspace_path=_CELL,
)
hooks = json.loads(Path(path).read_text())["hooks"]
assert set(hooks.keys()) == {
"SessionStart",
"PreToolUse",
"PostToolUse",
"Stop",
"UserPromptSubmit",
"PreCompact",
"SessionEnd",
}