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
26 changes: 15 additions & 11 deletions plugins/stewards/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,9 +108,10 @@ These were settled while designing; each changes what gets built.
a later wake brings it back. A steward waiting on someone else stays in
the sidebar between wakes too, and its finished workers and their reviewers
stay with it (§5, "Waiting"). thread-briefs is asked,
through each thread's metadata, not to archive or rename stewards and not
to group the project steward: between wakes a steward reads done while
its entity is still open, and its title carries the entity key. (This
through each thread's metadata, to leave stewards alone entirely: between
wakes a steward reads done while its entity is still open, and its title
carries the entity key, so a brief is wrong on every count — and one is a
model call per idle. (This
first left archiving to thread-briefs, which archived every steward whose
brief read done, whoever was next. The plugin now decides from the status
record instead.)
Expand Down Expand Up @@ -844,13 +845,14 @@ deployment: # where a merge is confirmed live (BB-53); omi
stewarded, the sweep deletes its section once nothing unarchived is left
in it, since its stewards are archived as their bindings end; a thread
filed there by hand keeps it.
- Stewards, the project steward included, are kept out of thread-briefs'
status sections (`group: false`), where one idle between wakes would read
done. thread-briefs only moves threads in and out of its own sections, so
it leaves the project section alone. Workers and reviewers are tagged the
same way when the `thread.created` handler files them, so thread-briefs
never moves one out of the section or auto-archives it. The project
steward is titled "<project name> project steward".
- Stewards, the project steward included, are kept out of thread-briefs
entirely (`threadBriefs: false`): no summary, no stored brief, no status
section, no ring, no auto-archive. One idle between wakes would read done,
and a summary would be a model call per idle. thread-briefs only moves
threads in and out of its own sections, so it leaves the project section
alone. Workers and reviewers are tagged the same way when the
`thread.created` handler files them. The project steward is titled
"<project name> project steward".
- Workers are children of their steward, owned by it through
`--lifecycle-owner-thread` and started with `bb thread spawn --project …
--parent-self --parent-notifications off --lifecycle-owner-thread
Expand Down Expand Up @@ -1560,7 +1562,9 @@ when unread, blue animated while their agent runs, grey otherwise — except a
draft, which keeps bb's own `ThreadStatusGlyph`. A worker or reviewer child row
under an effort, and a one-off thread's own children, use those same one-off
glyphs and carry no role tag; the indent is the only sign of the role. The
project steward footer row is the grey ring.
project steward footer row is the grey ring. Steward rows carry no
thread-briefs ring at all: the single off switch leaves every steward thread
with no brief, so this list's own glyph is the only one they draw.

**The status phrase (BB-76).** First match wins: `needs you`; `blocked on
<blockedOn>`; `held behind <holdBehind keys>` when `holdBehind` is non-empty,
Expand Down
26 changes: 12 additions & 14 deletions plugins/stewards/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,21 +188,19 @@ bb. [DESIGN.md](DESIGN.md) is the design; this is how it is built and run.
refuses an archived thread, so it runs `bb thread unarchive <id>` first, or
starts a fresh worker.
- **thread-briefs.** Every steward, the project steward included, is
spawned with `threadBriefs: { rename: false, group: false, archive: false }`
in this plugin's metadata namespace (see thread-briefs' "Threads a plugin
runs"). thread-briefs then never renames a steward, whose title carries
its entity key, never moves one into a status section or auto-archives
it, since between wakes it reads done while its entity is still open.
spawned with `threadBriefs: false` in this plugin's metadata namespace (see
thread-briefs' "Threads a plugin runs"). That turns thread-briefs off for
the thread entirely: no summary, no stored brief, no rename, no status
section, no ring and no auto-archive. A steward's title carries its entity
key, and between wakes it reads done while its entity is still open, so a
brief would be wrong on every count — and one is a model call per idle.
Workers and reviewers a steward spawns are tagged the same way, so
thread-briefs leaves them in the project's section too.
A ticket or pull request steward's row draws the stage ring like any
thread's. The project steward also gets `ring: false`: it has no stage of
its own, so its row keeps bb's own indicators. The project steward
is titled "<project name> project steward". The sweep tags threads
spawned before this, sets parent notifications off on a steward an earlier
version parented to the project steward (keeping the parent), and renames an
older project steward once. After that, a project steward renamed by hand
keeps its name.
thread-briefs leaves them in the project's section too. The project steward
is titled "<project name> project steward". The sweep tags threads spawned
before this, sets parent notifications off on a steward an earlier version
parented to the project steward (keeping the parent), and renames an older
project steward once. After that, a project steward renamed by hand keeps
its name.
- **The sidebar and the unread dot.** A steward is put in its project's
section when it is spawned, and a worker or reviewer the plugin did not
place is filed there by the `thread.created` handler. A finished worker
Expand Down
12 changes: 6 additions & 6 deletions plugins/stewards/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -424,7 +424,7 @@ describe("a Linear ticket steward", () => {
const [project, steward] = t.spawns;
expect(project).toMatchObject({ title: "Acme project steward", providerId: "claude-code", model: "claude-opus-5-5", reasoningLevel: "high" });
// thread-briefs keeps its hands off: no renames, auto-archiving or grouping for any steward, and no row ring for the project steward.
expect(project).toMatchObject({ pluginMetadata: { role: "project", threadBriefs: { rename: false, group: false, archive: false, ring: false } } });
expect(project).toMatchObject({ pluginMetadata: { role: "project", threadBriefs: false } });
// On the project's machine, never core's "project-default" (the server's own, which may have no daemon).
expect(project).toMatchObject({ environment: { type: "provider", environmentProviderId: "multi-repo-workspace", machine: { type: "existing", hostId: "host_on" } } });
// Both in the project's own sidebar section, and nothing pinned.
Expand All @@ -434,7 +434,7 @@ describe("a Linear ticket steward", () => {
expect(project).not.toHaveProperty("pinned");
// A child of the project steward, so the sidebar trees it there, with
// notifications off so a finished steward turn does not wake the parent.
expect(steward).toMatchObject({ title: "ENG-12: Fix login", parentThreadId: project!.id, parentNotifications: "off", environment: { type: "reuse", environmentId: "env1" }, model: "claude-sonnet-5-5", pluginMetadata: { role: "steward", threadBriefs: { rename: false, group: false, archive: false } } });
expect(steward).toMatchObject({ title: "ENG-12: Fix login", parentThreadId: project!.id, parentNotifications: "off", environment: { type: "reuse", environmentId: "env1" }, model: "claude-sonnet-5-5", pluginMetadata: { role: "steward", threadBriefs: false } });
const prompt = String(steward!.prompt);
expect(prompt).toMatch(/# Steward wake: ENG-12 — Fix login/u);
expect(prompt).toMatch(/You have just been bound/u);
Expand Down Expand Up @@ -2281,8 +2281,8 @@ describe("the project steward", () => {
t.hooks.forgetTags();
await t.hooks.sweep();
expect(t.threads.get(project!.id)!.title).toBe("Acme project steward");
expect(t.metadata.get(project!.id)).toEqual({ role: "project", threadBriefs: { rename: false, group: false, archive: false, ring: false } });
expect(t.metadata.get(steward!.id)).toEqual({ role: "steward", threadBriefs: { rename: false, group: false, archive: false } });
expect(t.metadata.get(project!.id)).toEqual({ role: "project", threadBriefs: false });
expect(t.metadata.get(steward!.id)).toEqual({ role: "steward", threadBriefs: false });

// A name given by hand afterwards sticks.
t.threads.get(project!.id)!.title = "Acme overseer";
Expand Down Expand Up @@ -7121,8 +7121,8 @@ describe("steward-spawned threads and the sidebar (BB-39)", () => {
const steward = t.spawns[1]!.id;
await t.created("thr_w1", steward);
await t.created("thr_r1", "thr_w1", "Review the pull request");
expect(t.metadata.get("thr_w1")).toEqual({ threadBriefs: { rename: false, group: false, archive: false } });
expect(t.metadata.get("thr_r1")).toEqual({ threadBriefs: { rename: false, group: false, archive: false } });
expect(t.metadata.get("thr_w1")).toEqual({ threadBriefs: false });
expect(t.metadata.get("thr_r1")).toEqual({ threadBriefs: false });
});

it("marks a steward read after a report that needs nobody, and leaves it unread when it needs the user", async () => {
Expand Down
29 changes: 14 additions & 15 deletions plugins/stewards/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,16 +103,15 @@ export const BRIEF_MAX_CHARS = 3800;
* a plugin runs"). Every steward keeps the title it was given, which carries
* the entity key, and is archived by this plugin when its binding ends, or
* when a wake turn ends waiting on someone else, rather than whenever its
* brief reads done between wakes. Stewards stay out of the status
* sections, where one idle between wakes would read done: the project
* steward is never finished, and a steward is finished when its binding ends.
* A ticket or pull request steward's row draws the stage ring like any
* thread's, following its entity's work. The project steward's draws none:
* it has no stage to show, and its ring would only say done about a project
* that is not.
* brief reads done between wakes. Stewards stay out of the status sections,
* where one idle between wakes would read done: the project steward is never
* finished, and a steward is finished when its binding ends. Their rows draw
* no ring either: a brief would say done about work that is still open.
*
* One switch for every thread this plugin spawns — steward, project steward,
* worker or reviewer — so thread-briefs writes no brief for any of them.
*/
export const STEWARD_BRIEFS = { rename: false, group: false, archive: false } as const;
export const PROJECT_BRIEFS = { ...STEWARD_BRIEFS, ring: false } as const;
export const THREAD_BRIEFS_OFF = false;

export interface Timing {
/** Rings for one entity within this long are one look. */
Expand Down Expand Up @@ -1562,7 +1561,7 @@ export function createStewardsPlugin(deps: StewardsDeps): (bb: BbPluginApi) => P
* process. A spawn seeds it; this covers threads spawned before it was.
*/
const tagged = new Set<string>();
async function tagThread(threadId: string, briefs: typeof STEWARD_BRIEFS | typeof PROJECT_BRIEFS): Promise<void> {
async function tagThread(threadId: string, briefs: typeof THREAD_BRIEFS_OFF): Promise<void> {
if (tagged.has(threadId)) return;
try {
await bb.sdk.threads.updatePluginMetadata({ threadId, set: { threadBriefs: briefs } });
Expand Down Expand Up @@ -1764,7 +1763,7 @@ export function createStewardsPlugin(deps: StewardsDeps): (bb: BbPluginApi) => P
if (row.threadId === null) return;
const info = await threadInfo(row.threadId).catch(() => null);
if (info === null || info.deletedAt !== null) return;
await tagThread(row.threadId, PROJECT_BRIEFS);
await tagThread(row.threadId, THREAD_BRIEFS_OFF);
await placeOnce(projectId, info);
if (row.title !== null && info.title !== row.title) return;
const title = await projectStewardTitle(projectId);
Expand Down Expand Up @@ -1792,7 +1791,7 @@ export function createStewardsPlugin(deps: StewardsDeps): (bb: BbPluginApi) => P
title,
...modelArgs(config.file.projectSteward.model ?? null),
...(sectionId === null ? {} : { sectionId }),
pluginMetadata: { role: "project", threadBriefs: PROJECT_BRIEFS },
pluginMetadata: { role: "project", threadBriefs: THREAD_BRIEFS_OFF },
});
tagged.add(thread.id);
if (sectionId !== null) store.markPlaced(thread.id);
Expand All @@ -1814,7 +1813,7 @@ export function createStewardsPlugin(deps: StewardsDeps): (bb: BbPluginApi) => P
const kept = new Set<string>();
async function keepSteward(projectId: string, threadId: string): Promise<void> {
if (kept.has(threadId)) return;
await tagThread(threadId, STEWARD_BRIEFS);
await tagThread(threadId, THREAD_BRIEFS_OFF);
const info = await threadInfo(threadId).catch(() => null);
if (info === null || info.deletedAt !== null) return;
const parent = info.parentThreadId;
Expand Down Expand Up @@ -1848,7 +1847,7 @@ export function createStewardsPlugin(deps: StewardsDeps): (bb: BbPluginApi) => P
prompt,
title: b.kind === "review" ? `Review ${b.entityKey}: ${b.title ?? ""}`.trim() : b.kind === "ops" ? (b.title ?? `Ops watch: ${b.entityKey}`) : `${b.entityKey}: ${b.title ?? ""}`.trim(),
...modelArgs(stewardModel(b, playbook)),
pluginMetadata: { role: "steward", bindingId: b.id, threadBriefs: STEWARD_BRIEFS },
pluginMetadata: { role: "steward", bindingId: b.id, threadBriefs: THREAD_BRIEFS_OFF },
};
let thread: { id: string };
try {
Expand Down Expand Up @@ -4385,7 +4384,7 @@ export function createStewardsPlugin(deps: StewardsDeps): (bb: BbPluginApi) => P
const spawned = spawnedOwner(thread);
if (spawned !== null) {
await placeOnce(spawned.projectId, { id: thread.id, sectionId: thread.sectionId ?? null });
await tagThread(thread.id, STEWARD_BRIEFS);
await tagThread(thread.id, THREAD_BRIEFS_OFF);
}
// A reviewer is a child of a worker. The newest round supersedes the
// older ones, so they are archived as it is created (BB-47).
Expand Down
59 changes: 29 additions & 30 deletions plugins/thread-briefs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -478,8 +478,8 @@ screen:
a fresh brief row without the stamp, so working in the thread again puts it
back in the normal cycle.
- **Nothing busy, hidden, deleted or already archived** is touched.
- **A thread its plugin exempted is never archived**, and neither is one
whose plugin's metadata could not be read. See
- **A thread its plugin keeps out of briefs is never archived**, and neither is
one whose plugin's metadata could not be read. See
[Threads a plugin runs](#threads-a-plugin-runs).

The sweep runs hourly on its own schedule rather than inside the brief sweep,
Expand All @@ -490,46 +490,45 @@ so a sweep running up to an hour late is invisible.
## Threads a plugin runs

Some threads are not a person's work but a plugin's: a long-lived thread that
plugin wakes and re-wakes, such as a steward. Three things this plugin does
without being asked are wrong for those. Its plugin chose the title, often
plugin wakes and re-wakes, such as a steward. Everything this plugin does
without being asked is wrong for those. Its plugin chose the title, often
around a key people search for, so a 4–6 word rename loses it. Between wakes
it reads done, so it greys, files under Done and is archived, only to be
brought back on its next wake. And a thread you always want where you left it,
like a project's steward, gets moved between status sections.
brought back on its next wake. A thread you always want where you left it,
like a project's steward, gets moved between status sections. And the summary
itself costs a model call on every idle and every sweep.

So the plugin that spawns a thread can turn each one off, in its own
metadata namespace on the thread:
So the plugin that spawns a thread can turn thread-briefs off for it entirely,
in its own metadata namespace on the thread:

```ts
await bb.sdk.threads.spawn({
// ...
pluginMetadata: { role: "project", threadBriefs: { rename: false, group: false, archive: false, ring: false } },
pluginMetadata: { role: "project", threadBriefs: false },
});
```

thread-briefs reads that namespace back through the thread's
`originPluginId`, which bb sets to whichever plugin seeded the metadata, so it
never has to know which plugins exist. Each key is an opt-out: only `false`
turns its behaviour off, and an absent key leaves it on. A plugin can add the
key to threads it spawned before, with `updatePluginMetadata`.

| Key | Off means |
| --- | --- |
| `rename` | the title is never replaced with the brief's |
| `group` | the thread stays out of the status sections, in bb's Threads group (or pinned, if you pin it) |
| `archive` | the thread is never auto-archived, and its grey ring does not say "archiving soon" |
| `ring` | the sidebar row draws no ring, so bb's own running, unread and error indicators are all it shows |

The brief is still written, and the panel, the board, the refresher and the
Stage and Status actions work as for any other thread. The rename, the grouping and the
archive sweep read the namespace live, one request per spawning plugin per
pass. When that read fails, the thread is not renamed or archived and is not
moved between sections, because each of those is something nobody asked for.
The sidebar and the board read a copy stored on the brief at each summary
(`optOut`) to decide whether to draw the ring and whether to say "archiving
soon". A summary refreshes that copy, and so does the ten-minute sweep, so a
plugin that asks for something new of an idle thread is heard within ten
minutes.
never has to know which plugins exist. `false` is the off switch; anything
else — the key absent, `true` — leaves the thread on. A plugin can add the key
to threads it spawned before, with `updatePluginMetadata`.

Off means off: no summary, no stored brief, no rename, no status section, no
ring, no auto-archive and no re-entry refresher, and the Brief panel says the
thread's plugin keeps it out of briefs rather than offering to make one. The
thread's title is never touched, so a key stays in it. The plugin that spawned
the thread archives it, if it archives it at all.

A thread tagged before this was a single switch carries the old
`threadBriefs: { rename, group, archive, ring }` object instead; any one of
those keys being `false` reads as off, so it needs no re-tag. That reading can
go a release later.

The namespace is read live, one request per spawning plugin per pass. When that
read fails, the thread is treated as off for that pass — its brief is kept and
it is skipped — rather than read as on and having its brief deleted or its
title changed on a transient error.

## How it is built

Expand Down
12 changes: 12 additions & 0 deletions plugins/thread-briefs/app.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,18 @@ describe("the brief panel", () => {
slot.lifecycle.unmount();
});

it("says the thread's plugin keeps it out of briefs, with no summarize offer", async () => {
const slot = await render({ getBrief: () => ({ state: "off" }) });

expect(
await slot.findByText("This thread's plugin keeps it out of briefs."),
).toBeTruthy();
// No summary is coming and none can be asked for.
expect(slot.queryByText("Summarizing…")).toBeNull();
expect(slot.queryByRole("button", { name: "Summarize now" })).toBeNull();
slot.lifecycle.unmount();
});

it("surfaces the unconfigured message instead of an empty brief", async () => {
const slot = await render({
getBrief: () => ({ state: "unconfigured", message: "Add an API key." }),
Expand Down
9 changes: 9 additions & 0 deletions plugins/thread-briefs/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -448,6 +448,15 @@ function BriefBody({
if (state.state === "summarizing") {
return <div className="text-sm text-muted-foreground">Summarizing…</div>;
}
if (state.state === "off") {
// The thread's plugin keeps it out of briefs entirely: there is no brief
// and no summary coming, so explain rather than offer to make one.
return (
<div className="text-sm text-muted-foreground">
This thread's plugin keeps it out of briefs.
</div>
);
}
if (state.state === "absent") {
// Threads that were already dormant when the plugin arrived are not
// backfilled, so say so and offer to make one rather than spinning.
Expand Down
Loading
Loading