@snarkipus/opencode-beads 0.8.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.
Files changed (48) hide show
  1. package/LICENSE +9 -0
  2. package/README.md +150 -0
  3. package/dist/init/artifacts/beads/SKILL.md +16 -0
  4. package/dist/init/artifacts/beads/references/DEPENDENCIES.md +5 -0
  5. package/dist/init/artifacts/beads/references/ISSUE_CREATION.md +5 -0
  6. package/dist/init/artifacts/beads/references/RESUMABILITY.md +5 -0
  7. package/dist/init/manifest.json +54 -0
  8. package/package.json +82 -0
  9. package/src/init-cli.ts +172 -0
  10. package/src/init.ts +700 -0
  11. package/src/plugin-core.ts +290 -0
  12. package/src/plugin.ts +150 -0
  13. package/src/prime.ts +88 -0
  14. package/src/skill-collision-policy.ts +133 -0
  15. package/src/vendor-adaptations.ts +146 -0
  16. package/src/vendor-provenance.ts +252 -0
  17. package/src/vendor.ts +297 -0
  18. package/vendor/agents/task-agent.md +61 -0
  19. package/vendor/commands/audit.md +28 -0
  20. package/vendor/commands/blocked.md +15 -0
  21. package/vendor/commands/close.md +18 -0
  22. package/vendor/commands/comments.md +30 -0
  23. package/vendor/commands/compact.md +31 -0
  24. package/vendor/commands/create.md +19 -0
  25. package/vendor/commands/decision.md +111 -0
  26. package/vendor/commands/delete.md +32 -0
  27. package/vendor/commands/dep.md +101 -0
  28. package/vendor/commands/epic.md +27 -0
  29. package/vendor/commands/export.md +25 -0
  30. package/vendor/commands/import.md +18 -0
  31. package/vendor/commands/init.md +18 -0
  32. package/vendor/commands/label.md +33 -0
  33. package/vendor/commands/list.md +64 -0
  34. package/vendor/commands/prime.md +10 -0
  35. package/vendor/commands/quickstart.md +13 -0
  36. package/vendor/commands/ready.md +15 -0
  37. package/vendor/commands/rename-prefix.md +24 -0
  38. package/vendor/commands/reopen.md +22 -0
  39. package/vendor/commands/restore.md +27 -0
  40. package/vendor/commands/search.md +103 -0
  41. package/vendor/commands/show.md +17 -0
  42. package/vendor/commands/stats.md +17 -0
  43. package/vendor/commands/sync.md +17 -0
  44. package/vendor/commands/template.md +337 -0
  45. package/vendor/commands/update.md +24 -0
  46. package/vendor/commands/version.md +26 -0
  47. package/vendor/commands/workflow.md +58 -0
  48. package/vendor/manifest.json +168 -0
