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
package/src/buddy/tui.ts CHANGED
@@ -1,8 +1,21 @@
1
- import type { TuiPluginApi, TuiThemeCurrent } from "@opencode-ai/plugin/tui"
2
- import { rollCompanion, describeCompanion, migrateSpecies } from "./companion.ts"
3
- import type { Companion, Rarity } from "./types.ts"
1
+ import type { TuiThemeCurrent } from "@opencode-ai/plugin/tui"
2
+ import type { Ui } from "../lib/ui.ts"
3
+ import {
4
+ rollCompanion,
5
+ describeCompanion,
6
+ migrateSpecies,
7
+ pickReaction,
8
+ createReactionGate,
9
+ cycleSpecies,
10
+ switchSpecies,
11
+ speciesDescription,
12
+ SPECIES,
13
+ type Companion,
14
+ type Rarity,
15
+ type ReactionKind,
16
+ type Species,
17
+ } from "./companion.ts"
4
18
  import { spriteFrame, withBubble, spriteHeight, SPRITE_WIDTH, type SpriteState } from "./sprites.ts"
5
- import { pickReaction, createReactionGate, type ReactionKind } from "./reactions.ts"
6
19
 
7
20
  const KV_KEY = "buddy.companion"
8
21
  /** Below this terminal width the buddy hides rather than crowd the prompt. */
@@ -20,6 +33,8 @@ const SLEEP_AFTER_MS = 120_000
20
33
  interface SpriteNode {
21
34
  content: string
22
35
  visible: boolean
36
+ height?: number | string
37
+ fg?: unknown
23
38
  destroyed?: boolean
24
39
  }
25
40
 
