opencode-overclock 0.3.0 → 0.5.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 (82) hide show
  1. package/README.md +252 -111
  2. package/package.json +6 -4
  3. package/skills/codebase-design/DEEPENING.md +35 -0
  4. package/skills/codebase-design/DESIGN-IT-TWICE.md +34 -0
  5. package/skills/codebase-design/SKILL.md +93 -0
  6. package/skills/diagnosing-bugs/SKILL.md +123 -0
  7. package/skills/domain-modeling/ADR-FORMAT.md +55 -0
  8. package/skills/domain-modeling/CONTEXT-FORMAT.md +32 -0
  9. package/skills/domain-modeling/SKILL.md +102 -0
  10. package/skills/doubt/SKILL.md +80 -0
  11. package/skills/grilling/SKILL.md +96 -0
  12. package/skills/source-discipline/SKILL.md +78 -0
  13. package/skills/tdd/SKILL.md +87 -0
  14. package/skills/to-spec/SKILL.md +69 -0
  15. package/skills/to-spec/SPEC-TEMPLATE.md +50 -0
  16. package/skills/to-tickets/SKILL.md +74 -0
  17. package/skills/to-tickets/TICKET-TEMPLATE.md +41 -0
  18. package/src/bridge.ts +1 -0
  19. package/src/buddy/companion.ts +104 -5
  20. package/src/buddy/sprites.ts +4 -4
  21. package/src/buddy/tui.ts +175 -65
  22. package/src/core/bridge.ts +34 -0
  23. package/src/core/lifecycle.ts +67 -0
  24. package/src/core/policy.ts +128 -0
  25. package/src/core/summary.ts +33 -0
  26. package/src/core/types.ts +193 -0
  27. package/src/features/buddy.ts +1 -2
  28. package/src/features/guard.ts +421 -37
  29. package/src/features/index.ts +18 -4
  30. package/src/features/recovery.ts +153 -0
  31. package/src/features/safety.ts +147 -0
  32. package/src/features/sched.ts +183 -89
  33. package/src/features/tasks.ts +134 -33
  34. package/src/features/truncator.ts +116 -0
  35. package/src/features/usage.ts +46 -65
  36. package/src/features/workflow.ts +256 -0
  37. package/src/index.ts +96 -67
  38. package/src/lib/busy.ts +1 -25
  39. package/src/lib/exec.ts +13 -0
  40. package/src/lib/inject.ts +10 -56
  41. package/src/lib/mirror.ts +13 -0
  42. package/src/lib/probe.ts +1 -15
  43. package/src/lib/state.ts +10 -39
  44. package/src/lib/tmux.ts +1 -0
  45. package/src/lib/ui.ts +208 -0
  46. package/src/merge.ts +2 -66
  47. package/src/platform/probe.ts +25 -0
  48. package/src/platform/process/exec.ts +317 -0
  49. package/src/platform/process/tmux.ts +60 -0
  50. package/src/platform/session/busy.ts +33 -0
  51. package/src/platform/session/inject.ts +89 -0
  52. package/src/platform/session/notify.ts +20 -0
  53. package/src/platform/storage/state.ts +99 -0
  54. package/src/platform/storage/store.ts +61 -0
  55. package/src/summary.ts +1 -0
  56. package/src/tools.ts +8 -244
  57. package/src/tui.ts +57 -186
  58. package/src/types.ts +1 -73
  59. package/src/v2/context.ts +470 -0
  60. package/src/v2/host.ts +120 -0
  61. package/src/v2/loader.ts +150 -0
  62. package/src/workflow/agents/codebase-researcher.ts +27 -0
  63. package/src/workflow/agents/design-explorer.ts +33 -0
  64. package/src/workflow/agents/doubt-reviewer.ts +26 -0
  65. package/src/workflow/agents/engineering-coach.ts +23 -0
  66. package/src/workflow/agents/performance-auditor.ts +29 -0
  67. package/src/workflow/agents/security-auditor.ts +23 -0
  68. package/src/workflow/agents/spec-reviewer.ts +15 -0
  69. package/src/workflow/agents/standards-reviewer.ts +24 -0
  70. package/src/workflow/agents/test-engineer.ts +28 -0
  71. package/src/workflow/catalog.ts +210 -0
  72. package/src/workflow/templates/build.ts +47 -0
  73. package/src/workflow/templates/define.ts +45 -0
  74. package/src/workflow/templates/diagnose.ts +58 -0
  75. package/src/workflow/templates/plan.ts +52 -0
  76. package/src/workflow/templates/ship.ts +64 -0
  77. package/src/buddy/reactions.ts +0 -41
  78. package/src/buddy/types.ts +0 -30
  79. package/src/config.ts +0 -19
  80. package/src/features/checkpoints.ts +0 -128
  81. package/src/features/sandbox.ts +0 -104
  82. package/src/validate.ts +0 -197
