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
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions plugins/workflow-stages/PLUGIN_OVERVIEW.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@ turn needs you. Every automatic move skips sticky stages, and "work stops" only
undoes the move "a turn starts" made, so a thread you filed by hand while it was
running stays filed.

One automatic move ships on: a message that asks for a plan files its thread
under Planning. Filing is otherwise the agent's job, and plan mode is the one
moment an agent may not do it — `bb stages set` is a mutating call, and plan
mode forbids those — so without this a `/plan` thread sits unfiled for the whole
planning session. It reads the composer's own command mention rather than the
message text, so a thread that merely discusses `/plan` is not filed.

## How it works

Ribbon owns the sidebar — rendering, drag-and-drop, manual order, and the stored
Expand Down
14 changes: 14 additions & 0 deletions plugins/workflow-stages/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,13 +70,27 @@ Configured in the same editor:
| Trigger | Default |
| --- | --- |
| A thread with no placement | Inbox |
| A message asks for a plan | Planning |
| A turn starts | off |
| Work stops | off |
| A question, an approval, or a failed turn | off |

"Work stops" only undoes the move "a turn starts" made, so a thread you filed by
hand while it was running stays filed. Every automatic move skips sticky stages.

"A message asks for a plan" is the one that ships **on**, because it is the one
trigger the agent cannot answer for itself. Filing is otherwise the agent's job:
it reads the stage table below and runs `bb stages set`. In plan mode it may not
— that is a mutating shell call, and plan mode is the one moment an agent is not
allowed to make those — so a thread you started with `/plan` would sit in Inbox
through the whole planning session and only get filed once the plan was
approved, by which point the honest stage is Building.

Detection is the composer's, not a string match: bb's plan action is a
provider-declared slash command, and the send carries it as a structured mention
rather than as text. So a message that merely *talks* about `/plan` is not one,
and a provider that calls its plan action something else is still recognised.

