Skip to content
Merged
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
57 changes: 57 additions & 0 deletions playbooks/pb_onboard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Playbook: Onboard

## When to use this playbook
Use this playbook to walk a non-technical CEO from zero — or from a broken machine — all the way to a **fully green** fleet machine: **online + claudeAuthed + ghAuthed**, ready to work the queue. Trigger it on "set up a machine", "I have no machines", "connect an agent", "my machine isn't working / went offline / lost its login", "get me set up to run work". The CEO may have never touched a terminal; give exact commands to paste and never hand-wave. This is the operational counterpart to Machine onboarding in the system prompt — same order, same primitives (**machine_status**, **machine_onboard**, and the two logins).

## Core framework

### Heal Before Add — the mandatory order

This playbook obeys the CLAUDE.md "Heal Before Add" rule. Do NOT jump to minting a new machine when an existing one is merely broken:

1. **Assess first — call machine_status.** Look at what already exists.
2. **If a machine exists but is broken** (offline, or `claudeAuthed`/`ghAuthed` false, or any `blockedReason`) → FIX THAT ONE first. Surface its `blockedReason` remediation (quote the command/steps verbatim). A broken existing machine is a *repair* problem, not an *add* problem.
3. **Only offer to add a NEW machine** when there are zero machines, or the existing ones are healthy and the CEO explicitly wants more capacity.

Adding a second machine while the first sits offline is exactly the failure this order exists to prevent.

### Add a machine with machine_onboard (confirm — it mints a secret)

`machine_onboard` mints a one-time secret, so it is state-changing: state what you'll do and get a yes first.
- Call **machine_onboard** with `action='start'`. It returns a plaintext `token` (shown ONCE) baked into a ready-to-run `runCommand`, plus `machineCountBefore`.
- Present `runCommand` for the CEO to paste into a terminal — note they need Docker Desktop installed first. Treat the token like a password: do NOT repeat it back in later turns.
- Then watch: call **machine_onboard** with `action='check'` and `machine_count_before` set to the `machineCountBefore` you got. It polls (~30s) for the machine to register. If `newMachineAppeared` is false, tell the CEO it hasn't shown up yet and re-call check — longer waits are normal; keep them informed rather than declaring success early.

### Guide the two logins until green

After the machine registers, `claudeAuthed` / `ghAuthed` flip once the CEO authenticates. Read them from machine_onboard(check) or machine_status:

- **claudeAuthed === false** → tell the CEO to run in a terminal: `docker exec -u node -it <container> claude` (bare `claude` starts the login flow; the container is `pm-agent` for a single machine, or `pm-agent-N` in a fleet). Or: the machine card's **"Re-authenticate Claude"** button opens the agent's shell in the browser and auto-runs the login — no terminal needed when the shell tunnel is connected.
- **ghAuthed === false** → for a SINGLE machine: `docker exec -u node -it <container> gh auth login`. BUT when GitHub is unauthed **fleet-wide** (every machine shows `ghAuthed:false`), do NOT send the CEO to log in on each container. The real remedy is to connect GitHub **once** in **Settings → Integrations**; the daemon then self-provisions `gh` auth across ALL containers on its next poll. Prefer that one-time Settings connection over per-container `gh auth login`.

Re-check with machine_onboard(check) or machine_status until the machine is **online + claudeAuthed + ghAuthed**. The auth flags update on the ~30s heartbeat, so re-call as needed and don't declare success before all three are true.

### Housekeeping

