Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion apps/docs/docs/guides/existing-repo.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,18 @@ title: Adopt an existing repo
Repositories that already run the vendored Facility system (or its
predecessors) adopt the platform incrementally — nothing breaks on day one.

## Step 0 — observe before automating

New projects created in the web app start in **observe first** mode. Connecting
the repository vendors and provisions nothing. Manual architect runs are
reviewed in Facility, do not acknowledge or publish progress in GitHub, and all
scheduled agents remain gated. A project operator can optionally publish one
terminal architect summary; the default is no GitHub write at all.

In **Project settings → autonomy**, switch to active only when the team is
ready for scheduled work and live GitHub progress. This switch is enforced by
the API schedulers, not only by the web interface.

## Step 1 — import

Connect the repo to a project. The platform detects vendored facility files
Expand All @@ -33,11 +45,19 @@ weekly security sweep to a platform sandbox, run the new Project Owner agent
platform-side. Each flip is reversible; the vendored workflows remain the
fallback.

Declare those choices in `.facility.json` under `executionLane`. A push to the
Declare those choices in `.facility.json` under `executionLane`, or apply an
operator override in **Project settings → autonomy**. The override exists so a
team can experiment without committing Facility configuration to the
repository; it takes precedence and is reversible. A push to the
default branch synchronizes the reviewed manifest into the control plane. An
unset agent remains on the `repo` lane, so adoption fails closed and does not
start duplicate automation.

If an incumbent automation already owns commands such as `/architect`, set a
repository command prefix in the same settings surface. With prefix `fx`, only
`/fx architect` and `/fx builder` route to Facility; the unprefixed commands
remain available to the incumbent tool.

Platform sandboxes clone the repository before starting the agent. Checked-in
`AGENTS.md`, `CLAUDE.md`, hooks, commands, guards, and skills therefore remain
available. If a repository skill and a Facility catalog skill have the same
Expand Down
23 changes: 23 additions & 0 deletions apps/docs/docs/self-host/github-app.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,29 @@ webhook inactive until the API has a public HTTPS URL.

## 2. Grant permissions

### Observe-only permission profile

An organization evaluating Facility without repository writes can begin with
an App limited to **Metadata, Contents, Issues, Pull requests, Actions, Checks,
Deployments, and the enabled security-alert surfaces: Read-only**. Keep the
organization Members and account Email addresses permissions below when this
same App provides sign-in.

That profile supports repository discovery, issue and pull-request mirrors,
and manual observe-first analysis. It deliberately cannot kickstart a
repository, publish comments, create branches or pull requests, project
findings as issues, or deliver builder output. Those actions fail closed until
the installation owner approves the corresponding write permissions. Move to
the lifecycle profile below only when the project is switched from observation
to active delivery.

GitHub App permissions apply to the whole installation, not to an individual
Facility project. For a mixed deployment, install a separate read-scoped App
on observation repositories or limit each App installation to repositories
with the same autonomy boundary.

### Active lifecycle permission profile

For an existing-repository lifecycle, set these **Repository permissions**:

| permission | access | why |
Expand Down
2 changes: 1 addition & 1 deletion apps/web/app/(app)/(org)/projects/new/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ export default function KickstartPage() {
name: name.trim(),
slug: effectiveSlug,
description: description.trim() || undefined,
settings: { check_cmds: [] },
settings: { check_cmds: [], autonomy_mode: "observe", observe_summary: false },
}),
}));
setProject(created);
Expand Down
8 changes: 8 additions & 0 deletions apps/web/app/(app)/projects/[projectId]/settings/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Divider, Eyebrow, PillTag, StatusDot } from "@facility/ui";
import Link from "next/link";
import { ErrorNotice, Offline } from "@/components/offline";
import { GatesEditor } from "@/components/project/gates-editor";
import { ObserveFirstEditor } from "@/components/project/observe-first-editor";
import { api } from "@/lib/api";

