- Where PRs land and releases are cut when no environment ladder is set below.
+ Where PRs land and releases are cut when no environment ladder
+ is set below.
+ Autonomous maintenance (CI-watch, video engine,
+ dependency-update bot, sandbox DB/Redis/Mongo) is configured
+ after creation, from this project's Edit Project dialog.
+
@@ -434,7 +440,8 @@ function EditProjectForm({
placeholder="main"
/>
- Where PRs land and releases are cut when no environment ladder is set below.
+ Where PRs land and releases are cut when no environment ladder is
+ set below.
diff --git a/panel/src/components/settings/__tests__/feature-flags-card.test.tsx b/panel/src/components/settings/__tests__/feature-flags-card.test.tsx
index 4008a648..28184831 100644
--- a/panel/src/components/settings/__tests__/feature-flags-card.test.tsx
+++ b/panel/src/components/settings/__tests__/feature-flags-card.test.tsx
@@ -154,4 +154,26 @@ describe("FeatureFlagsCard — M42 off-transition confirm + pending-keys Set", (
const unmapped = screen.getByText("Alpha");
expect(unmapped.getAttribute("data-state")).toBeNull();
});
+
+ // FLAG_DESCRIPTIONS previously lagged FLAG_TOOLTIPS for three vault/docs
+ // flags — the always-visible paragraph silently rendered empty for them.
+ it("renders an always-visible description for every vault/docs-sync flag", async () => {
+ getFeatureFlags.mockResolvedValueOnce({
+ flags: [
+ { key: "docs_sync_enabled", label: "Docs Sync", enabled: false },
+ {
+ key: "obsidian_vault_enabled",
+ label: "Obsidian Vault",
+ enabled: false,
+ },
+ { key: "vault_intake_enabled", label: "Vault Intake", enabled: false },
+ ],
+ note: "Changes take effect on the next backend restart.",
+ });
+ render(withQueryClient());
+
+ expect(await screen.findByText(/docs-update task/i)).toBeInTheDocument();
+ expect(screen.getByText(/wikilinked Obsidian vault/i)).toBeInTheDocument();
+ expect(screen.getByText(/board-review drafts/i)).toBeInTheDocument();
+ });
});
diff --git a/panel/src/components/settings/feature-flags-card.tsx b/panel/src/components/settings/feature-flags-card.tsx
index 2d854643..e453d224 100644
--- a/panel/src/components/settings/feature-flags-card.tsx
+++ b/panel/src/components/settings/feature-flags-card.tsx
@@ -67,6 +67,8 @@ const FLAG_DESCRIPTIONS: Record = {
"Periodically probe opted-in projects for dependency updates and open an update task when a lockfile would change (per-project opt-in; never auto-merges).",
env_sync_enabled:
"Cascade each project's declared environment ladder prod→dev via GitHub's merges API so dev never falls behind prod; a conflicted rung opens a sync PR for you to merge (per-project opt-in; never pushes prod).",
+ docs_sync_enabled:
+ "When a release publishes and the public docs site (roboco-website) has drifted from what shipped, open ONE docs-update task that rides the normal delivery flow (+ PR-review gate) — release-triggered, not polling; never auto-merges. Needs the docs-site repo registered as a project with a git token.",
release_manager_enabled:
"Run the deterministic release-readiness sweep and propose a release for you to approve or reject — it never publishes without your approval, and the executor is fail-closed on a red gate.",
org_memory_enabled:
@@ -91,6 +93,10 @@ const FLAG_DESCRIPTIONS: Record = {
"Also open a video-authoring task when a release publishes. Off by default even with video_engine_enabled on.",
video_on_spotlight:
"Also open a video-authoring task when you approve a feature-spotlight draft that requests one. Off by default even with video_engine_enabled on.",
+ obsidian_vault_enabled:
+ "Project tasks, journals, and A2A digests into a human-readable, wikilinked Obsidian vault on disk (RoboCo/Tasks, Journals, A2A, Agents) — a rebuildable, DB-derived projection, never the system of record. Needs ROBOCO_VAULT_PATH set.",
+ vault_intake_enabled:
+ "Watch the vault's inbox folder for #roboco-tagged notes and turn them into board-review drafts — the same Product-Owner-reviewed path a chat-confirmed task takes, never straight into delivery. Needs the Obsidian vault projection on.",
vault_report_enabled:
"Materialize a weekly org-report note (velocity, cycle time, rework, cost) in the vault's Reports/ folder and notify you — deterministic numbers, no LLM. Needs the Obsidian vault projection on.",
vault_kb_enabled:
@@ -148,11 +154,11 @@ const FLAG_TOOLTIPS: Record = {
video_engine_enabled:
"Authors and renders motion-graphics videos for social posts.",
video_on_release: "Drafts a video whenever a release publishes.",
- video_on_spotlight:
- "Drafts a video whenever a feature spotlight is drafted.",
+ video_on_spotlight: "Drafts a video whenever a feature spotlight is drafted.",
roadmap_engine_enabled:
"Weekly has the Board draft a themed roadmap for CEO approval.",
- fable_mode_enabled: "Adopts the Fable/Ponytail behavioral doctrine fleet-wide.",
+ fable_mode_enabled:
+ "Adopts the Fable/Ponytail behavioral doctrine fleet-wide.",
obsidian_vault_enabled:
"Projects tasks/journals/A2A into a human-readable Obsidian vault.",
vault_intake_enabled:
@@ -297,7 +303,9 @@ export function FeatureFlagsCard() {
trigger itself would clobber its open/closed
data-state (same trap as Switch/TabsTrigger). */}
- X (Twitter) credentials
+
+ X (Twitter) credentials
+
{xCredsOpen ? (
diff --git a/panel/src/components/tasks/edit-task-dialog.tsx b/panel/src/components/tasks/edit-task-dialog.tsx
index 211e2541..bf5969cb 100644
--- a/panel/src/components/tasks/edit-task-dialog.tsx
+++ b/panel/src/components/tasks/edit-task-dialog.tsx
@@ -28,6 +28,7 @@ import {
import { ChevronDown, ChevronRight, GitBranch } from "lucide-react";
import { toast } from "sonner";
import { MarkdownEditor } from "./markdown-editor";
+import { AcceptanceCriteriaEditor } from "./acceptance-criteria-editor";
import { AgentSelector } from "@/components/agents/agent-selector";
import { ProjectSelector } from "@/components/projects/project-selector";
import { HelpTip } from "@/components/ui/help-tip";
@@ -67,7 +68,8 @@ const TASK_TYPE_OPTIONS = [
// (branch, commits, PR) — this only classifies the kind of artifact.
const TASK_TYPE_DESCRIPTIONS: Record = {
[TaskType.CODE]: "Source code changes. Follows the full git workflow.",
- [TaskType.DOCUMENTATION]: "Documentation updates. Follows the full git workflow.",
+ [TaskType.DOCUMENTATION]:
+ "Documentation updates. Follows the full git workflow.",
[TaskType.RESEARCH]:
"Research findings, committed as notes. Follows the full git workflow.",
[TaskType.PLANNING]:
@@ -102,6 +104,10 @@ function EditTaskDialogInner({
const [nature, setNature] = useState(
task.nature ?? TaskNature.TECHNICAL,
);
+ const [acceptanceCriteria, setAcceptanceCriteria] = useState(
+ task.acceptance_criteria,
+ );
+ const [acError, setAcError] = useState();
const [taskType, setTaskType] = useState(
task.task_type ?? TaskType.CODE,
);
@@ -131,6 +137,19 @@ function EditTaskDialogInner({
return;
}
+ if (acceptanceCriteria.length === 0) {
+ setAcError("At least one acceptance criterion is required");
+ return;
+ }
+ setAcError(undefined);
+
+ const trimmedCriteria = acceptanceCriteria
+ .map((c) => c.trim())
+ .filter(Boolean);
+ const criteriaChanged =
+ JSON.stringify(trimmedCriteria) !==
+ JSON.stringify(task.acceptance_criteria);
+
try {
await updateTask.mutateAsync({
taskId: task.id,
@@ -145,6 +164,7 @@ function EditTaskDialogInner({
project_id: projectId,
assigned_to: assignedTo,
target_date: targetDate ? new Date(targetDate).toISOString() : null,
+ ...(criteriaChanged && { acceptance_criteria: trimmedCriteria }),
},
});
toast.success("Task updated successfully");
@@ -267,6 +287,13 @@ function EditTaskDialogInner({
+ {/* Acceptance Criteria */}
+
+
{/* Advanced Options */}
diff --git a/panel/src/hooks/__tests__/use-git-browser.test.tsx b/panel/src/hooks/__tests__/use-git-browser.test.tsx
index f48d926b..49db4230 100644
--- a/panel/src/hooks/__tests__/use-git-browser.test.tsx
+++ b/panel/src/hooks/__tests__/use-git-browser.test.tsx
@@ -4,6 +4,7 @@ import { useGitBrowser } from "../use-git-browser";
const {
mockUseProjects,
+ mockUseProject,
mockUseGitStatus,
mockUseGitLog,
mockUseGitBranches,
@@ -16,6 +17,7 @@ const {
mockToastError,
} = vi.hoisted(() => ({
mockUseProjects: vi.fn(),
+ mockUseProject: vi.fn(),
mockUseGitStatus: vi.fn(),
mockUseGitLog: vi.fn(),
mockUseGitBranches: vi.fn(),
@@ -30,6 +32,7 @@ const {
vi.mock("@/hooks/use-projects", () => ({
useProjects: () => mockUseProjects(),
+ useProject: (...args: unknown[]) => mockUseProject(...args),
}));
vi.mock("@/hooks/use-git", () => ({
@@ -95,6 +98,7 @@ describe("useGitBrowser", () => {
registeredCallbacks.length = 0;
mockUseProjects.mockReturnValue(buildQueryResult([]));
+ mockUseProject.mockReturnValue(buildQueryResult(null));
mockUseGitStatus.mockReturnValue(buildQueryResult(null));
mockUseGitLog.mockReturnValue(buildQueryResult(null));
mockUseGitBranches.mockReturnValue(buildQueryResult(null));
@@ -121,6 +125,52 @@ describe("useGitBrowser", () => {
expect(result.current.taskId).toBe("t1");
});
+ // Bug: GitActionsPanel used to hardcode "main" for the PR-target branch,
+ // wrong for the fleet default ("master") and any project on a real
+ // environment ladder. defaultBranch resolves the selected project's real
+ // head rung (or default_branch), not a literal.
+ it("resolves defaultBranch from the selected project's environment ladder head rung", () => {
+ mockUseProjects.mockReturnValue(
+ buildQueryResult([{ id: "proj-1", slug: "roboco", name: "RoboCo" }]),
+ );
+ mockUseProject.mockReturnValue(
+ buildQueryResult({
+ id: "proj-1",
+ default_branch: "master",
+ environments: [
+ { name: "head", branch: "slave" },
+ { name: "prod", branch: "master" },
+ ],
+ }),
+ );
+
+ const { result } = renderHook(() => useGitBrowser());
+ expect(mockUseProject).toHaveBeenCalledWith("proj-1");
+ expect(result.current.defaultBranch).toBe("slave");
+ });
+
+ it("falls back to default_branch when no environment ladder is set", () => {
+ mockUseProjects.mockReturnValue(
+ buildQueryResult([{ id: "proj-1", slug: "roboco", name: "RoboCo" }]),
+ );
+ mockUseProject.mockReturnValue(
+ buildQueryResult({
+ id: "proj-1",
+ default_branch: "master",
+ environments: null,
+ }),
+ );
+
+ const { result } = renderHook(() => useGitBrowser());
+ expect(result.current.defaultBranch).toBe("master");
+ });
+
+ it('falls back to "main" before any project has loaded', () => {
+ const { result } = renderHook(() => useGitBrowser());
+ expect(mockUseProject).toHaveBeenCalledWith("");
+ expect(result.current.defaultBranch).toBe("main");
+ });
+
it("passes project slug and enabled flag to git query hooks", () => {
renderHook(() => useGitBrowser());
diff --git a/panel/src/hooks/use-git-browser.ts b/panel/src/hooks/use-git-browser.ts
index 9b32abae..31883e42 100644
--- a/panel/src/hooks/use-git-browser.ts
+++ b/panel/src/hooks/use-git-browser.ts
@@ -3,7 +3,7 @@
import { useCallback, useEffect, useRef } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { toast } from "sonner";
-import { useProjects } from "@/hooks/use-projects";
+import { useProjects, useProject } from "@/hooks/use-projects";
import {
useGitStatus,
useGitLog,
@@ -20,6 +20,10 @@ export interface UseGitBrowserResult {
taskId: string;
projects: ReturnType["data"];
loadingProjects: boolean;
+ // Real head/default branch of the selected project (env-ladder rung 0,
+ // falling back to default_branch, then "main" before any project loads) —
+ // never a hardcoded "main".
+ defaultBranch: string;
status: ReturnType["data"];
loadingStatus: boolean;
log: ReturnType["data"];
@@ -80,6 +84,19 @@ export function useGitBrowser(): UseGitBrowserResult {
refetch: refetchProjects,
} = useProjects();
+ // The project list is a lightweight ProjectSummary (no default_branch /
+ // environments) — resolve its id, then fetch the full Project for the
+ // ladder/branch fields.
+ const currentProjectId =
+ projects?.find((p) => p.slug === projectSlug)?.id ?? "";
+ const { data: currentProject } = useProject(currentProjectId);
+ // Ladder rung 0 (head) when an environment ladder is declared, else the
+ // plain default_branch shim — mirrors roboco/models/env_branches.head_branch.
+ const defaultBranch =
+ currentProject?.environments?.[0]?.branch ??
+ currentProject?.default_branch ??
+ "main";
+
const {
data: status,
isLoading: loadingStatus,
@@ -168,7 +185,9 @@ export function useGitBrowser(): UseGitBrowserResult {
} = useGitOperations();
// Resume point for a capped stale-branch sweep, per project.
- const cleanupCursorRef = useRef<{ slug: string; cursor: string } | null>(null);
+ const cleanupCursorRef = useRef<{ slug: string; cursor: string } | null>(
+ null,
+ );
const handleCheckout = useCallback(
async (branch: string) => {
@@ -362,6 +381,7 @@ export function useGitBrowser(): UseGitBrowserResult {
taskId,
projects,
loadingProjects,
+ defaultBranch,
status,
loadingStatus,
log,