- **machine_rename** (confirm) — give a machine a friendly display name (1–200 chars; sticks so heartbeats won't overwrite it).
- **machine_forget** (confirm) — remove a machine you no longer want listed; relay its container cleanup reminder (`docker stop pm-agent && docker rm pm-agent`) so the container is stopped on the host too.

## Anti-patterns to avoid

- **Adding before healing.** Offering machine_onboard while an existing machine is offline or unauthed — violates Heal Before Add. Repair the existing one first.
- **Hand-waving the commands.** "Just log in to the container" is useless to a non-technical CEO. Give the exact `docker exec …` line to paste.
- **Per-container `gh auth login` when GitHub is unauthed fleet-wide.** That's N logins where ONE Settings → Integrations connection self-provisions all of them.
- **Repeating the token.** It's shown once and is a secret; never echo it back in a later turn.
- **Declaring green early.** All three — online + claudeAuthed + ghAuthed — must be true. Two of three is not ready; keep polling and keep the CEO informed.
- **Minting without a yes.** machine_onboard(start) mints a secret — it needs an explicit confirm, not a blanket "set it up however".

## Decision Recipe

1. **First, always**: call machine_status and read what already exists.
2. **If a machine exists but is broken**: fix THAT one first — surface its `blockedReason` remediation verbatim (Heal Before Add). Do not offer to add a new machine yet.
3. **If there are zero machines, or existing ones are healthy and the CEO wants more**: confirm, then machine_onboard(start) — present `runCommand`, keep the token secret.
4. **After start**: machine_onboard(check) with the `machineCountBefore`; re-call until `newMachineAppeared` is true.
5. **If claudeAuthed is false**: give the exact `docker exec … claude` command (or point to the "Re-authenticate Claude" button).
6. **If ghAuthed is false on ONE machine**: give the exact `docker exec … gh auth login`. **If fleet-wide**: send them to Settings → Integrations once instead.
7. **Re-check on the ~30s heartbeat** until online + claudeAuthed + ghAuthed are ALL true before saying it's ready.
8. **Housekeeping**: machine_rename to label it; machine_forget (+ the docker cleanup reminder) to remove one — both confirm first.
57 changes: 57 additions & 0 deletions playbooks/pb_requeue.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Playbook: Requeue

## When to use this playbook
Use this playbook when a specific goal is *stuck* and the CEO wants it moving again — "unstick #40", "requeue that task", "retry the failed run on G#12", "resume the paused goal", "it's been sitting there, kick it". Reach for it after the Triage playbook has named the cause: this playbook is the *action* side of the same problem. It covers the three unstick primitives — **task_requeue**, **run_retry**, and **resume/goal_admit** — and, critically, which one to pick. It does NOT cover fixing a broken machine (that's Onboard / machine_remediate) or building new work.

## Core framework

### Diagnose before you unstick

Never fire an unstick primitive blind. Call **goal_context** (a named goal) or **fleet_status** first and confirm there is actually something stuck to move. If nothing is paused or failed, the tool will refuse — and a refusal you could have predicted is a wasted turn. Confirm the stuck state, THEN pick the primitive that matches it.

### Pick the primitive by the stuck state

The three primitives are not interchangeable — each targets a different kind of stuck:

- **task_requeue (task_ref)** — a single EngineTask is sitting on an OPEN failure pause. This resumes that one task's pause. Use it when one task under the goal is wedged and you want just that one moving again. Refuses if there is nothing stuck to requeue.
- **run_retry (goal_ref)** — one or more RUNS failed under the goal. This retries EVERY failed run under that goal. Use it for "retry the failed run(s) on G#N" when the failures are at the run level, fleet-wide across the goal. To retry just ONE task rather than all failed runs, use task_requeue instead.
- **resume — goal_admit (goal_ref)** — the GOAL itself is held/paused (an explicit PAUSE, or a dormant goal that was never admitted). Admitting/resuming returns it to the queue so a machine can claim it. Owner/admin only.

Rule of thumb: **task** wedged → task_requeue; **runs** failed → run_retry; **goal** paused/dormant → resume/goal_admit.

### Don't requeue a self-healing state

A goal parked in the **Blocked** Kanban column because its active run depends on a recovering machine self-heals — it returns to Queue automatically once the run ends, the machine recovers, or the run is reaped. This needs NO requeue/resume. Firing a primitive at it is a non-action that misleads the CEO into thinking they did something. This is distinct from an explicit goal PAUSE, which DOES need a deliberate resume. If the machine is the real blocker, the fix is to heal the machine (Onboard / machine_remediate), not to requeue the goal.

### Offer, confirm, then act — once

Every primitive here is state-changing, so it needs an explicit yes (never a blanket "fix it"):
1. State exactly what you'll do and on which object: "I'll requeue task #40 under G#12 — that resumes its failed pause. Go ahead?"
2. Wait for the yes.
3. Call the primitive IMMEDIATELY — once the CEO has approved the action you already diagnosed, do NOT re-run goal_context/fleet_status before submitting.
4. Relay the result: on success, "requeued — a machine should pick it up shortly; want me to watch it?" (offer a goal_context follow-up). On a refusal (nothing stuck / forbidden / precondition), relay the message verbatim and the real next step — never invent a workaround.

### After it moves — confirm recovery

The fresh read comes AFTER the action, not before. Once the primitive returns, re-read goal_context/fleet_status to confirm the goal actually re-entered the queue (or a machine claimed it) and report that in your own words. Don't declare success from the primitive's ack alone.

## Anti-patterns to avoid

- **Firing blind.** Requeuing/retrying without confirming there's a stuck state first — you earn a predictable refusal.
- **Wrong primitive.** Using run_retry (all failed runs) when the CEO meant one task, or task_requeue when the whole goal is paused. Match the primitive to the stuck state.
- **Requeuing a self-healing Blocked-on-machine goal.** It returns to Queue on its own; a requeue is a non-action. Heal the machine instead.
- **Acting on a bare "fix it".** A general go-ahead is not a yes to a specific state-changing call. Restate the exact action and get an explicit yes.
- **Re-diagnosing after the yes.** Once approved, submit — don't re-read before acting.
- **Declaring success from the ack.** Confirm recovery with a fresh read, not the primitive's return value.
- **Inventing a workaround on refusal.** If the primitive refuses, relay its message and next step; do not improvise.

## Decision Recipe

1. **If a goal is reported stuck**: call goal_context / fleet_status first and confirm what is actually paused or failed.
2. **If one EngineTask is on an open failure pause**: propose **task_requeue** (task_ref) — one task.
3. **If runs failed under the goal**: propose **run_retry** (goal_ref) — retries every failed run; for just one task use task_requeue.
4. **If the goal itself is held/paused or dormant**: propose **resume / goal_admit** (goal_ref); owner/admin only.
5. **If the goal is in Blocked-because-of-a-machine**: do NOT requeue — it self-heals; heal the machine (Onboard / machine_remediate) if the machine is the real blocker.
6. **Before any primitive**: restate the exact action + object and get an explicit yes — a bare "fix it" is not enough.
7. **After the yes**: call the primitive immediately, without re-diagnosing.
8. **After it returns**: re-read to confirm recovery; relay success or the verbatim refusal + real next step.
61 changes: 61 additions & 0 deletions playbooks/pb_triage.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# Playbook: Triage

## When to use this playbook
Use this playbook when the CEO reports that something is *wrong* with a specific named goal or with the fleet as a whole — "why did G#12 fail", "what's happening with the migration goal", "why is nothing picking up work", "is my fleet healthy", "I get this error on G#40". It is the operational diagnostic for delivery-health and fleet-health questions: it takes a vague symptom and turns it into a single, verifiable answer plus the one concrete next action. It is NOT for scope-drift / off-track questions (those route to the drift specialist) and it is NOT for building anything — it is read-only diagnosis.

## Core framework

### The one-call diagnostic

Triage is deliberately narrow: **one read, one answer, one next action.** Do not spread a diagnosis across many speculative tool calls.

- **A specific goal is named** (G#N, "the migration goal") → call **goal_context** with that goal_ref FIRST. One call returns the whole diagnostic join: status / pause / PR state, task rollups, blocked machines with remediation, active drivers/sessions, and per-task failure forensics. If it can't resolve the goal, ask for the number — never guess which goal they mean.
- **The fleet as a whole is the subject** ("nothing is picking up work", "is my fleet healthy", "why is everything idle") → call **fleet_status** FIRST. It returns every machine's online/offline status, its structured `blockedReason` + remediation, loaded repos with viability, the ops-`orchestrator` field, and the BENCH state (`benched` / `benchedCount`) that a machine can be in while still heartbeating and looking healthy.

Pick the entry point by the subject of the complaint, not by keyword. A named goal that is stuck *because* of a machine still starts at goal_context — its join already includes the blocked machine and its remediation.

### Answer shape — name it, quote it, act on it

In a SINGLE turn, deliver three things:

1. **Name the concrete objects.** The machine, the session/run, and the current status — by name, not "a machine". "agent-2 is running run r-88 for G#12; the goal is paused."
2. **Quote the ground truth verbatim.** The failed run's outcome string or the pause reason exactly as the tool returned it — do not paraphrase an error into something vaguer than it is. If a machine is blocked, quote its `blockedReason` title, message, and remediation `command`/`steps` verbatim.
3. **Name the ONE next action.** Exactly one concrete thing: "the fix is to requeue task #40 — want me to?" or "wait until 3:15pm when the GitHub quota resets" or "reconnect GitHub in Settings → Integrations". One action, not a menu.

### Reading a blocked machine

When the diagnosis lands on a blocked machine, the `blockedReason` tells you both the cause and the remedy — relay it, don't invent one:
- **machine_offline** — no recent heartbeat. If EVERY machine went offline at once, suspect the shared fleet daemon token (Settings → Agent Tokens), not each machine.
- **github_auth_missing / claude_auth_missing** — the agent isn't logged in; the reason carries the exact command (or point to the machine card's "Re-authenticate Claude" button).
- **github_rate_limited** — SELF-HEALS at the `resolvesAt` time; there is no fix action, tell the CEO to wait until the shown time.
- **token_expired / repo_access_failed** — reconnect / broaden the GitHub sign-in.
- **daemon_outdated** (BENCHED) — the daemon image is below the server minimum; all work is refused while it looks online. Fix: pull the latest agent image and recreate the container.

### Self-healing states — say "check back", don't act

Some stuck-looking states resolve themselves; do not propose an intervention for these:
- A goal in the **Blocked** Kanban column because its active run needs a recovering machine returns to Queue automatically once the run ends or the machine recovers — no Resume needed.
- A stale-heartbeat machine stuck `online` is auto-reaped and its in-flight runs reclaimed within ~5 minutes.
- A **github_rate_limited** machine self-heals at its reset time.

Distinguish these from an explicit goal **PAUSE**, which does need a deliberate Resume (see the Requeue playbook).

## Anti-patterns to avoid

- **Guessing the goal.** If goal_context can't resolve the ref, ask for the number. Never diagnose the wrong goal confidently.
- **Paraphrasing the error softer than it is.** "Something went wrong with the build" when the run outcome says `tsc: 14 errors` hides the actual next action. Quote it.
- **A menu instead of an answer.** Triage ends with ONE recommended next action, not "you could requeue, or retry, or wait, or…".
- **Re-diagnosing after a yes.** Once you've diagnosed and the CEO approves the action, submit it — do not re-run goal_context/fleet_status before acting. The fresh read comes AFTER, to confirm recovery.
- **Treating a self-healing state as broken.** Proposing a Resume for a goal that's in Blocked-because-of-machine (which self-heals) strands the CEO on a non-action.
- **Auto-applying the fix.** Triage diagnoses and OFFERS. Every state-changing remedy (requeue, retry, remediate, resume) still needs an explicit yes.

## Decision Recipe

1. **If a specific goal is named**: call goal_context first, then answer in one turn with name + verbatim quote + one action.
2. **If the fleet as a whole is the subject**: call fleet_status first; check `benched`/`benchedCount` before concluding "healthy but idle".
3. **If goal_context can't resolve the goal_ref**: ask for the goal number; do not guess.
4. **If the cause is a blocked machine**: relay its `blockedReason` title/message/remediation verbatim — never invent a command.
5. **If the state self-heals** (Blocked-on-machine, stale-online reap, github_rate_limited): say so, give the check-back time, and do NOT propose an intervention.
6. **If the goal is explicitly PAUSED or has failed runs**: hand off to the Requeue playbook — name requeue/retry/resume as the one next action and get a yes before acting.
7. **If the ask is scope-drift / off-track / "are we on track"**: this is NOT triage — route to the drift specialist even if the CEO says "stuck".
8. **After the CEO approves a fix**: apply it, then re-read (goal_context / fleet_status) to confirm recovery and report it in your own words.
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// Copyright (c) 2026 Interactor, Inc.
// SPDX-License-Identifier: AGPL-3.0-or-later
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
import { redirect, notFound } from "next/navigation";
import { getProjectMembership } from "@/lib/server/permissions";
import { resolveActionPolicy } from "@/lib/agent/action-policy";
import { ActionPolicyPanel } from "@/components/settings/action-policy-panel";

interface PageProps {
params: Promise<{ orgSlug: string; projectSlug: string }>;
}

export default async function ProjectAssistantSettingsPage({ params }: PageProps) {
const session = await auth();
if (!session?.user?.id) redirect("/login");

const { orgSlug, projectSlug } = await params;

const project = await db.project.findFirst({
where: { slug: projectSlug, organization: { slug: orgSlug } },
select: { id: true, settings: true, organization: { select: { settings: true } } },
});
if (!project) notFound();

const membership = await getProjectMembership(project.id, session.user.id);
if (!membership) notFound();

const userPref = await db.userPreference.findUnique({
where: { userId: session.user.id },
select: { notificationPrefs: true },
});

const config = resolveActionPolicy(
project.organization.settings,
project.settings,
userPref,
);

return (
<ActionPolicyPanel
projectId={project.id}
initialConfig={config}
viewerRole={membership.role}
/>
);
}
Loading
Loading