export const metadata = { title: "project settings" };
Expand Down Expand Up @@ -89,6 +90,13 @@ export default async function ProjectSettingsPage({

<Divider />

<section className="flex flex-col gap-4">
<Eyebrow>autonomy</Eyebrow>
<ObserveFirstEditor projectId={projectId} settings={settings} repos={repoList} />
</section>

<Divider />

<section className="flex flex-col gap-4">
<Eyebrow>gates</Eyebrow>
<GatesEditor projectId={projectId} settings={settings} />
Expand Down
173 changes: 173 additions & 0 deletions apps/web/components/project/observe-first-editor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
"use client";

import type { ProjectRepo } from "@facility/sdk";
import { Button, PillTag } from "@facility/ui";
import { useRouter } from "next/navigation";
import { useState } from "react";

type Lane = "repo" | "platform";

const LANE_AGENTS = [
"architect",
"builder",
"codex-architect",
"codex-builder",
"review",
"address-review",
"ci-doctor",
"security-sweep",
] as const;

function objectOrEmpty(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}

export function ObserveFirstEditor({
projectId,
settings,
repos,
}: {
projectId: string;
settings: Record<string, unknown>;
repos: ProjectRepo[];
}) {
const router = useRouter();
const repoAnswers = objectOrEmpty(repos[0]?.renderAnswers);
const configured = objectOrEmpty(settings.execution_lane_override);
const fallback = objectOrEmpty(
repoAnswers.execution_lane_override ?? repoAnswers.execution_lane,
);
const initialLane = (name: string): Lane =>
(configured[name] ?? fallback[name]) === "platform" ? "platform" : "repo";
const [mode, setMode] = useState<"observe" | "active">(
settings.autonomy_mode === "observe" ? "observe" : "active",
);
const [summary, setSummary] = useState(settings.observe_summary === true);
const [lanes, setLanes] = useState<Record<string, Lane>>(() =>
Object.fromEntries(LANE_AGENTS.map((name) => [name, initialLane(name)])),
);
const [prefix, setPrefix] = useState(
typeof settings.command_prefix === "string"
? settings.command_prefix
: typeof repoAnswers.command_prefix === "string"
? repoAnswers.command_prefix
: "",
);
const [busy, setBusy] = useState(false);
const [message, setMessage] = useState<string | null>(null);

async function save() {
setBusy(true);
setMessage(null);
const response = await fetch(\`/api/v1/projects/\${projectId}\`, {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({
settings: {
...settings,
autonomy_mode: mode,
observe_summary: summary,
execution_lane_override: lanes,
command_prefix: prefix.trim() || null,
},
}),
});
setBusy(false);
if (!response.ok) {
const body = (await response.json().catch(() => null)) as {
error?: { message?: string };
} | null;
setMessage(body?.error?.message ?? \`could not save project policy (\${response.status})\`);
return;
}
setMessage("project policy saved");
router.refresh();
}

return (
<div className="flex flex-col gap-5 border border-(--line) p-5">
<div className="flex flex-wrap items-center gap-2">
<button type="button" onClick={() => setMode("observe")}>
<PillTag active={mode === "observe"}>observe first</PillTag>
</button>
<button type="button" onClick={() => setMode("active")}>
<PillTag active={mode === "active"}>active</PillTag>
</button>
</div>
<p className="text-sm leading-relaxed text-(--mut)">
{mode === "observe"
? "Manual runs stay inside Facility and scheduled agents do not fire. GitHub remains unchanged unless a summary is explicitly enabled."
: "Scheduled agents may run and control-plane sessions publish their normal GitHub feedback."}
</p>
{mode === "observe" ? (
<label className="flex items-center gap-2 text-sm text-(--mut)">
<input
type="checkbox"
checked={summary}
onChange={(event) => setSummary(event.target.checked)}
/>
publish one terminal summary for a completed architect run
</label>
) : null}

<div className="grid gap-4 border-t border-(--line) pt-5 sm:grid-cols-3">
{LANE_AGENTS.map((name) => (
<LaneSelect
key={name}
label={name}
value={lanes[name] ?? "repo"}
onChange={(value) => setLanes((current) => ({ ...current, [name]: value }))}
/>
))}
<label className="flex flex-col gap-1 text-[11px] font-medium text-(--dim)">
command prefix
<input
className="border border-(--line) bg-transparent px-3 py-2 font-mono text-[12px] text-(--ink)"
value={prefix}
maxLength={32}
placeholder="none — or fx"
pattern="[a-z0-9][a-z0-9_-]*"
onChange={(event) => setPrefix(event.target.value.toLowerCase())}
/>
</label>
</div>
<p className="text-[11px] leading-relaxed text-(--dim)">
These operator settings apply to {repos.length || "no"} connected{" "}
{repos.length === 1 ? "repository" : "repositories"} without modifying their manifests.
{prefix ? \` Facility listens to /\${prefix} architect and /\${prefix} builder.\` : ""}
</p>
<div className="flex items-center gap-3">
<Button size="sm" disabled={busy} onClick={() => void save()}>
{busy ? "saving…" : "save autonomy policy"}
</Button>
{message ? <span className="font-mono text-[11px] text-(--dim)">{message}</span> : null}
</div>
</div>
);
}

function LaneSelect({
label,
value,
onChange,
}: {
label: string;
value: Lane;
onChange: (value: Lane) => void;
}) {
return (
<label className="flex flex-col gap-1 text-[11px] font-medium text-(--dim)">
{label} lane
<select
className="border border-(--line) bg-(--bg) px-3 py-2 text-[12px] text-(--ink)"
value={value}
onChange={(event) => onChange(event.target.value as Lane)}
>
<option value="repo">repository</option>
<option value="platform">Facility</option>
</select>
</label>
);
}
13 changes: 12 additions & 1 deletion services/api/src/executors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ import {
toHarnessSpace,
} from "./harness.js";
import { MCP_TOOL_PERMISSIONS } from "./mcp-policy.js";
import { githubFeedbackMode } from "./project-policy.js";
import { createNextDraftVersion, publishRegistryVersion } from "./registry.js";
import { cancelRun } from "./sandbox/orchestrator.js";
import { appendRunEvents, TERMINAL_RUN_STATUSES } from "./sandbox/state.js";
Expand Down Expand Up @@ -892,6 +893,15 @@ async function executeKnownMcpTool(
const number = requiredNumber(args.number, "number");
const repoId = optionalString(args.repoId);
const agentName = requiredString(args.agentName, "agentName");
const project = (
await db
.select()
.from(projects)
.where(and(eq(projects.orgId, orgId), eq(projects.id, projectId)))
.limit(1)
)[0];
if (!project) throw new Error("project_not_found");
const feedback = githubFeedbackMode(project.settings);
const issues = await db
.select()
.from(ghIssues)
Expand Down Expand Up @@ -930,6 +940,7 @@ async function executeKnownMcpTool(
engine: agent.engine,
trigger: {
type: "mcp_issue",
githubFeedback: feedback,
repo: { id: repo.id, owner: repo.owner, name: repo.name },
issue: { number },
},
Expand All @@ -952,7 +963,7 @@ async function executeKnownMcpTool(
(options.config?.githubAppId && options.config.githubAppPrivateKey
? createGithubClientFactory(options.config)
: undefined);
if (factory) {
if (feedback === "live" && factory) {
try {
const github = await createGithubClientForRepo(db, factory, repo);
await github.createIssueComment(
Expand Down
17 changes: 16 additions & 1 deletion services/api/src/github/kickstart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,21 @@ export async function kickstartRepo(args: {
execution_lane: args.answers.execution_lane ?? { architect: "repo", builder: "repo" },
};
const render = await renderFacilityInit(renderAnswers, { existingFiles: existing });
const currentAnswers =
args.repo.renderAnswers &&
typeof args.repo.renderAnswers === "object" &&
!Array.isArray(args.repo.renderAnswers)
? (args.repo.renderAnswers as Record<string, unknown>)
: {};
const persistedRenderAnswers = {
...renderAnswers,
...(currentAnswers.execution_lane_override
? { execution_lane_override: currentAnswers.execution_lane_override }
: {}),
...(currentAnswers.command_prefix !== undefined
? { command_prefix: currentAnswers.command_prefix }
: {}),
};
const branch = "facility/kickstart";
const baseSha = await client.getDefaultBranchSha();
const baseCommit = await client.getCommit(baseSha);
Expand All @@ -327,7 +342,7 @@ export async function kickstartRepo(args: {
.set({
fingerprintStatus: "pending_merge",
fingerprint: { ...render.manifest, files: render.manifest.files },
renderAnswers,
renderAnswers: persistedRenderAnswers,
updatedAt: new Date(),
})
.where(eq(repos.id, args.repo.id));
Expand Down
Loading