@@ -0,0 +1,33 @@
1
+ import type { BusyTracker } from "../../core/types.ts"
2
+
3
+ export type { BusyTracker } from "../../core/types.ts"
4
+
5
+ /**
6
+ * Session busy tracking via `session.status` (doc-preferred; `session.idle` deprecated).
7
+ * Resilient to aborts, errors, and deletions so sessions do not deadlock in busy state.
8
+ */
9
+ export function createBusyTracker(): BusyTracker {
10
+ const busy = new Set<string>()
11
+ return {
12
+ onEvent(event: unknown) {
13
+ if (!event || typeof event !== "object") return
14
+ const ev = event as { type?: unknown; properties?: unknown }
15
+ if (typeof ev.type !== "string") return
16
+
17
+ const p = (ev.properties ?? {}) as { sessionID?: string; status?: { type?: string } }
18
+ if (!p.sessionID) return
19
+
20
+ if (ev.type === "session.status") {
21
+ p.status?.type === "idle" ? busy.delete(p.sessionID) : busy.add(p.sessionID)
22
+ } else if (
23
+ ev.type === "session.idle" ||
24
+ ev.type === "session.deleted" ||
25
+ ev.type === "session.error" ||
26
+ ev.type === "session.aborted"
27
+ ) {
28
+ busy.delete(p.sessionID)
29
+ }
30
+ },
31
+ isBusy: (id) => (id ? busy.has(id) : false),
32
+ }
33
+ }
@@ -0,0 +1,89 @@
1
+ import type { PluginInput } from "@opencode-ai/plugin"
2
+ import { toast } from "./notify.ts"
3
+
4
+ export { toast, type ToastVariant } from "./notify.ts"
5
+
6
+ type Client = PluginInput["client"]
7
+ export type ModelRef = { providerID: string; modelID: string }
8
+ export type SessionContext = {
9
+ model?: ModelRef
10
+ agent?: string
11
+ }
12
+
13
+ export interface InjectOptions {
14
+ noReply?: boolean
15
+ }
16
+
17
+ /**
18
+ * Session's active context = model of last assistant message and most recent agent name.
19
+ * Without model, promptAsync falls back to config default model -> injected turns
20
+ * run on the wrong model (and pile up QUEUED behind a hung default).
21
+ * Preserving agent ensures turns continue under the active persona.
22
+ */
23
+ export async function sessionContext(client: Client, sessionID: string): Promise<SessionContext> {
24
+ try {
25
+ const res = await client.session.messages({ path: { id: sessionID } })
26
+ const msgs = res.data ?? []
27
+ let model: ModelRef | undefined
28
+ let agent: string | undefined
29
+
30
+ for (let i = msgs.length - 1; i >= 0; i--) {
31
+ const info = msgs[i]?.info as
32
+ { role?: string; modelID?: string; providerID?: string; agent?: string } | undefined
33
+ if (!model && info?.role === "assistant" && info.modelID && info.providerID) {
34
+ model = { providerID: info.providerID, modelID: info.modelID }
35
+ }
36
+ if (!agent && info?.agent) {
37
+ agent = info.agent
38
+ }
39
+ if (model && agent) break
40
+ }
41
+ return { model, agent }
42
+ } catch (e) {
43
+ console.warn(`[overclock] sessionContext lookup failed (${sessionID}): ${e}`)
44
+ return {}
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Session's active model = model of last assistant message.
50
+ */
51
+ export async function sessionModel(client: Client, sessionID: string): Promise<ModelRef | undefined> {
52
+ const ctx = await sessionContext(client, sessionID)
53
+ return ctx.model
54
+ }
55
+
56
+ /**
57
+ * Re-entry: push text into session as user prompt, on the session's own model and agent.
58
+ * promptAsync = fire-and-forget, server queues if busy. Failure -> warn, never throw.
59
+ */
60
+ export async function inject(
61
+ client: Client,
62
+ sessionID: string,
63
+ text: string,
64
+ options?: InjectOptions,
65
+ ): Promise<boolean> {
66
+ try {
67
+ const ctx = await sessionContext(client, sessionID)
68
+ const res = await (client.session.promptAsync as any)({
69
+ path: { id: sessionID },
70
+ throwOnError: true,
71
+ body: {
72
+ parts: [{ type: "text", text }],
73
+ ...(ctx.model ? { model: ctx.model } : {}),
74
+ ...(ctx.agent ? { agent: ctx.agent } : {}),
75
+ ...(options?.noReply ? { noReply: true } : {}),
76
+ },
77
+ })
78
+ if (res && typeof res === "object" && "error" in res && (res as any).error) {
79
+ console.warn(
80
+ `[overclock] inject failed (session ${sessionID}): ${JSON.stringify((res as any).error)}`,
81
+ )
82
+ return false
83
+ }
84
+ return true
85
+ } catch (e) {
86
+ console.warn(`[overclock] inject failed (session ${sessionID}): ${e}`)
87
+ return false
88
+ }
89
+ }
@@ -0,0 +1,20 @@
1
+ import type { PluginInput } from "@opencode-ai/plugin"
2
+
3
+ type Client = PluginInput["client"]
4
+
5
+ export type ToastVariant = "info" | "success" | "warning" | "error"
6
+
7
+ /**
8
+ * TUI toast notification, best-effort (swallows errors in headless/no-TUI environments).
9
+ */
10
+ export async function toast(
11
+ client: Client,
12
+ message: string,
13
+ variant: ToastVariant = "info",
14
+ ): Promise<void> {
15
+ try {
16
+ await client.tui.showToast({ body: { message, variant } })
17
+ } catch {
18
+ // no TUI attached or client lacks tui surface
19
+ }
20
+ }
@@ -0,0 +1,99 @@
1
+ import { mkdir, copyFile } from "node:fs/promises"
2
+ import { writeFileSync, renameSync, unlinkSync, mkdirSync } from "node:fs"
3
+ import { dirname } from "node:path"
4
+ import { shellQuote } from "../process/exec.ts"
5
+
6
+ export { shellQuote }
7
+
8
+ /** State root: <project>/.opencode/overclock/[sub]. Pure -- creates nothing. */
9
+ export function stateDir(directory: string, sub?: string): string {
10
+ return `${directory}/.opencode/overclock${sub ? `/${sub}` : ""}`
11
+ }
12
+
13
+ /** State root: <project>/.opencode/overclock/[sub]. Creates if missing. */
14
+ export async function ensureStateDir(directory: string, sub?: string): Promise<string> {
15
+ const dir = stateDir(directory, sub)
16
+ await mkdir(dir, { recursive: true })
17
+ return dir
18
+ }
19
+
20
+ /**
21
+ * True once per project, then never again. Marker lives beside the other state, so
22
+ * deleting .opencode/overclock/ re-arms the first-run notice.
23
+ */
24
+ export async function firstRun(directory: string): Promise<boolean> {
25
+ const dir = await ensureStateDir(directory)
26
+ const marker = Bun.file(`${dir}/.installed`)
27
+ if (await marker.exists()) return false
28
+ await Bun.write(marker, new Date().toISOString())
29
+ return true
30
+ }
31
+
32
+ export async function readJson<T>(path: string, fallback: T): Promise<T> {
33
+ const file = Bun.file(path)
34
+ if (!(await file.exists())) return fallback
35
+ try {
36
+ return (await file.json()) as T
37
+ } catch (e) {
38
+ console.warn(`[overclock] failed to parse JSON at ${path}: ${e}`)
39
+ try {
40
+ await copyFile(path, `${path}.corrupt.${Date.now()}`)
41
+ } catch {}
42
+ return fallback
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Serializes the value formatted with 2 spaces and atomically writes to the destination path.
48
+ */
49
+ export async function writeJson(path: string, value: unknown): Promise<void> {
50
+ const tmpPath = `${path}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}.tmp`
51
+ const content = JSON.stringify(value, null, 2)
52
+ try {
53
+ writeFileSync(tmpPath, content)
54
+ renameSync(tmpPath, path)
55
+ } catch (err: any) {
56
+ if (err?.code === "ENOENT") {
57
+ mkdirSync(dirname(path), { recursive: true })
58
+ writeFileSync(tmpPath, content)
59
+ renameSync(tmpPath, path)
60
+ return
61
+ }
62
+ try {
63
+ unlinkSync(tmpPath)
64
+ } catch {}
65
+ throw err
66
+ }
67
+ }
68
+
69
+ /** A typed state file accessor. */
70
+ export interface Store<T> {
71
+ readonly file: string
72
+ path(directory: string): string
73
+ read(directory: string): Promise<T>
74
+ readMaybe(directory: string): Promise<T | undefined>
75
+ write(directory: string, value: T): Promise<void>
76
+ }
77
+
78
+ /** Factory for typed state file stores. */
79
+ export function defineStore<T>(file: string, fallback: () => T): Store<T> {
80
+ const path = (directory: string) => `${stateDir(directory)}/${file}`
81
+ return {
82
+ file,
83
+ path,
84
+ read: (directory) => readJson<T>(path(directory), fallback()),
85
+ readMaybe: async (directory) => {
86
+ const target = path(directory)
87
+ try {
88
+ if (!(await Bun.file(target).exists())) return undefined
89
+ } catch {
90
+ return undefined
91
+ }
92
+ return readJson<T>(target, fallback())
93
+ },
94
+ write: async (directory, value) => {
95
+ await ensureStateDir(directory)
96
+ await writeJson(path(directory), value)
97
+ },
98
+ }
99
+ }
@@ -0,0 +1,61 @@
1
+ import { defineStore } from "./state.ts"
2
+
3
+ // ---------------------------------------------------------------- tasks
4
+
5
+ export interface TaskMirrorEntry {
6
+ id: string
7
+ description: string
8
+ status: "running" | "exited" | "killed"
9
+ exitCode: number | null
10
+ startedAt: number
11
+ }
12
+
13
+ export const taskStore = defineStore<TaskMirrorEntry[]>("tasks.json", () => [])
14
+
15
+ // ---------------------------------------------------------------- usage
16
+
17
+ export interface UsageTokens {
18
+ input: number
19
+ output: number
20
+ reasoning: number
21
+ cacheRead: number
22
+ cacheWrite: number
23
+ }
24
+
25
+ export interface DayBucket {
26
+ cost: number
27
+ tokens: UsageTokens
28
+ messages: number
29
+ /** message ids already counted, so a replayed event cannot double-bill */
30
+ seen: string[]
31
+ }
32
+
33
+ export interface UsageState {
34
+ days: Record<string, DayBucket>
35
+ }
36
+
37
+ /** What the TUI needs off a day bucket. `seen` is write-side bookkeeping. */
38
+ export type DayBucketView = Omit<DayBucket, "seen">
39
+
40
+ export interface UsageStateView {
41
+ days?: Record<string, DayBucketView | undefined>
42
+ }
43
+
44
+ export const usageStore = defineStore<UsageState>("usage.json", () => ({ days: {} }))
45
+
46
+ // ---------------------------------------------------------------- schedules
47
+
48
+ export interface ScheduleEntry {
49
+ id: string
50
+ spec: string
51
+ prompt: string
52
+ target: "current" | "new-session"
53
+ /** creator; also the inject target when target=current */
54
+ sessionID: string
55
+ createdAt: string
56
+ }
57
+
58
+ /** The TUI lists schedules; it has no business reading the prompt or the session id. */
59
+ export type ScheduleEntryView = Pick<ScheduleEntry, "id" | "spec">
60
+
61
+ export const scheduleStore = defineStore<ScheduleEntry[]>("schedules.json", () => [])
package/src/summary.ts ADDED
@@ -0,0 +1 @@
1
+ export { summarise } from "./core/summary.ts"
package/src/tools.ts CHANGED
@@ -1,244 +1,8 @@
1
- import type { ConfigIssue, FeatureModule, OverclockConfig } from "./types.ts"
2
-
3
- /**
4
- * Tool ids opencode registers itself (observed on 1.18.4 via `/experimental/tool/ids`).
5
- *
6
- * Only used to warn: a tool registered under one of these *replaces* the built-in in the final
7
- * tool map, and a name that differs only by case (`Task` vs `task`) is worse still -- the host
8
- * offers both, and a consumer that matches case-insensitively sees a duplicate. Drift in this
9
- * list only makes the warning less complete, never wrong.
10
- */
11
- export const HOST_TOOL_IDS: readonly string[] = [
12
- "apply_patch",
13
- "bash",
14
- "edit",
15
- "glob",
16
- "grep",
17
- "invalid",
18
- "question",
19
- "read",
20
- "skill",
21
- "task",
22
- "todowrite",
23
- "webfetch",
24
- "websearch",
25
- "write",
26
- ]
27
-
28
- export interface KnownAllowlist {
29
- /** every name the list permits */
30
- names: readonly string[]
31
- /**
32
- * declared tool name -> the name from this list it is offered under. Only for names that
33
- * mean the same operation; nothing is invented, so this table stays short.
34
- */
35
- aliases: Readonly<Record<string, string>>
36
- /**
37
- * Tools with no honest alias in this list, and why. Recorded rather than left blank so the
38
- * absence is a decision someone made, not an oversight -- a test requires every tool to be
39
- * in `aliases` or here, and the reason is shown when such a tool gets withheld.
40
- */
41
- unaliased: Readonly<Record<string, string>>
42
- }
43
-
44
- /**
45
- * Named allowlists, usable anywhere a tool name is accepted in `toolAllowlist`.
46
- *
47
- * `claude-code` is Claude Code's registered tool set. opencode's own ids are snake_case and
48
- * disjoint from it, so every name here is free for this plugin to use.
49
- *
50
- * `aliases` only covers tools where a name in the list denotes the same operation, so nothing
51
- * here is a guess: background tasks and cron already exist in this vocabulary. Anything else is
52
- * listed in `unaliased` with the reason, because picking an unrelated name on a user's behalf
53
- * would mislead the model about what the tool does. Map those yourself with `toolNames`.
54
- *
55
- * Adding a tool: give it an alias or an `unaliased` reason in the same commit. A test fails
56
- * otherwise, so the decision surfaces in CI rather than as a rejected request mid-session.
57
- */
58
- export const KNOWN_ALLOWLISTS: Readonly<Record<string, KnownAllowlist>> = {
59
- "claude-code": {
60
- names: [
61
- "Read",
62
- "Write",
63
- "Edit",
64
- "MultiEdit",
65
- "NotebookEdit",
66
- "Glob",
67
- "Grep",
68
- "Bash",
69
- "Agent",
70
- "Task",
71
- "Workflow",
72
- "TodoWrite",
73
- "TaskCreate",
74
- "TaskGet",
75
- "TaskList",
76
- "TaskUpdate",
77
- "TaskStop",
78
- "TaskOutput",
79
- "TeamCreate",
80
- "TeamDelete",
81
- "SendMessage",
82
- "EnterPlanMode",
83
- "ExitPlanMode",
84
- "EnterWorktree",
85
- "ExitWorktree",
86
- "ListMcpResourcesTool",
87
- "WaitForMcpServers",
88
- "ToolSearch",
89
- "Skill",
90
- "CronCreate",
91
- "CronDelete",
92
- "CronList",
93
- "ScheduleWakeup",
94
- "AskUserQuestion",
95
- "StructuredOutput",
96
- "ValidationResult",
97
- "ReportFindings",
98
- "LSP",
99
- ],
100
- aliases: {
101
- task_run: "TaskCreate",
102
- task_status: "TaskList",
103
- task_output: "TaskOutput",
104
- task_kill: "TaskStop",
105
- schedule_create: "CronCreate",
106
- schedule_list: "CronList",
107
- schedule_delete: "CronDelete",
108
- },
109
- unaliased: {
110
- bash_unsandboxed: "the only fitting name is `Bash`, which is also an opencode built-in",
111
- checkpoint_list: "no name in this list denotes session checkpoints",
112
- checkpoint_revert: "no name in this list denotes session checkpoints",
113
- checkpoint_restore: "no name in this list denotes session checkpoints",
114
- usage_report: "no name in this list denotes cost/token telemetry",
115
- },
116
- },
117
- }
118
-
119
- export interface ToolPolicy {
120
- /** declared name -> model-visible name */
121
- rename: Record<string, string>
122
- /** declared names withheld from the model entirely (no allowed name to use) */
123
- withheld: Set<string>
124
- }
125
-
126
- export const EMPTY_POLICY: ToolPolicy = { rename: {}, withheld: new Set() }
127
-
128
- /** Entries like "claude-code" name a bundled list; a literal tool id would not look like this. */
129
- function looksLikeListName(entry: string): boolean {
130
- return /^[a-z0-9]+(-[a-z0-9]+)+$/.test(entry)
131
- }
132
-
133
- /**
134
- * Expand `toolAllowlist` into the names it permits plus the aliases any named list brings.
135
- * Entries are either a known list name or a literal tool name, so extending a bundled list is
136
- * `["claude-code", "MyExtraTool"]` -- no separate key, no re-listing what the bundle covers.
137
- */
138
- export function resolveAllowlist(value: unknown): {
139
- names?: string[]
140
- aliases: Record<string, string>
141
- /** declared tool -> why no bundled list offered a name for it */
142
- unaliased: Record<string, string>
143
- issues: ConfigIssue[]
144
- } {
145
- const aliases: Record<string, string> = {}
146
- const unaliased: Record<string, string> = {}
147
- if (value === undefined) return { aliases, unaliased, issues: [] }
148
-
149
- const entries = typeof value === "string" ? [value] : value
150
- if (!Array.isArray(entries) || !entries.every((v) => typeof v === "string")) {
151
- return {
152
- aliases,
153
- unaliased,
154
- issues: [
155
- {
156
- path: "toolAllowlist",
157
- message: `must be a name or an array of names (a known list is ${Object.keys(KNOWN_ALLOWLISTS).join(", ")}), got ${Array.isArray(value) ? "array with non-strings" : typeof value}`,
158
- },
159
- ],
160
- }
161
- }
162
-
163
- const names: string[] = []
164
- const issues: ConfigIssue[] = []
165
- for (const entry of entries as string[]) {
166
- const known = KNOWN_ALLOWLISTS[entry]
167
- if (known) {
168
- names.push(...known.names)
169
- Object.assign(aliases, known.aliases)
170
- Object.assign(unaliased, known.unaliased)
171
- continue
172
- }
173
- // A typo'd list name would otherwise pass as a literal tool name, withhold everything, and
174
- // suggest the typo itself as a free name. Cheap to catch, confusing to debug.
175
- if (looksLikeListName(entry)) {
176
- issues.push({
177
- path: "toolAllowlist",
178
- message: `"${entry}" looks like a known list but is not one (known: ${Object.keys(KNOWN_ALLOWLISTS).join(", ")}) -- treating it as a literal tool name`,
179
- })
180
- }
181
- names.push(entry)
182
- }
183
- return { names, aliases, unaliased, issues }
184
- }
185
-
186
- /**
187
- * Fold the allowlist's aliases + explicit renames into one policy, and report what a human
188
- * needs to act on. Every check here exists because the failure it catches is otherwise
189
- * invisible until a request comes back rejected:
190
- * - a tool with no permitted name is withheld, and the still-free names are listed so picking
191
- * one is a single config line;
192
- * - a name that collides with a host built-in (or its case-twin) is called out, since that
193
- * replaces the built-in or reads as a duplicate.
194
- */
195
- export function resolveToolPolicy(
196
- config: Pick<OverclockConfig, "toolNames" | "toolAllowlist">,
197
- features: readonly FeatureModule[],
198
- ): { policy: ToolPolicy; issues: ConfigIssue[] } {
199
- const { names: allowlist, aliases, unaliased, issues } = resolveAllowlist(config.toolAllowlist)
200
-
201
- // Explicit names win: a bundled list is a starting point, not a straitjacket.
202
- const rename: Record<string, string> = { ...aliases, ...(config.toolNames ?? {}) }
203
-
204
- const final = new Map<string, string>() // declared -> model-visible
205
- for (const name of features.flatMap((f) => f.tools ?? [])) {
206
- final.set(name, rename[name] ?? name)
207
- }
208
-
209
- for (const [name, visible] of final) {
210
- const twin = HOST_TOOL_IDS.find((id) => id.toLowerCase() === visible.toLowerCase())
211
- if (!twin) continue
212
- issues.push({
213
- path: `tool "${name}"`,
214
- message:
215
- twin === visible
216
- ? `"${visible}" is an opencode built-in -- registering it replaces that built-in`
217
- : `"${visible}" differs from opencode's built-in "${twin}" only by case; anything matching case-insensitively sees one name twice`,
218
- })
219
- }
220
-
221
- const withheld = new Set<string>()
222
- if (allowlist) {
223
- const allowed = new Set(allowlist)
224
- const taken = new Set([...final.values()].filter((v) => allowed.has(v)))
225
- const builtin = new Set(HOST_TOOL_IDS.map((id) => id.toLowerCase()))
226
- // Suggesting a name that would immediately earn a built-in collision warning is worse than
227
- // suggesting nothing, so case-twins of opencode's own ids are not offered.
228
- const free = allowlist.filter((n) => !taken.has(n) && !builtin.has(n.toLowerCase()))
229
- for (const [name, visible] of final) {
230
- if (allowed.has(visible)) continue
231
- withheld.add(name)
232
- const why = unaliased[name] ? ` (${unaliased[name]})` : ""
233
- issues.push({
234
- path: `tool "${name}"`,
235
- message:
236
- `"${visible}" is not in toolAllowlist${why} -- withheld from the model. ` +
237
- `Pick a name for it via toolNames (free: ${free.slice(0, 4).join(", ") || "none left"}), ` +
238
- `or add one to toolAllowlist`,
239
- })
240
- }
241
- }
242
-
243
- return { policy: { rename, withheld }, issues }
244
- }
1
+ export {
2
+ applyToolPolicy,
3
+ EMPTY_POLICY,
4
+ HOST_TOOL_IDS,
5
+ renameInText,
6
+ resolveToolPolicy,
7
+ type ToolPolicy,
8
+ } from "./core/policy.ts"