@@ -0,0 +1,290 @@
1
+ import type { Hooks } from "@opencode-ai/plugin";
2
+ import type {
3
+ Agent,
4
+ SessionMessagesResponse,
5
+ SessionPromptData,
6
+ TextPart,
7
+ TextPartInput,
8
+ UserMessage,
9
+ } from "@opencode-ai/sdk";
10
+ import { PrimeTimeoutError } from "./prime";
11
+ import { beadsGuidance, loadAgent, loadCommands } from "./vendor";
12
+
13
+ const BEADS_TASK_AGENT = "beads-task-agent";
14
+
15
+ type ChatMessageInput = Parameters<NonNullable<Hooks["chat.message"]>>[0];
16
+ type SessionMessageResponse = SessionMessagesResponse[number];
17
+ type SessionPromptBody = NonNullable<SessionPromptData["body"]>;
18
+
19
+ export type ModelContext = NonNullable<ChatMessageInput["model"]>;
20
+ export type MessageContext = Pick<ChatMessageInput, "sessionID" | "agent" | "model">;
21
+ export type ContextSink = (context: string) => void | Promise<void>;
22
+
23
+ // The controller only needs this projection; the OpenCode adapter validates the full response.
24
+ export type SessionMessage = {
25
+ info: Pick<SessionMessageResponse["info"], "role"> &
26
+ Partial<Pick<UserMessage, "agent" | "model" | "system">>;
27
+ parts?: ReadonlyArray<
28
+ Pick<SessionMessageResponse["parts"][number], "type"> & Partial<Pick<TextPart, "text">>
29
+ >;
30
+ };
31
+
32
+ export type AgentInfo = Pick<Agent, "name" | "mode">;
33
+
34
+ export type PromptBody = Omit<SessionPromptBody, "noReply" | "parts"> & {
35
+ noReply: true;
36
+ parts: Array<TextPartInput & { synthetic: true }>;
37
+ };
38
+
39
+ export interface PluginRuntime {
40
+ getMessages(
41
+ directory: string,
42
+ sessionID: string,
43
+ limit?: number
44
+ ): Promise<ReadonlyArray<SessionMessage> | undefined>;
45
+ getAgents(directory: string): Promise<ReadonlyArray<AgentInfo> | undefined>;
46
+ prompt(directory: string, sessionID: string, body: PromptBody): Promise<void>;
47
+ prime(directory: string): Promise<string>;
48
+ diagnose(diagnostic: PluginDiagnostic): Promise<void>;
49
+ }
50
+
51
+ export type DiagnosticCode =
52
+ | "agents_lookup_failed"
53
+ | "config_collision"
54
+ | "messages_lookup_failed"
55
+ | "prime_failed"
56
+ | "prime_timeout"
57
+ | "prompt_failed";
58
+
59
+ export type PluginDiagnostic =
60
+ | {
61
+ code: Exclude<DiagnosticCode, "config_collision">;
62
+ directory: string;
63
+ sessionID: string;
64
+ }
65
+ | {
66
+ code: "config_collision";
67
+ directory: string;
68
+ surface: "agent" | "command";
69
+ names: string[];
70
+ };
71
+
72
+ export interface ControllerOptions {
73
+ diagnosticIntervalMs?: number;
74
+ now?: () => number;
75
+ }
76
+
77
+ export type MutablePluginConfig = Parameters<NonNullable<Hooks["config"]>>[0];
78
+
79
+ export interface BeadsController {
80
+ onMessage(message: MessageContext, sink: ContextSink): Promise<void>;
81
+ onCompacted(sessionID: string): Promise<void>;
82
+ configure(config: MutablePluginConfig): Promise<void>;
83
+ }
84
+
85
+ /** Resolve OpenCode project scope without falling back to the process directory. */
86
+ export function resolveProjectDirectory(directory: string, worktree: string): string {
87
+ if (directory.trim()) return directory;
88
+ if (worktree.trim()) return worktree;
89
+ throw new Error("OpenCode did not provide a project directory or worktree");
90
+ }
91
+
92
+ /** Select the newest eligible user context from OpenCode's oldest-first message list. */
93
+ function latestSessionContext(messages: ReadonlyArray<SessionMessage> | undefined) {
94
+ if (!messages) return undefined;
95
+
96
+ for (let index = messages.length - 1; index >= 0; index--) {
97
+ const message = messages[index];
98
+ if (message?.info.role === "user" && message.info.model) {
99
+ return { model: message.info.model, agent: message.info.agent };
100
+ }
101
+ }
102
+
103
+ return undefined;
104
+ }
105
+
106
+ /** Create the stateful Beads behavior behind the OpenCode hook adapter. */
107
+ export async function createBeadsController(
108
+ runtime: PluginRuntime,
109
+ directory: string,
110
+ options: ControllerOptions = {}
111
+ ): Promise<BeadsController> {
112
+ const [loadedCommands, loadedAgents] = await Promise.all([loadCommands(), loadAgent()]);
113
+ const commands = loadedCommands ?? {};
114
+ const agents = loadedAgents ?? {};
115
+ const injectedSessions = new Set<string>();
116
+ const injectionAttempts = new Map<string, Promise<void>>();
117
+ const diagnosticTimes = new Map<string, number>();
118
+ const diagnosticIntervalMs = options.diagnosticIntervalMs ?? 60_000;
119
+ const now = options.now ?? Date.now;
120
+
121
+ async function diagnose(diagnostic: PluginDiagnostic, key: string): Promise<void> {
122
+ const currentTime = now();
123
+ const previousTime = diagnosticTimes.get(key);
124
+ if (previousTime !== undefined && currentTime - previousTime < diagnosticIntervalMs) return;
125
+
126
+ diagnosticTimes.set(key, currentTime);
127
+ await runtime.diagnose(diagnostic).catch(() => undefined);
128
+ }
129
+
130
+ async function diagnoseSession(
131
+ code: Exclude<DiagnosticCode, "config_collision">,
132
+ sessionID: string
133
+ ): Promise<void> {
134
+ await diagnose({ code, directory, sessionID }, `${code}:${sessionID}`);
135
+ }
136
+
137
+ async function shouldInject(
138
+ agentName: string | undefined,
139
+ sessionID: string
140
+ ): Promise<boolean> {
141
+ if (!agentName || agentName === BEADS_TASK_AGENT) return true;
142
+
143
+ const availableAgents = await runtime.getAgents(directory).catch(async () => {
144
+ await diagnoseSession("agents_lookup_failed", sessionID);
145
+ return undefined;
146
+ });
147
+ const agent = availableAgents?.find((candidate) => candidate.name === agentName);
148
+ return agent ? agent.mode === "primary" || agent.mode === "all" : true;
149
+ }
150
+
151
+ async function renderContext(
152
+ sessionID: string,
153
+ context?: { model?: ModelContext; agent?: string }
154
+ ): Promise<string | undefined> {
155
+ let output: string;
156
+ try {
157
+ output = (await runtime.prime(directory)).trim();
158
+ } catch (error) {
159
+ const code = error instanceof PrimeTimeoutError ? "prime_timeout" : "prime_failed";
160
+ await diagnoseSession(code, sessionID);
161
+ return undefined;
162
+ }
163
+ if (!output) return undefined;
164
+
165
+ const audience = context?.agent === BEADS_TASK_AGENT ? "task-agent" : "primary";
166
+ return `<beads-context>\n${output}\n</beads-context>\n\n${beadsGuidance(audience)}`;
167
+ }
168
+
169
+ async function injectPrompt(
170
+ sessionID: string,
171
+ context?: { model?: ModelContext; agent?: string }
172
+ ): Promise<boolean> {
173
+ const rendered = await renderContext(sessionID, context);
174
+ if (!rendered) return false;
175
+
176
+ try {
177
+ await runtime.prompt(directory, sessionID, {
178
+ noReply: true,
179
+ model: context?.model,
180
+ agent: context?.agent,
181
+ parts: [
182
+ {
183
+ type: "text",
184
+ text: rendered,
185
+ synthetic: true,
186
+ },
187
+ ],
188
+ });
189
+ return true;
190
+ } catch {
191
+ await diagnoseSession("prompt_failed", sessionID);
192
+ return false;
193
+ }
194
+ }
195
+
196
+ async function performInitialInjection(message: MessageContext, sink: ContextSink): Promise<void> {
197
+ if (!(await shouldInject(message.agent, message.sessionID))) {
198
+ injectedSessions.add(message.sessionID);
199
+ return;
200
+ }
201
+
202
+ try {
203
+ const existing = await runtime.getMessages(directory, message.sessionID);
204
+ const hasBeadsContext = existing?.some(
205
+ (item) =>
206
+ item.info.system?.includes("<beads-context>") ||
207
+ item.parts?.some(
208
+ (part) => part.type === "text" && part.text?.includes("<beads-context>")
209
+ )
210
+ );
211
+ if (hasBeadsContext) {
212
+ injectedSessions.add(message.sessionID);
213
+ return;
214
+ }
215
+ } catch {
216
+ await diagnoseSession("messages_lookup_failed", message.sessionID);
217
+ // Message lookup is advisory; injection remains the safe fallback.
218
+ }
219
+
220
+ const rendered = await renderContext(message.sessionID, message);
221
+ if (!rendered) return;
222
+
223
+ try {
224
+ await sink(rendered);
225
+ injectedSessions.add(message.sessionID);
226
+ } catch {
227
+ await diagnoseSession("prompt_failed", message.sessionID);
228
+ }
229
+ }
230
+
231
+ return {
232
+ async onMessage(message, sink) {
233
+ if (injectedSessions.has(message.sessionID)) return;
234
+
235
+ const pending = injectionAttempts.get(message.sessionID);
236
+ if (pending) return pending;
237
+
238
+ const attempt = performInitialInjection(message, sink);
239
+ injectionAttempts.set(message.sessionID, attempt);
240
+ try {
241
+ await attempt;
242
+ } finally {
243
+ if (injectionAttempts.get(message.sessionID) === attempt) {
244
+ injectionAttempts.delete(message.sessionID);
245
+ }
246
+ }
247
+ },
248
+
249
+ async onCompacted(sessionID) {
250
+ const messages = await runtime.getMessages(directory, sessionID, 50).catch(async () => {
251
+ await diagnoseSession("messages_lookup_failed", sessionID);
252
+ return undefined;
253
+ });
254
+ const context = latestSessionContext(messages);
255
+ if (await shouldInject(context?.agent, sessionID)) {
256
+ await injectPrompt(sessionID, context);
257
+ }
258
+ },
259
+
260
+ async configure(config) {
261
+ const commandCollisions = Object.keys(config.command ?? {})
262
+ .filter(
263
+ (name) =>
264
+ Object.hasOwn(commands, name) && config.command?.[name] !== commands[name]
265
+ )
266
+ .sort();
267
+ const agentCollisions = Object.keys(config.agent ?? {})
268
+ .filter(
269
+ (name) => Object.hasOwn(agents, name) && config.agent?.[name] !== agents[name]
270
+ )
271
+ .sort();
272
+
273
+ await Promise.all(
274
+ ([
275
+ ["command", commandCollisions],
276
+ ["agent", agentCollisions],
277
+ ] as const).map(async ([surface, names]) => {
278
+ if (names.length === 0) return;
279
+ await diagnose(
280
+ { code: "config_collision", directory, surface, names },
281
+ `config_collision:${surface}:${names.join("\0")}`
282
+ );
283
+ })
284
+ );
285
+
286
+ config.command = { ...commands, ...config.command };
287
+ config.agent = { ...agents, ...config.agent };
288
+ },
289
+ };
290
+ }
package/src/plugin.ts ADDED
@@ -0,0 +1,150 @@
1
+ /** OpenCode adapter for the Beads issue tracker. */
2
+
3
+ import type { Plugin, PluginInput } from "@opencode-ai/plugin";
4
+ import type { Agent, Part, SessionMessagesResponse } from "@opencode-ai/sdk";
5
+ import {
6
+ createBeadsController,
7
+ resolveProjectDirectory,
8
+ type PluginRuntime,
9
+ } from "./plugin-core";
10
+ import { runBdPrime } from "./prime";
11
+
12
+ function isRecord(value: unknown): value is Record<string, unknown> {
13
+ return typeof value === "object" && value !== null;
14
+ }
15
+
16
+ function isSessionMessage(value: unknown): boolean {
17
+ if (
18
+ !isRecord(value) ||
19
+ !isRecord(value.info) ||
20
+ (value.info.role !== "user" && value.info.role !== "assistant") ||
21
+ (value.info.agent !== undefined && typeof value.info.agent !== "string") ||
22
+ (value.info.system !== undefined && typeof value.info.system !== "string") ||
23
+ (value.info.model !== undefined &&
24
+ (!isRecord(value.info.model) ||
25
+ typeof value.info.model.providerID !== "string" ||
26
+ typeof value.info.model.modelID !== "string"))
27
+ ) {
28
+ return false;
29
+ }
30
+ if (!Array.isArray(value.parts)) return false;
31
+
32
+ return value.parts.every(
33
+ (part) =>
34
+ isRecord(part) &&
35
+ typeof part.type === "string" &&
36
+ (part.text === undefined || typeof part.text === "string")
37
+ );
38
+ }
39
+
40
+ function isAgent(value: unknown): boolean {
41
+ return (
42
+ isRecord(value) &&
43
+ typeof value.name === "string" &&
44
+ (value.mode === "subagent" || value.mode === "primary" || value.mode === "all")
45
+ );
46
+ }
47
+
48
+ /** Prevent the synthetic no-reply prompt from recursively triggering its own injection. */
49
+ function isBeadsContextInjection(parts: ReadonlyArray<Part>): boolean {
50
+ return parts.some(
51
+ (part) =>
52
+ part.type === "text" &&
53
+ part.synthetic === true &&
54
+ part.text.includes("<beads-context>")
55
+ );
56
+ }
57
+
58
+ /** Adapt the official OpenCode client to the controller's small deterministic boundary. */
59
+ export function createOpenCodeRuntime(client: PluginInput["client"]): PluginRuntime {
60
+ return {
61
+ async getMessages(projectDirectory, sessionID, limit) {
62
+ const response = await client.session.messages({
63
+ path: { id: sessionID },
64
+ query: { directory: projectDirectory, limit },
65
+ });
66
+ if (response.error !== undefined) throw response.error;
67
+ if (!Array.isArray(response.data) || !response.data.every(isSessionMessage)) {
68
+ throw new Error("OpenCode returned malformed session messages");
69
+ }
70
+ return response.data satisfies SessionMessagesResponse;
71
+ },
72
+
73
+ async getAgents(projectDirectory) {
74
+ const response = await client.app.agents({ query: { directory: projectDirectory } });
75
+ if (response.error !== undefined) throw response.error;
76
+ if (!Array.isArray(response.data) || !response.data.every(isAgent)) {
77
+ throw new Error("OpenCode returned malformed agents");
78
+ }
79
+ return response.data satisfies Agent[];
80
+ },
81
+
82
+ async prompt(projectDirectory, sessionID, body) {
83
+ const response = await client.session.prompt({
84
+ path: { id: sessionID },
85
+ query: { directory: projectDirectory },
86
+ body,
87
+ });
88
+ if (response.error !== undefined) throw response.error;
89
+ if (response.data === undefined) throw new Error("OpenCode returned no prompt result");
90
+ },
91
+
92
+ async prime(projectDirectory) {
93
+ return runBdPrime(projectDirectory);
94
+ },
95
+
96
+ async diagnose(diagnostic) {
97
+ const extra =
98
+ diagnostic.code === "config_collision"
99
+ ? { surface: diagnostic.surface, names: diagnostic.names }
100
+ : { sessionID: diagnostic.sessionID };
101
+ const response = await client.app.log({
102
+ query: { directory: diagnostic.directory },
103
+ body: {
104
+ service: "opencode-beads",
105
+ level: "warn",
106
+ message: diagnostic.code,
107
+ extra,
108
+ },
109
+ });
110
+ if (response.error !== undefined) throw response.error;
111
+ if (response.data === undefined) throw new Error("OpenCode returned no log result");
112
+ },
113
+ };
114
+ }
115
+
116
+ export const BeadsPlugin: Plugin = async ({ client, directory, worktree }) => {
117
+ const projectDirectory = resolveProjectDirectory(directory, worktree);
118
+ const runtime = createOpenCodeRuntime(client);
119
+
120
+ const controller = await createBeadsController(runtime, projectDirectory);
121
+
122
+ return {
123
+ "chat.message": async (input, output) => {
124
+ if (isBeadsContextInjection(output.parts)) return;
125
+
126
+ await controller.onMessage(
127
+ {
128
+ sessionID: input.sessionID,
129
+ model: input.model,
130
+ agent: input.agent,
131
+ },
132
+ (context) => {
133
+ output.message.system = output.message.system
134
+ ? `${output.message.system}\n\n${context}`
135
+ : context;
136
+ }
137
+ );
138
+ },
139
+
140
+ event: async ({ event }) => {
141
+ if (event.type === "session.compacted") {
142
+ await controller.onCompacted(event.properties.sessionID);
143
+ }
144
+ },
145
+
146
+ config: async (config) => {
147
+ await controller.configure(config);
148
+ },
149
+ };
150
+ };
package/src/prime.ts ADDED
@@ -0,0 +1,88 @@
1
+ export const DEFAULT_PRIME_TIMEOUT_MS = 10_000;
2
+
3
+ export class PrimeTimeoutError extends Error {
4
+ constructor(timeoutMs: number) {
5
+ super(`bd prime timed out after ${timeoutMs}ms`);
6
+ this.name = "PrimeTimeoutError";
7
+ }
8
+ }
9
+
10
+ export class PrimeProcessError extends Error {
11
+ constructor(readonly exitCode: number, readonly stderr: string) {
12
+ super(`bd prime exited with code ${exitCode}`);
13
+ this.name = "PrimeProcessError";
14
+ }
15
+ }
16
+
17
+ export interface PrimeProcess {
18
+ stdout: ReadableStream<Uint8Array>;
19
+ stderr: ReadableStream<Uint8Array>;
20
+ exited: Promise<number>;
21
+ kill(signal?: NodeJS.Signals): void;
22
+ }
23
+
24
+ export interface PrimeExecutionOptions {
25
+ timeoutMs?: number;
26
+ spawn?: (directory: string, args: readonly string[]) => PrimeProcess;
27
+ scheduleTimeout?: (callback: () => void, delayMs: number) => () => void;
28
+ }
29
+
30
+ function spawnPrime(directory: string, args: readonly string[]): PrimeProcess {
31
+ return Bun.spawn(["bd", "prime", ...args], {
32
+ cwd: directory,
33
+ stdin: "ignore",
34
+ stdout: "pipe",
35
+ stderr: "pipe",
36
+ });
37
+ }
38
+
39
+ function scheduleTimeout(callback: () => void, delayMs: number): () => void {
40
+ const timer = setTimeout(callback, delayMs);
41
+ return () => clearTimeout(timer);
42
+ }
43
+
44
+ async function runPrimeAttempt(
45
+ directory: string,
46
+ args: readonly string[],
47
+ options: PrimeExecutionOptions = {}
48
+ ): Promise<string> {
49
+ const timeoutMs = options.timeoutMs ?? DEFAULT_PRIME_TIMEOUT_MS;
50
+ const process = (options.spawn ?? spawnPrime)(directory, args);
51
+ const stdout = new Response(process.stdout).text();
52
+ const stderr = new Response(process.stderr).text();
53
+
54
+ let timedOut = false;
55
+ let rejectTimeout: (error: PrimeTimeoutError) => void = () => {};
56
+ const timeout = new Promise<never>((_resolve, reject) => {
57
+ rejectTimeout = reject;
58
+ });
59
+ const cancelTimeout = (options.scheduleTimeout ?? scheduleTimeout)(() => {
60
+ timedOut = true;
61
+ process.kill("SIGKILL");
62
+ rejectTimeout(new PrimeTimeoutError(timeoutMs));
63
+ }, timeoutMs);
64
+
65
+ const completion = Promise.all([process.exited, stdout, stderr]).then(
66
+ ([exitCode, output, errorOutput]) => {
67
+ if (exitCode !== 0) throw new PrimeProcessError(exitCode, errorOutput);
68
+ return output;
69
+ }
70
+ );
71
+
72
+ try {
73
+ return await Promise.race([completion, timeout]);
74
+ } finally {
75
+ cancelTimeout();
76
+ if (timedOut) {
77
+ await completion.catch(() => undefined);
78
+ }
79
+ }
80
+ }
81
+
82
+ /** Load the canonical Beads workflow and persistent project memories. */
83
+ export async function runBdPrime(
84
+ directory: string,
85
+ options: PrimeExecutionOptions = {}
86
+ ): Promise<string> {
87
+ return runPrimeAttempt(directory, [], options);
88
+ }
@@ -0,0 +1,133 @@
1
+ import { lstat } from "node:fs/promises";
2
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
3
+
4
+ const SKILL_ROOTS = [".opencode", ".agents", ".claude"] as const;
5
+
6
+ export type SkillScope = "global" | "project";
7
+ export type ManagedSkillState = "current" | "modified" | "stale";
8
+ export type SkillLocationState =
9
+ | "absent"
10
+ | "differently-managed"
11
+ | "managed-current"
12
+ | "managed-modified"
13
+ | "managed-stale"
14
+ | "unmanaged";
15
+
16
+ export interface SkillLocation {
17
+ path: string;
18
+ root: (typeof SKILL_ROOTS)[number];
19
+ scope: SkillScope;
20
+ }
21
+
22
+ export interface SkillLocationInspection extends SkillLocation {
23
+ state: SkillLocationState;
24
+ }
25
+
26
+ export type ManagedSkillInspector = (
27
+ location: SkillLocation
28
+ ) => Promise<ManagedSkillState | "differently-managed" | undefined>;
29
+
30
+ function isMissing(error: unknown): boolean {
31
+ return (
32
+ typeof error === "object" &&
33
+ error !== null &&
34
+ "code" in error &&
35
+ error.code === "ENOENT"
36
+ );
37
+ }
38
+
39
+ /** Resolve every project and global discovery location for a skill named beads. */
40
+ export function beadsSkillLocations(
41
+ cwd: string,
42
+ worktree: string,
43
+ homeDirectory: string,
44
+ opencodeConfigDirectory = join(homeDirectory, ".config", "opencode")
45
+ ): SkillLocation[] {
46
+ const canonicalCwd = resolve(cwd);
47
+ const canonicalWorktree = resolve(worktree);
48
+ const fromWorktree = relative(canonicalWorktree, canonicalCwd);
49
+ if (fromWorktree === ".." || fromWorktree.startsWith(`..${sep}`) || isAbsolute(fromWorktree)) {
50
+ throw new Error(`cwd must be within worktree: ${canonicalCwd}`);
51
+ }
52
+
53
+ const projectDirectories: string[] = [];
54
+ for (let current = canonicalCwd; ; current = dirname(current)) {
55
+ projectDirectories.push(current);
56
+ if (current === canonicalWorktree) break;
57
+ }
58
+
59
+ const locations = [
60
+ ...projectDirectories.flatMap((directory) =>
61
+ SKILL_ROOTS.map((root) => ({
62
+ path: resolve(directory, root, "skills", "beads"),
63
+ root,
64
+ scope: "project" as const,
65
+ }))
66
+ ),
67
+ ...SKILL_ROOTS.map((root) => ({
68
+ path:
69
+ root === ".opencode"
70
+ ? join(opencodeConfigDirectory, "skills", "beads")
71
+ : join(homeDirectory, root, "skills", "beads"),
72
+ root,
73
+ scope: "global" as const,
74
+ })),
75
+ ];
76
+ const uniqueLocations: SkillLocation[] = [];
77
+ const seenPaths = new Set<string>();
78
+ for (const location of locations) {
79
+ if (seenPaths.has(location.path)) continue;
80
+ seenPaths.add(location.path);
81
+ uniqueLocations.push(location);
82
+ }
83
+ return uniqueLocations;
84
+ }
85
+
86
+ /** Inspect all discovery locations without mutating them. */
87
+ export async function inspectBeadsSkillLocations(
88
+ cwd: string,
89
+ worktree: string,
90
+ homeDirectory: string,
91
+ inspectManaged: ManagedSkillInspector,
92
+ opencodeConfigDirectory = join(homeDirectory, ".config", "opencode")
93
+ ): Promise<SkillLocationInspection[]> {
94
+ return Promise.all(
95
+ beadsSkillLocations(cwd, worktree, homeDirectory, opencodeConfigDirectory).map(
96
+ async (location) => {
97
+ let stats;
98
+ try {
99
+ stats = await lstat(location.path);
100
+ } catch (error) {
101
+ if (isMissing(error)) return { ...location, state: "absent" as const };
102
+ return { ...location, state: "unmanaged" as const };
103
+ }
104
+
105
+ if (!stats.isDirectory() || stats.isSymbolicLink()) {
106
+ return { ...location, state: "unmanaged" as const };
107
+ }
108
+
109
+ const managedState = await inspectManaged(location).catch(() => undefined);
110
+ let state: SkillLocationState = "unmanaged";
111
+ if (managedState === "differently-managed") {
112
+ state = managedState;
113
+ } else if (managedState) {
114
+ state = `managed-${managedState}`;
115
+ }
116
+ return { ...location, state };
117
+ }
118
+ )
119
+ );
120
+ }
121
+
122
+ /** Return every location that prevents a managed operation at the requested target. */
123
+ export function blockingSkillCollisions(
124
+ inspections: ReadonlyArray<SkillLocationInspection>,
125
+ target: string
126
+ ): SkillLocationInspection[] {
127
+ const resolvedTarget = resolve(target);
128
+ return inspections.filter((inspection) => {
129
+ if (inspection.state === "absent") return false;
130
+ if (resolve(inspection.path) !== resolvedTarget) return true;
131
+ return inspection.state !== "managed-current" && inspection.state !== "managed-stale";
132
+ });
133
+ }