The last one ships off on purpose. A stage says where the work is; whether it
needs you is a different axis, and Ribbon already draws that on the row itself.
Pointing it at a stage means a thread bounces there every time an agent asks a
Expand Down
17 changes: 14 additions & 3 deletions plugins/workflow-stages/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -447,6 +447,15 @@ function WorkflowSettings() {
{options(false)}
</select>
</Row>
<Row label="When a plan is asked for">
<select
className={cn(FIELD, "max-w-64")}
value={state.config.planStageId ?? ""}
onChange={(event) => configure({ planStageId: event.target.value || null })}
>
{options(true)}
</select>
</Row>
<Row label="When a turn starts">
<select
className={cn(FIELD, "max-w-64")}
Expand Down Expand Up @@ -475,9 +484,11 @@ function WorkflowSettings() {
</select>
</Row>
<p className="text-sm text-subtle-foreground">
A question, an approval, or a failed turn counts as needing you. Automatic moves skip
any stage marked <em>only the user files here</em>, and "when work stops" only undoes
the move "when a turn starts" made.
A plan is asked for when a message opens with your provider's plan command, which is
the one trigger the agent cannot answer itself. A question, an approval, or a failed
turn counts as needing you. Automatic moves skip any stage marked{" "}
<em>only the user files here</em>, and "when work stops" only undoes the move "when a
turn starts" made.
</p>
</div>
</div>
Expand Down
99 changes: 98 additions & 1 deletion plugins/workflow-stages/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@
// plugin — `getGroupingCatalogV1` — plus the placement calls in ribbon.ts.
// Nothing has to be registered with Ribbon, and nothing here replaces bb's
// sidebar.
import { defineRpcContract, type BbPluginApi } from "@get-bb/plugin-sdk";
import {
defineRpcContract,
type BbPluginApi,
type PluginDispatchInput,
} from "@get-bb/plugin-sdk";
import { z } from "zod";
import { GLYPHS, GLYPH_NAMES, GROUPING_GLYPH } from "./icons";
import {
Expand Down Expand Up @@ -179,6 +183,15 @@ export default async function plugin(bb: BbPluginApi) {
const suffix = marks.length === 0 ? "" : ` (${marks.join(", ")})`;
lines.push(`- ${stage.id} — ${stage.label}${suffix}: ${stage.description || "No rule set."}`);
}
const planStage = config.planStageId === null ? null : store.get(config.planStageId);
if (planStage !== null) {
lines.push(
"",
`A message that asks for a plan files the thread under ${planStage.label} on its own,`,
"before you read this. Rule 1 is already satisfied there — leave it, and move it on",
"when the plan is agreed and you start building.",
);
}
lines.push(
"",
"A stage marked user-only is the user's call: never file into or out of one —",
Expand Down Expand Up @@ -269,6 +282,90 @@ export default async function plugin(bb: BbPluginApi) {
}
}

// ------------------------------------------------------------- plan mode

// Nothing on a thread says "this one is planning": no event carries it, and
// `ThreadResponse` has no field for it. The message does. bb's plan composer
// action is a provider-declared slash command, so a plan-mode send arrives
// as a text block whose leading mention is that command — which is why this
// hangs off the dispatch hook rather than off `bb.events`.
//
// The agent cannot file itself here even though the instructions ask it to:
// `bb stages set` is a mutating shell call, and plan mode is the one moment
// an agent is not allowed to make those. So the plugin does it instead.
const FALLBACK_PLAN_COMMANDS: ReadonlySet<string> = new Set(["plan"]);
let planCommands: ReadonlySet<string> = FALLBACK_PLAN_COMMANDS;
let planCommandsReadAt = 0;
/** Long enough that installing a provider mid-session is picked up. */
const PLAN_COMMAND_TTL_MS = 5 * 60 * 1000;

/**
* Ask each provider what it calls its plan action, so this recognises a
* provider that named it something other than "plan". Never awaited from the
* hook: the dispatch pass holds a server-wide lock, so it reads whatever the
* last refresh left and the first send of a session runs on the fallback.
*/
async function refreshPlanCommands(): Promise<void> {
planCommandsReadAt = Date.now();
try {
const names = new Set(FALLBACK_PLAN_COMMANDS);
for (const provider of await bb.sdk.providers.list()) {
for (const action of provider.composerActions) {
if (action.kind === "plan") names.add(action.command.name);
}
}
planCommands = names;
} catch (cause) {
// Bind-gated before the server is listening, and a provider list is not
// worth failing a send over. The fallback still names Claude Code's.
bb.log.debug(
`plan command refresh skipped: ${cause instanceof Error ? cause.message : String(cause)}`,
);
}
}

/**
* A plan send, told from an ordinary one. The mention must open the message —
* the composer prepends the command — and be a command rather than a skill,
* so a skill that happens to be called "plan" does not file the thread.
*/
function isPlanDispatch(input: PluginDispatchInput): boolean {
return input.blocks.some(
(block) =>
block.type === "text" &&
block.mentions.some(
(mention) =>
mention.start === 0 &&
mention.resource.kind === "command" &&
mention.resource.source === "command" &&
planCommands.has(mention.resource.name),
),
);
}

bb.experimental_hooks.on("message.dispatch", (context) => {
// Fail-closed, time-boxed, and holding a lock every other send queues
// behind: decide synchronously, never throw, and let the move happen off
// the pass. The answer is always the same one — this hook watches, it does
// not gate. Re-running on a drain or a retry is harmless because moving a
// thread to the stage it is already in is a no-op.
try {
if (isPlanDispatch(context.input)) {
void automaticMove(context.thread.id, store.config().planStageId);
}
if (Date.now() - planCommandsReadAt > PLAN_COMMAND_TTL_MS) {
void refreshPlanCommands();
}
} catch (cause) {
bb.log.debug(
`plan filing skipped: ${cause instanceof Error ? cause.message : String(cause)}`,
);
}
return { action: "proceed" };
});

// ------------------------------------------------------------ transitions

bb.events.on("thread.active", ({ thread }) => {
void automaticMove(thread.id, store.config().activeStageId);
});
Expand Down
6 changes: 6 additions & 0 deletions plugins/workflow-stages/skills/workflow-stages/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@ yet, so the evidence rule below would say "wait" — and a whole planning sessio
would pass with the thread sitting where nobody can see it was picked up. Decide
from what you are about to do, not from what has happened.

The exception is plan mode, which files itself. A message sent with the plan
command moves its thread to the planning stage before you read anything, because
you could not do it yourself there — `bb stages set` mutates, and plan mode
forbids that. So in a plan-mode turn the first move is already made: leave it,
and move the thread on when the plan is agreed and you start building.

If no stage describes what you are about to do — a question, a code read, a
chore that fits none of them — leave the thread in the default stage. That
escape is for no stage fitting. It is not for being unsure which of two fits:
Expand Down
10 changes: 10 additions & 0 deletions plugins/workflow-stages/stages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ export const configSchema = z
idleStageId: z.string().nullable(),
/** Entered when the agent asks a question or a turn fails. */
attentionStageId: z.string().nullable(),
/** Entered when a message asks the provider for a plan. */
planStageId: z.string().nullable(),
})
.strict();
export type WorkflowConfig = z.output<typeof configSchema>;
Expand Down Expand Up @@ -122,11 +124,18 @@ const SEED: ReadonlyArray<SeedStage> = [
// already draws per row. Filing a question-asking thread back to the default
// stage would undo the first move on every clarification, so the attention
// bounce ships off and stays a setting for anyone who wants that queue.
//
// Plan mode is the exception that ships on. The other three read a thread's
// runtime — busy, idle, waiting — and guess a workflow position from it, which
// is why they are off by default. Asking for a plan is not a guess: the user
// said what the next stretch of work is, and Planning is the stage that says
// it back.
const DEFAULT_CONFIG: WorkflowConfig = {
defaultStageId: "inbox",
activeStageId: null,
idleStageId: null,
attentionStageId: null,
planStageId: "planning",
};

/** A stored icon is only as trustworthy as whatever wrote it. */
Expand Down Expand Up @@ -218,6 +227,7 @@ export function createStageStore(database: BetterSqlite3.Database): StageStore {
activeStageId: resolve("activeStageId", DEFAULT_CONFIG.activeStageId),
idleStageId: resolve("idleStageId", DEFAULT_CONFIG.idleStageId),
attentionStageId: resolve("attentionStageId", DEFAULT_CONFIG.attentionStageId),
planStageId: resolve("planStageId", DEFAULT_CONFIG.planStageId),
};
}
function writeConfig(patch: Partial<WorkflowConfig>): WorkflowConfig {
Expand Down