paseo-bm-plugin 0.0.0-placeholder.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +53 -0
- package/client/agent-tree.ts +308 -0
- package/client/answer-state.ts +62 -0
- package/client/bead-chips.tsx +147 -0
- package/client/beads-header-button.ts +108 -0
- package/client/beads-model.ts +581 -0
- package/client/beads-screen.tsx +516 -0
- package/client/beads-tab.tsx +58 -0
- package/client/chat-card.tsx +636 -0
- package/client/chat-cards.ts +1038 -0
- package/client/dashboard-actions.tsx +255 -0
- package/client/dashboard-model.ts +947 -0
- package/client/dashboard-view.ts +215 -0
- package/client/dashboard.tsx +318 -0
- package/client/launch-manager.ts +323 -0
- package/client/launcher.tsx +516 -0
- package/client/markdown-view.tsx +112 -0
- package/client/markdown.ts +145 -0
- package/client/settings.tsx +104 -0
- package/client/setup-model.ts +552 -0
- package/client/setup-screen.tsx +913 -0
- package/client/slot.ts +47 -0
- package/client/tree.tsx +204 -0
- package/client/ui.tsx +262 -0
- package/client/waiting-pills-model.ts +156 -0
- package/client/waiting-pills.tsx +201 -0
- package/index.client.tsx +232 -0
- package/index.server.ts +168 -0
- package/package.json +35 -0
- package/paseo-plugin.json +6 -0
- package/roles/manager.md +181 -0
- package/roles/reviewer.md +160 -0
- package/roles/worker.md +407 -0
- package/server/agent-labels.ts +194 -0
- package/server/agent-role.ts +102 -0
- package/server/answer-marks.ts +120 -0
- package/server/bead-actions.ts +88 -0
- package/server/bead-work.ts +80 -0
- package/server/beads-store.ts +342 -0
- package/server/bm-report.ts +433 -0
- package/server/chat-peers.ts +65 -0
- package/server/chat-rpc.ts +122 -0
- package/server/chat-waiting.ts +182 -0
- package/server/collector.ts +629 -0
- package/server/config-writer.ts +222 -0
- package/server/cost.ts +88 -0
- package/server/dashboard-rpc.ts +662 -0
- package/server/fallback-detect.ts +183 -0
- package/server/fallback-handover.ts +365 -0
- package/server/fallback-manager.ts +170 -0
- package/server/fallback-reviewer.ts +198 -0
- package/server/fallback-rpc.ts +306 -0
- package/server/fallback-settings.ts +322 -0
- package/server/fallback-state.ts +518 -0
- package/server/fallback-switch.ts +191 -0
- package/server/fallback-wait.ts +188 -0
- package/server/format-check.ts +352 -0
- package/server/install-home.ts +187 -0
- package/server/live-timeline.ts +129 -0
- package/server/manager-instructions.ts +9 -0
- package/server/manager.ts +647 -0
- package/server/model-costs.ts +238 -0
- package/server/notice-queue.ts +315 -0
- package/server/notices.ts +81 -0
- package/server/paseo-cli.ts +115 -0
- package/server/provider-id.ts +12 -0
- package/server/review-budget.ts +208 -0
- package/server/reviewer-instructions.ts +9 -0
- package/server/role-choices.ts +161 -0
- package/server/role-extras.ts +270 -0
- package/server/role-hook.ts +347 -0
- package/server/role-mode.ts +397 -0
- package/server/role-settings-rpc.ts +325 -0
- package/server/roles.ts +96 -0
- package/server/settings-notices.ts +112 -0
- package/server/setup-rpc.ts +70 -0
- package/server/setup-skills.ts +121 -0
- package/server/setup-tools.ts +162 -0
- package/server/shell.ts +68 -0
- package/server/stop-propagation.ts +365 -0
- package/server/tools-check.ts +118 -0
- package/server/trace-store.ts +1137 -0
- package/server/traces.ts +1356 -0
- package/server/worker-instructions.ts +9 -0
- package/server/workflow-steps.ts +422 -0
- package/shared/bead-ids.ts +25 -0
- package/shared/bm-fallback.ts +91 -0
- package/shared/bm-format.ts +424 -0
- package/shared/bm-questions.ts +213 -0
- package/shared/bm-report.ts +433 -0
- package/shared/contracts.ts +1371 -0
- package/shared/fallback-patterns.ts +201 -0
- package/shared/fallback.ts +46 -0
- package/shared/new-request.ts +20 -0
- package/shared/order.ts +22 -0
- package/shared/prices.ts +65 -0
- package/shared/settings.ts +57 -0
- package/shared/sole-worker.ts +20 -0
- package/shared/version.ts +6 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which paseo-bm role an agent has (delta 20260918g §4.1, REQ-061 a).
|
|
3
|
+
*
|
|
4
|
+
* The `bm.role` label is the usual answer, but it is not the only one: an agent
|
|
5
|
+
* started from Paseo's own new-agent flow with a paseo-bm profile runs the role
|
|
6
|
+
* (the `before("agent.create")` hook gives it the instructions) and carries no
|
|
7
|
+
* label at all, because that hook can change `{ config, env }` only. The owner's
|
|
8
|
+
* "Refactor Dependency" Manager was such an agent, and every lookup that
|
|
9
|
+
* filtered on the label treated its chat as "not paseo-bm's" (bead bm-llmg).
|
|
10
|
+
* So the provider decides whenever the label does not.
|
|
11
|
+
*
|
|
12
|
+
* Pure apart from `listAllAgents`, which only calls the `list` it is given.
|
|
13
|
+
*/
|
|
14
|
+
import { fallbackAliasOf } from "../shared/fallback";
|
|
15
|
+
import { providerId } from "./provider-id";
|
|
16
|
+
|
|
17
|
+
export type BmRole = "manager" | "worker" | "reviewer";
|
|
18
|
+
|
|
19
|
+
/** The role, and whether it came from the `bm.role` label (true) or only from the provider (false). */
|
|
20
|
+
export interface RoleFact {
|
|
21
|
+
role: BmRole;
|
|
22
|
+
labelled: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Label key that names an agent's paseo-bm role. */
|
|
26
|
+
export const ROLE_LABEL = "bm.role";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The three main paseo-bm provider aliases and the role each one runs. A
|
|
30
|
+
* fallback alias `bm-<role>-fallback-<n>` runs the role in its name
|
|
31
|
+
* (`roleOfProvider`, delta 20260921 §4.4.1); it is not listed here.
|
|
32
|
+
*/
|
|
33
|
+
export const ROLE_BY_PROVIDER: Readonly<Record<string, BmRole>> = {
|
|
34
|
+
"bm-manager": "manager",
|
|
35
|
+
"bm-worker": "worker",
|
|
36
|
+
"bm-reviewer": "reviewer",
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const ROLES: ReadonlySet<string> = new Set(["manager", "worker", "reviewer"]);
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The role a provider selection runs, or null: `bm-worker` and
|
|
43
|
+
* `bm-worker/<model>` run the Worker, and so do `bm-worker-fallback-<1..3>`
|
|
44
|
+
* (with or without `/<model>`).
|
|
45
|
+
*/
|
|
46
|
+
export function roleOfProvider(provider: unknown): BmRole | null {
|
|
47
|
+
const id = providerId(provider);
|
|
48
|
+
if (id === null) return null;
|
|
49
|
+
if (Object.prototype.hasOwnProperty.call(ROLE_BY_PROVIDER, id)) return ROLE_BY_PROVIDER[id]!;
|
|
50
|
+
return fallbackAliasOf(id)?.role ?? null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The role of an agent snapshot, or null when it is not a paseo-bm agent.
|
|
55
|
+
* A valid `bm.role` label wins; otherwise the provider decides, which also
|
|
56
|
+
* covers a label holding an unknown value. Never throws.
|
|
57
|
+
*/
|
|
58
|
+
export function roleOfAgent(agent: unknown): RoleFact | null {
|
|
59
|
+
try {
|
|
60
|
+
if (agent === null || typeof agent !== "object") return null;
|
|
61
|
+
const { labels, provider } = agent as { labels?: unknown; provider?: unknown };
|
|
62
|
+
const label =
|
|
63
|
+
labels !== null && typeof labels === "object" ? (labels as Record<string, unknown>)[ROLE_LABEL] : undefined;
|
|
64
|
+
if (typeof label === "string" && ROLES.has(label)) return { role: label as BmRole, labelled: true };
|
|
65
|
+
const role = roleOfProvider(provider);
|
|
66
|
+
return role === null ? null : { role, labelled: false };
|
|
67
|
+
} catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Page size of every `agents.list` walk; the daemon caps a page at 200. */
|
|
73
|
+
export const LIST_PAGE_LIMIT = 200;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Walks every page of an `agents.list` query and returns all snapshots.
|
|
77
|
+
* A page without `pageInfo` is the last one, so a host (or fake) that pages
|
|
78
|
+
* nothing still works.
|
|
79
|
+
*/
|
|
80
|
+
export async function listAllAgents<Filter, Snapshot>(
|
|
81
|
+
list: (options: {
|
|
82
|
+
filter: Filter;
|
|
83
|
+
page: { limit: number; cursor?: string };
|
|
84
|
+
}) => Promise<{
|
|
85
|
+
entries: Array<{ agent: Snapshot }>;
|
|
86
|
+
pageInfo?: { nextCursor: string | null; hasMore: boolean };
|
|
87
|
+
}>,
|
|
88
|
+
filter: Filter,
|
|
89
|
+
): Promise<Snapshot[]> {
|
|
90
|
+
const found: Snapshot[] = [];
|
|
91
|
+
let cursor: string | undefined;
|
|
92
|
+
for (;;) {
|
|
93
|
+
const page = await list({
|
|
94
|
+
filter,
|
|
95
|
+
page: cursor === undefined ? { limit: LIST_PAGE_LIMIT } : { limit: LIST_PAGE_LIMIT, cursor },
|
|
96
|
+
});
|
|
97
|
+
for (const { agent } of page.entries) found.push(agent);
|
|
98
|
+
if (!page.pageInfo?.hasMore || !page.pageInfo.nextCursor) break;
|
|
99
|
+
cursor = page.pageInfo.nextCursor;
|
|
100
|
+
}
|
|
101
|
+
return found;
|
|
102
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where "Mark as answered" is remembered (delta 20260918d-card-replies §4.9,
|
|
3
|
+
* REQ-059 k; owner decision Q16 a).
|
|
4
|
+
*
|
|
5
|
+
* A mark says the user considers a question card answered, even if its Worker
|
|
6
|
+
* still looks like it waits. It must survive an app reload, so it lives in a
|
|
7
|
+
* small file of the install home, next to the launcher's pinned order, and is
|
|
8
|
+
* written through the same machinery: the no-follow path guard and the atomic
|
|
9
|
+
* temp-then-rename the trace store uses.
|
|
10
|
+
*
|
|
11
|
+
* Reads never repair the file. A corrupt or too-new file reads as "no marks"
|
|
12
|
+
* and says so; writing over a newer version's file is refused.
|
|
13
|
+
*/
|
|
14
|
+
import { readFileSync } from "node:fs";
|
|
15
|
+
import { dirname, join } from "node:path";
|
|
16
|
+
import { z } from "zod";
|
|
17
|
+
import { UI_DIR_NAME } from "./install-home";
|
|
18
|
+
import { assertNoSymlinkOnPath, ensureStoreDir, writeStoreFileAtomically, type TraceStoreLocation } from "./trace-store";
|
|
19
|
+
import { DashboardError } from "../shared/contracts";
|
|
20
|
+
|
|
21
|
+
/** Bumped only when the shape below changes incompatibly. */
|
|
22
|
+
export const ANSWER_MARKS_SCHEMA_VERSION = 1;
|
|
23
|
+
export const ANSWER_MARKS_FILE_NAME = "answer-marks.json";
|
|
24
|
+
/** Oldest marks go first once there are more: a mark only matters for a live card. */
|
|
25
|
+
export const ANSWER_MARKS_LIMIT = 500;
|
|
26
|
+
export const ANSWER_MARK_KEY_MAX = 400;
|
|
27
|
+
|
|
28
|
+
const answerMarksFileSchema = z.object({
|
|
29
|
+
schemaVersion: z.number().int().positive(),
|
|
30
|
+
marks: z.array(z.object({ key: z.string(), at: z.string() })),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
export interface AnswerMarks {
|
|
34
|
+
/** Card keys, oldest first. */
|
|
35
|
+
keys: string[];
|
|
36
|
+
/** What could not be read, in the user's terms. Empty when all is well. */
|
|
37
|
+
notices: string[];
|
|
38
|
+
/** True when the file on disk is newer than this plugin understands. */
|
|
39
|
+
tooNew: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** True when `text` holds an ASCII control character (codes 0-31 and 127). */
|
|
43
|
+
function hasControlCharacter(text: string): boolean {
|
|
44
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
45
|
+
const code = text.charCodeAt(index);
|
|
46
|
+
if (code < 32 || code === 127) return true;
|
|
47
|
+
}
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A key this store accepts: a card key is `agentId|requestId|questionIds|hash`. */
|
|
52
|
+
export function isAnswerMarkKey(key: unknown): key is string {
|
|
53
|
+
return typeof key === "string" && key.length > 0 && key.length <= ANSWER_MARK_KEY_MAX && !hasControlCharacter(key);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function answerMarksPath(location: TraceStoreLocation): string {
|
|
57
|
+
return join(dirname(location.tracesDir), UI_DIR_NAME, ANSWER_MARKS_FILE_NAME);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function readMarks(location: TraceStoreLocation): { marks: Array<{ key: string; at: string }>; notices: string[]; tooNew: boolean } {
|
|
61
|
+
const path = answerMarksPath(location);
|
|
62
|
+
// Before the read, so a symlinked path is refused rather than followed.
|
|
63
|
+
assertNoSymlinkOnPath(dirname(location.tracesDir), path);
|
|
64
|
+
|
|
65
|
+
let raw: string;
|
|
66
|
+
try {
|
|
67
|
+
raw = readFileSync(path, "utf8");
|
|
68
|
+
} catch (error) {
|
|
69
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return { marks: [], notices: [], tooNew: false };
|
|
70
|
+
return { marks: [], notices: [`Could not read the answered marks (${(error as Error).message}).`], tooNew: false };
|
|
71
|
+
}
|
|
72
|
+
let parsed: unknown;
|
|
73
|
+
try {
|
|
74
|
+
parsed = JSON.parse(raw);
|
|
75
|
+
} catch {
|
|
76
|
+
return { marks: [], notices: ["The answered-marks file is not valid JSON; no card is marked."], tooNew: false };
|
|
77
|
+
}
|
|
78
|
+
const result = answerMarksFileSchema.safeParse(parsed);
|
|
79
|
+
if (!result.success) {
|
|
80
|
+
return { marks: [], notices: ["The answered-marks file does not have the expected shape; no card is marked."], tooNew: false };
|
|
81
|
+
}
|
|
82
|
+
if (result.data.schemaVersion > ANSWER_MARKS_SCHEMA_VERSION) {
|
|
83
|
+
return {
|
|
84
|
+
marks: [],
|
|
85
|
+
notices: [
|
|
86
|
+
`The answered-marks file is version ${result.data.schemaVersion}, newer than this plugin understands (${ANSWER_MARKS_SCHEMA_VERSION}); no card is marked and nothing will be written.`,
|
|
87
|
+
],
|
|
88
|
+
tooNew: true,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
const marks = result.data.marks.filter((mark) => isAnswerMarkKey(mark.key));
|
|
92
|
+
const dropped = result.data.marks.length - marks.length;
|
|
93
|
+
return { marks, notices: dropped > 0 ? [`Ignored ${dropped} unusable mark(s) in the answered-marks file.`] : [], tooNew: false };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The marks, or none. Never throws for a bad file; throws only for a symlinked path. */
|
|
97
|
+
export function readAnswerMarks(location: TraceStoreLocation): AnswerMarks {
|
|
98
|
+
const { marks, notices, tooNew } = readMarks(location);
|
|
99
|
+
return { keys: marks.map((mark) => mark.key), notices, tooNew };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Adds (as the newest) or removes one mark. Refuses a bad key and a newer file. */
|
|
103
|
+
export function writeAnswerMark(location: TraceStoreLocation, key: string, marked: boolean, now: () => Date = () => new Date()): AnswerMarks {
|
|
104
|
+
if (!isAnswerMarkKey(key)) {
|
|
105
|
+
throw new DashboardError("E_TRACE_STORE_UNWRITABLE", `not a usable card key: ${JSON.stringify(String(key).slice(0, 60))}`);
|
|
106
|
+
}
|
|
107
|
+
const current = readMarks(location);
|
|
108
|
+
if (current.tooNew) {
|
|
109
|
+
throw new DashboardError("E_TRACE_STORE_UNWRITABLE", "the answered-marks file was written by a newer version of paseo-bm; refusing to overwrite it");
|
|
110
|
+
}
|
|
111
|
+
const others = current.marks.filter((mark) => mark.key !== key);
|
|
112
|
+
const marks = (marked ? [...others, { key, at: now().toISOString() }] : others).slice(-ANSWER_MARKS_LIMIT);
|
|
113
|
+
ensureStoreDir(location.tracesDir, join(dirname(location.tracesDir), UI_DIR_NAME));
|
|
114
|
+
writeStoreFileAtomically(
|
|
115
|
+
location,
|
|
116
|
+
answerMarksPath(location),
|
|
117
|
+
`${JSON.stringify({ schemaVersion: ANSWER_MARKS_SCHEMA_VERSION, marks }, null, 2)}\n`,
|
|
118
|
+
);
|
|
119
|
+
return { keys: marks.map((mark) => mark.key), notices: [], tooNew: false };
|
|
120
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Beads screen: list, detail and the three hand-off actions
|
|
3
|
+
* (design delta 20260916-beads-screen).
|
|
4
|
+
*
|
|
5
|
+
* An action never touches the bead store. It makes sure the workspace has a
|
|
6
|
+
* Beads Manager and sends it a request; the Manager delegates to a Worker like
|
|
7
|
+
* any other request, so labels, reports, the review budget and the Metric
|
|
8
|
+
* screen all keep working.
|
|
9
|
+
*/
|
|
10
|
+
import { isReady, readBeads, type BeadRecord } from "./beads-store";
|
|
11
|
+
import { DashboardError, type BeadAction, type BeadDetail, type BeadRow } from "../shared/contracts";
|
|
12
|
+
|
|
13
|
+
export function beadRowOf(bead: BeadRecord, all: Map<string, BeadRecord>): BeadRow {
|
|
14
|
+
return {
|
|
15
|
+
id: bead.id,
|
|
16
|
+
title: bead.title,
|
|
17
|
+
status: bead.status,
|
|
18
|
+
issueType: bead.issueType,
|
|
19
|
+
priority: bead.priority,
|
|
20
|
+
labels: bead.labels,
|
|
21
|
+
createdAt: bead.createdAt,
|
|
22
|
+
updatedAt: bead.updatedAt === "" ? null : bead.updatedAt,
|
|
23
|
+
closedAt: bead.closedAt,
|
|
24
|
+
ready: bead.status !== "closed" && isReady(bead, all),
|
|
25
|
+
parentId: bead.parentId,
|
|
26
|
+
work: null,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Every bead of a workspace, newest update first. */
|
|
31
|
+
export function listBeadRows(workspaceDirectory: string): BeadRow[] {
|
|
32
|
+
const { beads } = readBeads(workspaceDirectory);
|
|
33
|
+
return [...beads.values()]
|
|
34
|
+
.map((bead) => beadRowOf(bead, beads))
|
|
35
|
+
.sort((a, b) => ((a.updatedAt ?? "") < (b.updatedAt ?? "") ? 1 : (a.updatedAt ?? "") > (b.updatedAt ?? "") ? -1 : 0));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function getBeadDetail(workspaceDirectory: string, id: string): BeadDetail {
|
|
39
|
+
const { beads } = readBeads(workspaceDirectory);
|
|
40
|
+
const bead = beads.get(id);
|
|
41
|
+
if (bead === undefined) throw new DashboardError("E_BEAD_NOT_FOUND", `no bead ${id} in ${workspaceDirectory}`);
|
|
42
|
+
return {
|
|
43
|
+
...beadRowOf(bead, beads),
|
|
44
|
+
description: bead.description,
|
|
45
|
+
closeReason: bead.closeReason,
|
|
46
|
+
blockedBy: bead.blockedBy,
|
|
47
|
+
children: [...beads.values()].filter((other) => other.parentId === id).map((other) => other.id),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const INSTRUCTION: Record<BeadAction, string> = {
|
|
52
|
+
implement: "Have a Worker implement this bead until it is closed with evidence, following its acceptance criteria.",
|
|
53
|
+
delete:
|
|
54
|
+
"Have a Worker assess whether this bead is still needed. If it is not, delete it with the tracker (br) and report why. If it is still needed, do not delete it; report the reason instead.",
|
|
55
|
+
close:
|
|
56
|
+
"Have a Worker check whether this bead can be closed: its acceptance criteria are met and nothing it requires is still open. If so, close it with the tracker (br) and the evidence as the reason. If not, leave it open and report what is missing.",
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/** The request the Manager receives. Agent-facing, so English. */
|
|
60
|
+
export function actionMessage(action: BeadAction, bead: Pick<BeadRow, "id" | "title">): string {
|
|
61
|
+
const title = bead.title === null ? "" : ` ("${bead.title}")`;
|
|
62
|
+
return [
|
|
63
|
+
`[Beads screen] The user asks: ${action} bead ${bead.id}${title}.`,
|
|
64
|
+
INSTRUCTION[action],
|
|
65
|
+
"The user confirmed this action on the Beads screen. Treat it as a new request from the user. Do not commit or push.",
|
|
66
|
+
].join("\n");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface BeadActionPaseo {
|
|
70
|
+
agents: { ref(agentId: string): { send(text: string): Promise<void> } };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Sends the action to the workspace's Manager. `ensure` is the existing
|
|
75
|
+
* `ensureManager` bound to this host, so a workspace without a Manager gets
|
|
76
|
+
* one exactly as the launcher would create it.
|
|
77
|
+
*/
|
|
78
|
+
export async function runBeadAction(
|
|
79
|
+
input: { workspaceId: string; action: BeadAction; bead: Pick<BeadRow, "id" | "title"> },
|
|
80
|
+
deps: {
|
|
81
|
+
paseo: BeadActionPaseo;
|
|
82
|
+
ensure: (workspaceId: string) => Promise<{ agentId: string; created: boolean }>;
|
|
83
|
+
},
|
|
84
|
+
): Promise<{ managerId: string; created: boolean }> {
|
|
85
|
+
const manager = await deps.ensure(input.workspaceId);
|
|
86
|
+
await deps.paseo.agents.ref(manager.agentId).send(actionMessage(input.action, input.bead));
|
|
87
|
+
return { managerId: manager.agentId, created: manager.created };
|
|
88
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who is working on a bead, and since when, read from the trace store.
|
|
3
|
+
*
|
|
4
|
+
* The bead store cannot say: `br` records no start time, and Workers do not set
|
|
5
|
+
* an assignee. What the store of traces does have is every shell command a
|
|
6
|
+
* Worker ran and every report it sent, so:
|
|
7
|
+
*
|
|
8
|
+
* - **started**: the latest Worker command that moved the bead to
|
|
9
|
+
* `in_progress` (`br update <id> --status in_progress` or `--claim`);
|
|
10
|
+
* - **last**: the latest Worker command or `BM-REPORT` that named the bead.
|
|
11
|
+
*
|
|
12
|
+
* Only Worker turns count: a Reviewer reading a bead with `br show` is not
|
|
13
|
+
* working on it. Pure: records and agents in, marks out.
|
|
14
|
+
*/
|
|
15
|
+
import { brActions } from "./shell";
|
|
16
|
+
import type { AgentFacts } from "./traces";
|
|
17
|
+
import type { BeadWork, TraceRecord } from "../shared/contracts";
|
|
18
|
+
|
|
19
|
+
interface Seen {
|
|
20
|
+
agentId: string;
|
|
21
|
+
at: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** A matcher for `id` as a whole id (`bm-a.1` does not match `bm-a.12`). */
|
|
25
|
+
function wholeId(id: string): RegExp {
|
|
26
|
+
const escaped = id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
27
|
+
return new RegExp(`(?<![A-Za-z0-9_.-])${escaped}(?![A-Za-z0-9_-]|\\.[A-Za-z0-9])`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** True when `text` names `id` as a whole id. */
|
|
31
|
+
export function namesBead(text: string, id: string): boolean {
|
|
32
|
+
return wholeId(id).test(text);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function beadWorkOf(
|
|
36
|
+
beadIds: readonly string[],
|
|
37
|
+
records: readonly TraceRecord[],
|
|
38
|
+
agents: ReadonlyMap<string, AgentFacts>,
|
|
39
|
+
): Map<string, BeadWork> {
|
|
40
|
+
const started = new Map<string, Seen>();
|
|
41
|
+
const last = new Map<string, Seen>();
|
|
42
|
+
const later = (map: Map<string, Seen>, id: string, seen: Seen) => {
|
|
43
|
+
const current = map.get(id);
|
|
44
|
+
if (current === undefined || current.at <= seen.at) map.set(id, seen);
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// Built once: the loop below runs them over every Worker command.
|
|
48
|
+
const matchers = beadIds.map((id) => [id, wholeId(id)] as const);
|
|
49
|
+
for (const record of records) {
|
|
50
|
+
if (record.role !== "worker") continue;
|
|
51
|
+
for (const evidence of record.evidence) {
|
|
52
|
+
if (evidence.kind !== "shell") continue;
|
|
53
|
+
const seen = { agentId: record.agentId, at: evidence.at ?? record.at };
|
|
54
|
+
const actions = brActions(evidence.detail);
|
|
55
|
+
const startedIds = new Set(actions.flatMap((action) => (action.startsWork ? action.ids : [])));
|
|
56
|
+
for (const [id, matcher] of matchers) {
|
|
57
|
+
if (startedIds.has(id)) later(started, id, seen);
|
|
58
|
+
if (matcher.test(evidence.detail)) later(last, id, seen);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
for (const report of record.reports) {
|
|
62
|
+
const named = new Set([...report.beadsCreated, ...report.beadsUpdated, ...report.beadsClosed, ...report.beadsReady]);
|
|
63
|
+
for (const id of beadIds) {
|
|
64
|
+
if (named.has(id)) later(last, id, { agentId: report.agentId, at: report.at });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const mark = (seen: Seen | undefined) => {
|
|
70
|
+
if (seen === undefined) return null;
|
|
71
|
+
const agent = agents.get(seen.agentId);
|
|
72
|
+
return { ...seen, title: agent?.title ?? null, status: agent?.status ?? null };
|
|
73
|
+
};
|
|
74
|
+
const out = new Map<string, BeadWork>();
|
|
75
|
+
for (const id of beadIds) {
|
|
76
|
+
if (!started.has(id) && !last.has(id)) continue;
|
|
77
|
+
out.set(id, { started: mark(started.get(id)), last: mark(last.get(id)) });
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|