@@ -38,20 +53,17 @@ function rarityColor(rarity: Rarity, theme: TuiThemeCurrent): unknown {
38
53
 
39
54
  /**
40
55
  * Hatch (or load) the companion and mount it in the prompt-right slots.
41
- * Dynamic import of @opentui/solid: if the host doesn't map the specifier to its
42
- * own instance, this throws and the caller degrades to "no buddy" -- it must
43
- * never take the rest of the TUI plugin down.
56
+ * `enableJsx` throws when the host does not map @opentui/solid to its own instance;
57
+ * the caller degrades to "no buddy" and the rest of the TUI plugin is unaffected.
44
58
  */
45
- export async function registerBuddy(api: TuiPluginApi): Promise<void> {
46
- const { jsx } = (await import("@opentui/solid/jsx-runtime")) as unknown as {
47
- jsx: (type: string, props?: Record<string, unknown> | null) => unknown
48
- }
59
+ export async function registerBuddy(ui: Ui): Promise<void> {
60
+ await ui.enableJsx()
49
61
 
50
- let companion = api.kv.get<Companion | undefined>(KV_KEY, undefined)
62
+ let companion = ui.api.kv?.get<Companion | undefined>(KV_KEY, undefined)
51
63
  if (!companion) {
52
64
  companion = rollCompanion()
53
- api.kv.set(KV_KEY, companion)
54
- api.ui.toast({ message: `a buddy hatched: ${describeCompanion(companion)}` })
65
+ ui.api.kv?.set(KV_KEY, companion)
66
+ ui.toast(`a buddy hatched: ${describeCompanion(companion)}`)
55
67
  } else {
56
68
  // A companion persisted under a species we have since retired has no art:
57
69
  // every lookup into ART would throw from inside the slot render, where the
@@ -60,11 +72,11 @@ export async function registerBuddy(api: TuiPluginApi): Promise<void> {
60
72
  const migrated = migrateSpecies(companion)
61
73
  if (migrated) {
62
74
  companion = migrated
63
- api.kv.set(KV_KEY, migrated)
64
- api.ui.toast({ message: `${migrated.name} is a ${migrated.species} now` })
75
+ ui.api.kv?.set(KV_KEY, migrated)
76
+ ui.toast(`${migrated.name} is a ${migrated.species} now`)
65
77
  }
66
78
  }
67
- const hatched: Companion = companion
79
+ let active: Companion = companion
68
80
 
69
81
  // One ticker drives every mounted node (home + session slots).
70
82
  const nodes = new Set<SpriteNode>()
@@ -82,9 +94,9 @@ export async function registerBuddy(api: TuiPluginApi): Promise<void> {
82
94
 
83
95
  function paint(): void {
84
96
  const now = Date.now()
85
- const frame = spriteFrame(hatched.species, currentState(now), tick)
97
+ const frame = spriteFrame(active.species, currentState(now), tick)
86
98
  const text = withBubble(frame, now < bubbleUntil ? bubble : undefined)
87
- const wide = api.renderer.width >= MIN_COLS
99
+ const wide = (ui.api.renderer?.width ?? 120) >= MIN_COLS
88
100
  for (const node of [...nodes]) {
89
101
  try {
90
102
  if (node.destroyed) {
@@ -108,29 +120,116 @@ export async function registerBuddy(api: TuiPluginApi): Promise<void> {
108
120
  paint()
109
121
  }
110
122
 
111
- const timer = setInterval(() => {
123
+ function setCompanion(next: Companion, toastMessage?: string): void {
124
+ active = next
125
+ ui.api.kv?.set(KV_KEY, next)
126
+ const currentTheme = ui.api.theme?.current
127
+ for (const node of [...nodes]) {
128
+ try {
129
+ if (node.destroyed) {
130
+ nodes.delete(node)
131
+ continue
132
+ }
133
+ if (currentTheme) {
134
+ node.fg = rarityColor(active.rarity, currentTheme)
135
+ }
136
+ node.height = spriteHeight(active.species)
137
+ } catch {
138
+ nodes.delete(node)
139
+ }
140
+ }
141
+ react("pet")
142
+ ui.toast(toastMessage ?? describeCompanion(active))
143
+ }
144
+
145
+ function openSwitchDialog(dialog?: unknown): void {
146
+ const stack = (dialog ?? ui.api.ui?.dialog) as
147
+ { replace(render: () => unknown, onClose?: () => void): void; clear(): void } | undefined
148
+ const DialogSelect = ui.api.ui?.DialogSelect
149
+
150
+ if (!stack || typeof stack.replace !== "function" || !DialogSelect) {
151
+ const next = cycleSpecies(active.species)
152
+ setCompanion(
153
+ switchSpecies(active, next),
154
+ `switched to ${next}: ${describeCompanion(switchSpecies(active, next))}`,
155
+ )
156
+ return
157
+ }
158
+
159
+ let closed = false
160
+ const selectBuddy = (speciesOrRandom: string) => {
161
+ if (closed) return
162
+ closed = true
163
+ try {
164
+ stack.clear()
165
+ } catch {
166
+ // ignore
167
+ }
168
+ if (speciesOrRandom === "random") {
169
+ const fresh = rollCompanion()
170
+ setCompanion(fresh, `a new buddy hatched: ${describeCompanion(fresh)}`)
171
+ } else if ((SPECIES as readonly string[]).includes(speciesOrRandom)) {
172
+ const nextSpecies = speciesOrRandom as Species
173
+ setCompanion(
174
+ switchSpecies(active, nextSpecies),
175
+ `switched to ${nextSpecies}: ${describeCompanion(switchSpecies(active, nextSpecies))}`,
176
+ )
177
+ }
178
+ }
179
+
180
+ const options = [
181
+ ...SPECIES.map((species) => ({
182
+ title: species,
183
+ value: species,
184
+ description: speciesDescription(species, species === active.species),
185
+ onSelect: () => selectBuddy(species),
186
+ })),
187
+ {
188
+ title: "random roll",
189
+ value: "random",
190
+ description: "hatch a brand new companion with new stats & rarity",
191
+ onSelect: () => selectBuddy("random"),
192
+ },
193
+ ]
194
+
195
+ try {
196
+ stack.replace(() =>
197
+ ui.node(DialogSelect as any, {
198
+ title: "Switch Buddy",
199
+ placeholder: "Select a species...",
200
+ current: active.species,
201
+ options,
202
+ onSelect: (opt: { value: string }) => selectBuddy(opt.value),
203
+ }),
204
+ )
205
+ } catch {
206
+ const next = cycleSpecies(active.species)
207
+ setCompanion(
208
+ switchSpecies(active, next),
209
+ `switched to ${next}: ${describeCompanion(switchSpecies(active, next))}`,
210
+ )
211
+ }
212
+ }
213
+
214
+ ui.every(TICK_MS, () => {
112
215
  tick++
113
216
  paint()
114
- }, TICK_MS)
115
- api.lifecycle.onDispose(async () => clearInterval(timer))
217
+ })
116
218
 
117
219
  const gate = createReactionGate()
118
- const unsubs = [
119
- api.event.on("session.status", (event) => {
120
- lastActivity = Date.now()
121
- if (event.properties.status.type === "idle" && gate.tryFire()) react("done")
122
- }),
123
- api.event.on("session.error", () => {
124
- if (gate.tryFire()) react("error")
125
- }),
126
- api.event.on("permission.asked", () => {
127
- if (gate.tryFire()) react("permission")
128
- }),
129
- api.event.on("question.asked", () => {
130
- if (gate.tryFire()) react("question")
131
- }),
132
- ]
133
- for (const unsub of unsubs) api.lifecycle.onDispose(async () => unsub())
220
+ ui.on("session.status", (event) => {
221
+ lastActivity = Date.now()
222
+ if (event.properties?.status?.type === "idle" && gate.tryFire()) react("done")
223
+ })
224
+ ui.on("session.error", () => {
225
+ if (gate.tryFire()) react("error")
226
+ })
227
+ ui.on("permission.asked", () => {
228
+ if (gate.tryFire()) react("permission")
229
+ })
230
+ ui.on("question.asked", () => {
231
+ if (gate.tryFire()) react("question")
232
+ })
134
233
 
135
234
  // The host renders the slot inside a one-line flex row next to the agent/model
136
235
  // text (prompt/index.tsx: justifyContent="space-between"). An in-flow sprite
@@ -139,44 +238,55 @@ export async function registerBuddy(api: TuiPluginApi): Promise<void> {
139
238
  // parent's edges, and bottom/right pin the creature's last row onto the
140
239
  // agent/model line while it grows upward over the (usually empty) input area.
141
240
  const renderSprite = (theme: TuiThemeCurrent) =>
142
- jsx("text", {
241
+ ui.node("text", {
143
242
  position: "absolute",
144
243
  bottom: 0,
145
244
  right: 0,
146
245
  width: SPRITE_WIDTH,
147
- height: spriteHeight(hatched.species),
148
- content: spriteFrame(hatched.species, "idle", tick),
149
- fg: rarityColor(hatched.rarity, theme),
246
+ height: spriteHeight(active.species),
247
+ content: spriteFrame(active.species, "idle", tick),
248
+ fg: rarityColor(active.rarity, theme),
150
249
  selectable: false,
151
- visible: api.renderer.width >= MIN_COLS,
250
+ visible: (ui.api.renderer?.width ?? 120) >= MIN_COLS,
152
251
  ref: (node: SpriteNode | undefined) => {
153
252
  if (node) nodes.add(node)
154
253
  },
155
254
  })
156
255
 
157
- // register returns the assigned plugin id, not a disposer -- the host owns
158
- // slot cleanup when the TUI plugin deactivates.
159
- api.slots.register({
160
- slots: {
161
- home_prompt_right: (ctx) => renderSprite(ctx.theme.current),
162
- session_prompt_right: (ctx) => renderSprite(ctx.theme.current),
256
+ ui.slots({
257
+ home_prompt_right: (ctx) => renderSprite(ctx.theme.current),
258
+ session_prompt_right: (ctx) => renderSprite(ctx.theme.current),
259
+ })
260
+
261
+ ui.command({
262
+ title: "Overclock: Pet buddy",
263
+ slash: "oc-buddy",
264
+ aliases: ["buddy"],
265
+ run: () => {
266
+ react("pet")
267
+ ui.toast(describeCompanion(active))
163
268
  },
164
269
  })
165
270
 
166
- try {
167
- const uncommand = api.command?.register(() => [
168
- {
169
- title: "Overclock: Pet buddy",
170
- value: "overclock.buddy",
171
- slash: { name: "oc-buddy" },
172
- onSelect: async () => {
173
- react("pet")
174
- api.ui.toast({ message: describeCompanion(hatched) })
175
- },
176
- },
177
- ])
178
- if (uncommand) api.lifecycle.onDispose(async () => uncommand())
179
- } catch (e) {
180
- console.warn(`[overclock-tui] /oc-buddy command registration failed: ${e}`)
181
- }
271
+ ui.command({
272
+ title: "Overclock: Switch buddy",
273
+ slash: "oc-buddy-switch",
274
+ aliases: ["buddy-switch"],
275
+ run: (dialog) => {
276
+ openSwitchDialog(dialog)
277
+ },
278
+ })
279
+
280
+ ui.command({
281
+ title: "Overclock: Cycle buddy",
282
+ slash: "oc-buddy-cycle",
283
+ aliases: ["buddy-cycle"],
284
+ run: () => {
285
+ const next = cycleSpecies(active.species)
286
+ setCompanion(
287
+ switchSpecies(active, next),
288
+ `switched to ${next}: ${describeCompanion(switchSpecies(active, next))}`,
289
+ )
290
+ },
291
+ })
182
292
  }
@@ -0,0 +1,34 @@
1
+ import type { Hooks, Plugin as V1Plugin } from "@opencode-ai/plugin"
2
+ import type { PluginContext as V2Context } from "@opencode-ai/plugin/v2/promise"
3
+ import type { HybridPlugin, HybridPluginDefinition } from "./types.ts"
4
+
5
+ export type { HybridPlugin, HybridPluginDefinition } from "./types.ts"
6
+
7
+ /**
8
+ * Creates a plugin export that satisfies both OpenCode V1 and V2 host lifecycles.
9
+ *
10
+ * - On a V1 host, it can be called directly as a function `(input, options)` or read via `{ id, server }`.
11
+ * - On a V2 host, it presents `{ id, setup(context) }` conforming to the V2 plugin specification.
12
+ */
13
+ export function createHybridPlugin<TOptions = Record<string, unknown>>(
14
+ definition: HybridPluginDefinition<TOptions>,
15
+ ): HybridPlugin<TOptions> {
16
+ const v1Runner: V1Plugin = async (input, options) => {
17
+ if (!definition.server) return {}
18
+ return (await definition.server(input, options as TOptions)) as Hooks
19
+ }
20
+
21
+ const v2Runner = async (context: V2Context): Promise<void> => {
22
+ if (!definition.setup) return
23
+ const options = (context.options ?? {}) as TOptions
24
+ await definition.setup(context, options)
25
+ }
26
+
27
+ const hybrid = Object.assign(v1Runner, {
28
+ id: definition.id,
29
+ server: v1Runner,
30
+ setup: v2Runner,
31
+ }) as HybridPlugin<TOptions>
32
+
33
+ return hybrid
34
+ }
@@ -0,0 +1,67 @@
1
+ import type { Hooks } from "@opencode-ai/plugin"
2
+ import { applyToolPolicy, EMPTY_POLICY, type ToolPolicy } from "./policy.ts"
3
+
4
+ /**
5
+ * Compose many Partial<Hooks> into one unified Hooks object.
6
+ * - fn hooks: sequential composition in array order.
7
+ * - tool definitions: merged and filtered/remapped via policy.
8
+ * - dispose: fault-tolerant sequential cleanup guaranteeing every disposer executes.
9
+ */
10
+ export function mergeHooks(parts: Partial<Hooks>[], policy: ToolPolicy = EMPTY_POLICY): Hooks {
11
+ const merged: Record<string, unknown> = {}
12
+ const rawTools: Record<string, unknown> = {}
13
+
14
+ for (const part of parts) {
15
+ for (const [key, value] of Object.entries(part)) {
16
+ if (value === undefined) continue
17
+ if (key === "tool") {
18
+ const toolsObj = value as Record<string, unknown>
19
+ for (const [toolName, toolDef] of Object.entries(toolsObj)) {
20
+ if (rawTools[toolName] !== undefined) {
21
+ console.warn(`[overclock] tool collision: ${toolName} (later module wins)`)
22
+ }
23
+ rawTools[toolName] = toolDef
24
+ }
25
+ continue
26
+ }
27
+ const prev = merged[key] as ((...a: unknown[]) => Promise<unknown>) | undefined
28
+ const next = value as (...a: unknown[]) => Promise<unknown>
29
+ merged[key] = prev
30
+ ? async (...args: unknown[]) => {
31
+ if (key === "dispose") {
32
+ await Promise.resolve(prev(...args)).catch((e) =>
33
+ console.warn(`[overclock] dispose error: ${e}`),
34
+ )
35
+ await Promise.resolve(next(...args)).catch((e) =>
36
+ console.warn(`[overclock] dispose error: ${e}`),
37
+ )
38
+ return
39
+ }
40
+ if (key === "event") {
41
+ try {
42
+ await prev(...args)
43
+ } catch (e) {
44
+ console.warn(`[overclock] event error: ${e}`)
45
+ }
46
+ try {
47
+ await next(...args)
48
+ } catch (e) {
49
+ console.warn(`[overclock] event error: ${e}`)
50
+ }
51
+ return
52
+ }
53
+ const prevRes = await prev(...args)
54
+ const nextRes = await next(...args)
55
+ return nextRes !== undefined ? nextRes : prevRes
56
+ }
57
+ : next
58
+ }
59
+ }
60
+
61
+ const processedTools = applyToolPolicy(rawTools, policy)
62
+ if (Object.keys(processedTools).length > 0) {
63
+ merged.tool = processedTools
64
+ }
65
+
66
+ return merged as Hooks
67
+ }
@@ -0,0 +1,128 @@
1
+ import type { ConfigIssue, FeatureModule, OverclockOptions, ToolPolicy } from "./types.ts"
2
+ export { EMPTY_POLICY, type ToolPolicy } from "./types.ts"
3
+
4
+ /**
5
+ * Tool ids opencode registers itself (observed on 1.18.4 via `/experimental/tool/ids`).
6
+ *
7
+ * Only used to warn: a tool registered under one of these *replaces* the built-in in the final
8
+ * tool map, and a name that differs only by case (`Task` vs `task`) is worse still -- the host
9
+ * offers both, and a consumer that matches case-insensitively sees a duplicate.
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
+ /**
29
+ * Resolve tool rename and allowlist policies.
30
+ *
31
+ * - Remaps tool names according to `config.toolNames`.
32
+ * - Withholds any tool not in `config.toolAllowlist` (if configured).
33
+ * - Emits warnings on collisions with host built-in tool names.
34
+ */
35
+ export function resolveToolPolicy(
36
+ config: Pick<OverclockOptions, "toolNames" | "toolAllowlist">,
37
+ features: readonly FeatureModule[],
38
+ ): { policy: ToolPolicy; issues: ConfigIssue[] } {
39
+ const issues: ConfigIssue[] = []
40
+ const rename: Record<string, string> = { ...(config.toolNames ?? {}) }
41
+ const withheld = new Set<string>()
42
+
43
+ let allowedSet: Set<string> | undefined
44
+ if (config.toolAllowlist !== undefined) {
45
+ if (typeof config.toolAllowlist === "string") {
46
+ allowedSet = new Set([config.toolAllowlist])
47
+ } else if (
48
+ Array.isArray(config.toolAllowlist) &&
49
+ config.toolAllowlist.every((s) => typeof s === "string")
50
+ ) {
51
+ allowedSet = new Set(config.toolAllowlist)
52
+ } else {
53
+ issues.push({
54
+ path: "toolAllowlist",
55
+ message: `must be a string or array of strings, got ${Array.isArray(config.toolAllowlist) ? "array with non-strings" : typeof config.toolAllowlist}`,
56
+ })
57
+ }
58
+ }
59
+
60
+ const allTools = features.flatMap((f) => f.tools ?? [])
61
+ for (const declared of allTools) {
62
+ const visible = rename[declared] ?? declared
63
+
64
+ // Check collisions with host built-ins
65
+ const twin = HOST_TOOL_IDS.find((id) => id.toLowerCase() === visible.toLowerCase())
66
+ if (twin) {
67
+ issues.push({
68
+ path: `tool "${declared}"`,
69
+ message:
70
+ twin === visible
71
+ ? `"${visible}" is an opencode built-in -- registering it replaces that built-in`
72
+ : `"${visible}" differs from opencode's built-in "${twin}" only by case; anything matching case-insensitively sees one name twice`,
73
+ })
74
+ }
75
+
76
+ // Check allowlist
77
+ if (allowedSet && !allowedSet.has(visible)) {
78
+ withheld.add(declared)
79
+ issues.push({
80
+ path: `tool "${declared}"`,
81
+ message: `"${visible}" is not in toolAllowlist -- withheld from the model. Add it to toolAllowlist or remap via toolNames`,
82
+ })
83
+ }
84
+ }
85
+
86
+ return { policy: { rename, withheld }, issues }
87
+ }
88
+
89
+ /**
90
+ * Rewrite declared tool names appearing inside a description in a single regex pass.
91
+ * Word-anchored so a name that is a substring of a longer identifier is not clobbered,
92
+ * and single-pass so chained mappings (A -> B, B -> C) do not cascade.
93
+ */
94
+ export function renameInText(text: string, rename: Record<string, string>): string {
95
+ const activeEntries = Object.entries(rename).filter(([from, to]) => from !== to)
96
+ if (activeEntries.length === 0) return text
97
+
98
+ const pattern = new RegExp(
99
+ `\\b(${activeEntries.map(([k]) => k.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")})\\b`,
100
+ "g",
101
+ )
102
+ return text.replace(pattern, (match) => rename[match] ?? match)
103
+ }
104
+
105
+ /**
106
+ * Filter withheld tools and apply renames and description updates to tool definitions.
107
+ */
108
+ export function applyToolPolicy(
109
+ tools: Record<string, unknown>,
110
+ policy: ToolPolicy = { rename: {}, withheld: new Set() },
111
+ ): Record<string, unknown> {
112
+ const result: Record<string, unknown> = {}
113
+ const { rename, withheld } = policy
114
+ const hasRenames = Object.keys(rename).length > 0
115
+
116
+ for (const [declared, def] of Object.entries(tools)) {
117
+ if (withheld.has(declared)) continue
118
+ const name = rename[declared] ?? declared
119
+ if (result[name]) console.warn(`[overclock] tool collision: ${name} (later module wins)`)
120
+
121
+ const d = def as { description?: unknown }
122
+ result[name] =
123
+ hasRenames && typeof d?.description === "string"
124
+ ? { ...d, description: renameInText(d.description, rename) }
125
+ : def
126
+ }
127
+ return result
128
+ }
@@ -0,0 +1,33 @@
1
+ import type { FeatureModule, ToolPolicy } from "./types.ts"
2
+ import { EMPTY_POLICY } from "./policy.ts"
3
+
4
+ /**
5
+ * One-line inventory of what this plugin just added to the session.
6
+ *
7
+ * Reports capability: installing overclock grants the agent background shell execution and
8
+ * recurring scheduling, and that should not be something a user discovers by accident.
9
+ */
10
+ export function summarise(
11
+ enabled: readonly FeatureModule[],
12
+ skipped: readonly string[],
13
+ policy: ToolPolicy = EMPTY_POLICY,
14
+ ): string {
15
+ const { rename, withheld } = policy
16
+ const offered = enabled.flatMap((f) => (f.tools ?? []).filter((t) => !withheld.has(t)))
17
+ // Report the name the model is actually offered, not the declared one -- under a remap the
18
+ // declared name appears nowhere on the wire, so listing it would misdescribe the session.
19
+ const parts = enabled.map((f) => {
20
+ const names = (f.tools ?? []).filter((t) => !withheld.has(t)).map((t) => rename[t] ?? t)
21
+ return `${f.name}${names.length ? ` (${names.join(", ")})` : ""}`
22
+ })
23
+ const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? "" : "s"}`
24
+ let line = `${plural(enabled.length, "module")}, ${plural(offered.length, "tool")}: ${parts.join(" · ")}`
25
+ const applied = offered.filter((t) => rename[t] && rename[t] !== t).map((t) => `${t}->${rename[t]}`)
26
+ if (applied.length) line += ` | renamed: ${applied.join(", ")}`
27
+ // Withheld tools are the one case where the session is quietly less capable than the config
28
+ // implies, so they are named here rather than left to the issue log alone.
29
+ const held = enabled.flatMap((f) => (f.tools ?? []).filter((t) => withheld.has(t)))
30
+ if (held.length) line += ` | withheld: ${held.join(", ")}`
31
+ if (skipped.length) line += ` | skipped: ${skipped.join(", ")}`
32
+ return line
33
+ }