opencode-codeops 1.10.3 → 2.0.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/plugin/index.ts CHANGED
@@ -1,7 +1,8 @@
1
- import type { Plugin } from "@opencode-ai/plugin"
1
+ import { Plugin } from "@opencode/plugin"
2
+ import { randomUUID } from "node:crypto"
2
3
  import { readFileSync } from "node:fs"
3
4
  import { homedir } from "node:os"
4
- import { join, dirname } from "node:path"
5
+ import { dirname, join } from "node:path"
5
6
  import { fileURLToPath } from "node:url"
6
7
 
7
8
  import {
@@ -17,10 +18,11 @@ import {
17
18
  isEffortLevel,
18
19
  isEffortTraceEnabled,
19
20
  isRoutingReasoning,
20
- modelSupportsReasoning,
21
+ normalizeModelVariants,
21
22
  readRoutingReasoning,
22
23
  readSessionEffort,
23
24
  resolveEffort,
25
+ selectEffortVariant,
24
26
  } from "../bin/lib/reasoning-effort.mjs"
25
27
  import type { EffortLevel } from "../bin/lib/reasoning-effort.mjs"
26
28
 
@@ -51,7 +53,8 @@ function readPackageVersion(): string {
51
53
  }
52
54
 
53
55
  // ---------------------------------------------------------------------------
54
- // Load standards at startup (once). Both files are injected into every session.
56
+ // Load standards at startup (once). Both files are injected into every request
57
+ // as system instructions, so they also survive context compaction.
55
58
  // ---------------------------------------------------------------------------
56
59
  const codingStandards = readFileSync(
57
60
  join(PACKAGE_ROOT, "standards", "coding-standards.md"),
@@ -64,50 +67,49 @@ const outputStyle = readFileSync(
64
67
  const standardsText = `${codingStandards}\n\n${outputStyle}`
65
68
 
66
69
  // ---------------------------------------------------------------------------
67
- // Workspace hygiene — the plugin owns one temp directory per session (exported
68
- // as CODEOPS_TMPDIR) and sweeps directories abandoned by earlier interrupted
69
- // runs. The sweep runs at most once per plugin process, so spawning many child
70
- // sessions does not repeat the directory walk.
70
+ // OpenCode 2 does not expose a session identifier to shell hooks, so the plugin
71
+ // owns one scratch directory per plugin runtime instead of one per session.
72
+ // Skills and subagents still receive it as CODEOPS_TMPDIR and delete their own
73
+ // scratch; the plugin removes the runtime directory on unload and sweeps
74
+ // directories abandoned by earlier runs. Two sessions served by the same
75
+ // runtime share this directory; see _shared/workspace-hygiene.md.
71
76
  // ---------------------------------------------------------------------------
72
- let sweptStaleTmpDirs = false
77
+ const runtimeID = `runtime-${randomUUID()}`
78
+
79
+ /** Heading prefixed to the standards block injected into compaction summaries. */
80
+ const STANDARDS_HEADING = "## CodeOps Standards (always active)"
73
81
 
74
82
  /**
75
- * Sweep temp directories abandoned by earlier interrupted runs, once per
76
- * process. Cleanup is best effort: a failure must never block a session.
83
+ * Check whether a value is a plain object (not an array, class instance, or
84
+ * `null`). Used to read unknown hook payloads without unsafe casts.
85
+ *
86
+ * @param value - Value to inspect
87
+ * @returns True for `{}`-shaped objects
77
88
  */
78
- function sweepStaleTmpDirsOnce(): void {
79
- if (sweptStaleTmpDirs) return
80
- sweptStaleTmpDirs = true
81
- try {
82
- cleanStaleTmpDirs()
83
- } catch {
84
- // Best effort only.
85
- }
89
+ function isRecord(value: unknown): value is Record<string, unknown> {
90
+ return typeof value === "object" && value !== null && !Array.isArray(value)
86
91
  }
87
92
 
88
- // ---------------------------------------------------------------------------
89
- // Helper — inject standards into a session without triggering an AI reply.
90
- // Uses client.session.prompt with noReply: true (confirmed from OpenCode SDK).
91
- // ---------------------------------------------------------------------------
92
- async function injectStandards(
93
- client: Parameters<Plugin>[0]["client"],
94
- sessionId: string
95
- ): Promise<void> {
96
- await client.session.prompt({
97
- path: { id: sessionId },
98
- body: {
99
- noReply: true,
100
- parts: [{ type: "text", text: standardsText }],
101
- },
102
- })
93
+ /**
94
+ * Log one content-free warning. Logging is best effort: a failed log must never
95
+ * break a request.
96
+ *
97
+ * @param message - Warning text that never contains prompt or file content
98
+ */
99
+ function warnContentFree(message: string): void {
100
+ console.warn(`CodeOps: ${message}`)
103
101
  }
104
102
 
105
- // ---------------------------------------------------------------------------
106
- // Helper — read the version recorded in an installed skills marker, if any.
107
- // The marker (`.opencode-codeops.json`) is written by the skills installer.
108
- // Its absence means the skills are not managed — for example a development
109
- // symlink — so there is no version to compare against.
110
- // ---------------------------------------------------------------------------
103
+ /**
104
+ * Read the version recorded in an installed skills marker, if any.
105
+ *
106
+ * The marker (`.opencode-codeops.json`) is written by the skills installer.
107
+ * Its absence means the skills are not managed — for example a development
108
+ * symlink — so there is no version to compare against.
109
+ *
110
+ * @param skillsDir - Directory that may hold the installer marker
111
+ * @returns The recorded version, or `undefined`
112
+ */
111
113
  function installedSkillsVersion(skillsDir: string): string | undefined {
112
114
  try {
113
115
  const marker = JSON.parse(
@@ -119,16 +121,15 @@ function installedSkillsVersion(skillsDir: string): string | undefined {
119
121
  }
120
122
  }
121
123
 
122
- // ---------------------------------------------------------------------------
123
- // Helper — warn (non-blocking) when the installed skills were written by a
124
- // different CodeOps version than this plugin. The plugin and the files are
125
- // installed by separate commands, so their versions can drift; a mismatch
126
- // usually means the skills need `npx opencode-codeops update` again.
127
- // ---------------------------------------------------------------------------
128
- async function warnOnVersionSkew(
129
- client: Parameters<Plugin>[0]["client"],
130
- directory: string
131
- ): Promise<void> {
124
+ /**
125
+ * Warn (non-blocking) when the installed skills were written by a different
126
+ * CodeOps version than this plugin. The plugin and the files are installed by
127
+ * separate commands, so their versions can drift; a mismatch usually means the
128
+ * skills need `npx opencode-codeops update` again.
129
+ *
130
+ * @param directory - Project directory whose local skills should be checked
131
+ */
132
+ function warnOnVersionSkew(directory: string): void {
132
133
  const skillsDirs = [
133
134
  join(homedir(), ".config", "opencode", "skills"),
134
135
  join(directory, ".opencode", "skills"),
@@ -138,38 +139,21 @@ async function warnOnVersionSkew(
138
139
  const installed = installedSkillsVersion(skillsDir)
139
140
  if (!installed || installed === packageVersion) continue
140
141
 
141
- await client.app.log({
142
- body: {
143
- service: "codeops",
144
- level: "warn",
145
- message:
146
- `CodeOps skills at ${skillsDir} are version ${installed}, ` +
147
- `but the plugin is version ${packageVersion}. ` +
148
- `Run \`npx opencode-codeops@${packageVersion} update\` to match them.`,
149
- },
150
- })
151
- }
152
- }
153
-
154
- // ---------------------------------------------------------------------------
155
- // Helper — log one content-free warning. Logging is best effort: a failed log
156
- // must never break a request.
157
- // ---------------------------------------------------------------------------
158
- async function warnContentFree(
159
- client: Parameters<Plugin>[0]["client"],
160
- message: string
161
- ): Promise<void> {
162
- try {
163
- await client.app.log({ body: { service: "codeops", level: "warn", message } })
164
- } catch {
165
- // Best effort only.
142
+ warnContentFree(
143
+ `CodeOps skills at ${skillsDir} are version ${installed}, ` +
144
+ `but the plugin is version ${packageVersion}. ` +
145
+ `Run \`npx opencode-codeops@${packageVersion} update\` to match them.`
146
+ )
166
147
  }
167
148
  }
168
149
 
169
- // ---------------------------------------------------------------------------
170
- // Helper — read the project routing config fresh on every request, so an edit
171
- // applies without restarting the session. Any failure means "no routing".
172
- // ---------------------------------------------------------------------------
150
+ /**
151
+ * Read the project routing config fresh on every request, so an edit applies
152
+ * without restarting the session. Any failure means "no routing".
153
+ *
154
+ * @param directory - Project directory holding `codeops/codeops.json`
155
+ * @returns The parsed config, or an empty object
156
+ */
173
157
  function readRoutingConfig(directory: string): unknown {
174
158
  try {
175
159
  return JSON.parse(readFileSync(join(directory, "codeops", "codeops.json"), "utf8"))
@@ -178,149 +162,139 @@ function readRoutingConfig(directory: string): unknown {
178
162
  }
179
163
  }
180
164
 
181
- // ---------------------------------------------------------------------------
182
- // Helper — append one content-free trace line when the optional
183
- // CODEOPS_EFFORT_TRACE switch is on. Tracing is diagnostic only: it never
184
- // affects a request and swallows its own failures.
185
- // ---------------------------------------------------------------------------
165
+ /**
166
+ * Append one content-free trace line when the optional CODEOPS_EFFORT_TRACE
167
+ * switch is on. Tracing is diagnostic only: it never affects a request and
168
+ * swallows its own failures.
169
+ *
170
+ * @param enabled - Whether tracing is enabled for this runtime
171
+ * @param sessionID - Session the entry belongs to
172
+ * @param entry - Content-free trace record
173
+ */
186
174
  function traceEffort(
187
175
  enabled: boolean,
188
176
  sessionID: string,
189
177
  entry: Record<string, unknown>
190
178
  ): void {
191
179
  if (!enabled) return
192
- appendEffortTrace(sessionID, { ts: new Date().toISOString(), ...entry })
180
+ appendEffortTrace(runtimeID, { ts: new Date().toISOString(), sessionID, ...entry })
181
+ }
182
+
183
+ /**
184
+ * Look up the active model's variant record from the model registry.
185
+ *
186
+ * OpenCode 2 request hooks carry only a model reference (provider and model
187
+ * id), while the provider-specific option sets live on the registered model as
188
+ * a `variants` array. Any lookup failure means "no variants", which leaves the
189
+ * request unchanged.
190
+ *
191
+ * @param ctx - Plugin context whose model registry is queried
192
+ * @param ref - Model reference from the request hook
193
+ * @returns A variant record keyed by variant id, or an empty record
194
+ */
195
+ async function readModelVariants(
196
+ ctx: Plugin.Context,
197
+ ref: { providerID: string; id: string }
198
+ ): Promise<Record<string, unknown>> {
199
+ try {
200
+ const listed = await ctx.model.list()
201
+ const info = listed.data.find(
202
+ (model) => model.providerID === ref.providerID && model.id === ref.id
203
+ )
204
+ if (info === undefined) return {}
205
+ return normalizeModelVariants(info.variants)
206
+ } catch {
207
+ return {}
208
+ }
193
209
  }
194
210
 
195
211
  // ---------------------------------------------------------------------------
196
- // CodeOps plugin for OpenCode
212
+ // CodeOps plugin for OpenCode 2
197
213
  // Replaces: hooks/hooks.json + hook_session_context.sh + hook_marker_guard.sh
198
214
  // ---------------------------------------------------------------------------
199
- export const CodeOpsPlugin: Plugin = async ({ client, directory }) => {
200
- // Reasoning-effort state lives for the lifetime of this plugin instance:
201
- // the latest dispatch-marker level per session, plus a deduplication set
202
- // for unsupported-level warnings.
203
- const effortMarkers = new Map<string, EffortLevel>()
204
- const warnedEffortLevels = new Set<string>()
205
- const effortTraceEnabled = isEffortTraceEnabled(process.env.CODEOPS_EFFORT_TRACE)
215
+ export default Plugin.define({
216
+ id: "opencode-codeops",
217
+ async setup(ctx) {
218
+ const directory = ctx.location.directory
206
219
 
207
- return {
208
- // -----------------------------------------------------------------------
209
- // Hook 1 & 2: inject standards on session.created and session.compacted.
210
- // Both are dispatched via the generic event hook.
211
- // session.created → new session (Codex: startup)
212
- // session.compacted → after compact (Codex: resume|compact)
213
- // Also the hygiene lifecycle: a new top-level session sweeps abandoned
214
- // temp directories once, and a deleted session's temp directory is removed.
215
- // -----------------------------------------------------------------------
216
- event: async ({ event }) => {
217
- if (event.type === "session.created") {
218
- const info = (event.properties as { info: { id: string; parentID?: string } }).info
219
- await injectStandards(client, info.id)
220
- await warnOnVersionSkew(client, directory)
221
- if (!info.parentID) sweepStaleTmpDirsOnce()
222
- } else if (event.type === "session.deleted") {
223
- const info = (event.properties as { info: { id: string } }).info
224
- removeSessionTmpDir(info.id)
225
- try {
226
- effortMarkers.delete(info.id)
227
- } catch {
228
- await warnContentFree(client, "Could not clear captured reasoning-effort markers.")
229
- }
230
- } else if (event.type === "session.compacted") {
231
- const sessionId: string = (event.properties as { sessionID: string }).sessionID
232
- await injectStandards(client, sessionId)
233
- }
234
- },
220
+ // Reasoning-effort state lives for the lifetime of this plugin instance:
221
+ // the latest dispatch-marker level per session, plus a deduplication set
222
+ // for unsupported-level warnings.
223
+ const effortMarkers = new Map<string, EffortLevel>()
224
+ const warnedEffortLevels = new Set<string>()
225
+ const effortTraceEnabled = isEffortTraceEnabled(process.env.CODEOPS_EFFORT_TRACE)
235
226
 
236
- // -----------------------------------------------------------------------
237
- // Hook 3: inject standards into the compaction context itself, so they
238
- // survive through the compaction summary and are not lost mid-session.
239
- // -----------------------------------------------------------------------
240
- "experimental.session.compacting": async (_input, output) => {
241
- output.context.push(
242
- "## CodeOps Standards (always active — survive this compaction)\n\n" +
243
- standardsText
244
- )
245
- },
227
+ // Sweep scratch directories abandoned by earlier interrupted runs, once.
228
+ // Cleanup is best effort: a failure must never block a session.
229
+ try {
230
+ cleanStaleTmpDirs()
231
+ } catch {
232
+ // Best effort only.
233
+ }
246
234
 
247
- // -----------------------------------------------------------------------
248
- // Hook 4: export CODEOPS_PLUGIN_ROOT into every shell so skills can call
249
- // scripts as: python3 "${CODEOPS_PLUGIN_ROOT}/scripts/codeops_plan.py"
250
- // The value is the package root (parent of this plugin/ directory), which
251
- // is where skills/, scripts/, and the other shipped assets live.
252
- // CODEOPS_TMPDIR is the session's directory under the CodeOps temp root;
253
- // skills and agents put every scratch file there and delete it when done.
254
- // -----------------------------------------------------------------------
255
- "shell.env": async (input, output) => {
256
- output.env.CODEOPS_PLUGIN_ROOT = PACKAGE_ROOT
235
+ warnOnVersionSkew(directory)
236
+
237
+ // ---------------------------------------------------------------------
238
+ // Session lifecycle: drop captured markers when a session is deleted, so
239
+ // a long-lived runtime does not remember ended sessions.
240
+ // ---------------------------------------------------------------------
241
+ const controller = new AbortController()
242
+ void (async () => {
257
243
  try {
258
- output.env.CODEOPS_TMPDIR = ensureSessionTmpDir(input.sessionID)
244
+ for await (const event of ctx.event.subscribe({ signal: controller.signal })) {
245
+ if (event.type === "session.deleted") {
246
+ effortMarkers.delete(event.data.sessionID)
247
+ }
248
+ }
259
249
  } catch {
260
- // Best effort: a temp-directory failure must never block a shell.
250
+ // The stream ended or the plugin unloaded; nothing to do.
261
251
  }
262
- },
263
-
264
- // -----------------------------------------------------------------------
265
- // Hook 5: advisory guard — warn (non-blocking) if any edit tool targets
266
- // codeops/.codeops.yml, which is owned exclusively by the setup-codeops
267
- // skill. Equivalent to Codex PreToolUse hook_marker_guard.sh.
268
- // args are on the output parameter per the OpenCode plugin type signature.
269
- // -----------------------------------------------------------------------
270
- "tool.execute.before": async (input, output) => {
271
- const editTools = ["write", "edit", "apply_patch"]
272
- if (!editTools.includes(input.tool)) return
252
+ })()
273
253
 
274
- const args = output.args as Record<string, unknown> | undefined
275
- const filePath: string =
276
- (args?.filePath as string | undefined) ??
277
- (args?.path as string | undefined) ??
278
- ""
254
+ // ---------------------------------------------------------------------
255
+ // Standards injection: add the CodeOps standards to every agent-loop
256
+ // request and to every compaction summary, so every agent and every turn
257
+ // sees them and they cannot be lost to compaction.
258
+ // ---------------------------------------------------------------------
259
+ await ctx.session.hook("context", (event) => {
260
+ event.system.push({ type: "text", text: standardsText })
261
+ })
279
262
 
280
- if (filePath.includes("codeops/.codeops.yml")) {
281
- process.stderr.write(
282
- "CodeOps warning: codeops/.codeops.yml is the layout marker and is " +
283
- "owned by setup-codeops. Edit it only through the setup/migration " +
284
- "workflow (run the setup-codeops skill).\n"
285
- )
286
- }
287
- },
263
+ await ctx.session.hook("compaction", (event) => {
264
+ event.system.push({ type: "text", text: `${STANDARDS_HEADING}\n\n${standardsText}` })
265
+ })
288
266
 
289
- // -----------------------------------------------------------------------
290
- // Hook 6: capture a dispatch marker from an incoming user message. The
291
- // marker travels in the dispatch packet text; it is remembered for the
292
- // whole session, because the later chat.params hook receives the latest
293
- // user message, which can differ from the message that carried it.
294
- // -----------------------------------------------------------------------
295
- "chat.message": async (input, output) => {
267
+ // ---------------------------------------------------------------------
268
+ // Effort capture: a dispatch marker travels in the incoming user prompt.
269
+ // It is remembered for the whole session, because the later request hook
270
+ // sees the same session rather than the message that carried it.
271
+ // ---------------------------------------------------------------------
272
+ await ctx.session.hook("prompt", (event) => {
296
273
  try {
297
- const texts = output.parts.map((part) =>
298
- part?.type === "text" ? part.text : undefined
299
- )
300
- const level = findEffortMarker(texts)
274
+ const level = findEffortMarker([event.prompt.text])
301
275
  if (level !== undefined) {
302
- effortMarkers.set(input.sessionID, level)
303
- traceEffort(effortTraceEnabled, input.sessionID, {
276
+ effortMarkers.set(event.sessionID, level)
277
+ traceEffort(effortTraceEnabled, event.sessionID, {
304
278
  event: "capture",
305
- messageID: output.message.id,
279
+ messageID: event.messageID,
306
280
  level,
307
281
  })
308
282
  }
309
283
  } catch {
310
- await warnContentFree(client, "Could not scan a message for a reasoning-effort marker.")
284
+ warnContentFree("Could not scan a message for a reasoning-effort marker.")
311
285
  }
312
- },
286
+ })
313
287
 
314
- // -----------------------------------------------------------------------
315
- // Hook 7: resolve the request's reasoning level (dispatch marker, then
316
- // session flag, then routing default) and merge the model's own variant
317
- // options. Any failure leaves the request unchanged.
318
- // -----------------------------------------------------------------------
319
- "chat.params": async (input, output) => {
288
+ // ---------------------------------------------------------------------
289
+ // Effort apply: resolve the request's reasoning level (dispatch marker,
290
+ // then session flag, then routing default) and merge the model's own
291
+ // variant options. Any failure leaves the request unchanged.
292
+ // ---------------------------------------------------------------------
293
+ await ctx.session.hook("context", async (event) => {
320
294
  try {
321
- const marker = effortMarkers.get(input.sessionID)
322
- const session = readSessionEffort(input.sessionID)
323
- const routing = readRoutingReasoning(readRoutingConfig(directory), input.agent)
295
+ const marker = effortMarkers.get(event.sessionID)
296
+ const session = readSessionEffort(runtimeID)
297
+ const routing = readRoutingReasoning(readRoutingConfig(directory), event.agent)
324
298
  const level = resolveEffort({ marker, session, routing })
325
299
  const source = isEffortLevel(marker)
326
300
  ? "marker"
@@ -330,10 +304,9 @@ export const CodeOpsPlugin: Plugin = async ({ client, directory }) => {
330
304
  ? "routing"
331
305
  : "none"
332
306
  if (level === undefined) {
333
- traceEffort(effortTraceEnabled, input.sessionID, {
307
+ traceEffort(effortTraceEnabled, event.sessionID, {
334
308
  event: "apply",
335
- messageID: input.message.id,
336
- agent: input.agent,
309
+ agent: event.agent,
337
310
  level: null,
338
311
  source,
339
312
  applied: false,
@@ -341,36 +314,88 @@ export const CodeOpsPlugin: Plugin = async ({ client, directory }) => {
341
314
  return
342
315
  }
343
316
 
344
- const applied = applyEffort(output.options, level, input.model)
345
- const changed = applied !== output.options
346
- traceEffort(effortTraceEnabled, input.sessionID, {
317
+ const variants = await readModelVariants(ctx, event.model)
318
+ const model = { variants }
319
+ const applied = applyEffort(event.options, level, model)
320
+ const changed = applied !== event.options
321
+ traceEffort(effortTraceEnabled, event.sessionID, {
347
322
  event: "apply",
348
- messageID: input.message.id,
349
- agent: input.agent,
323
+ agent: event.agent,
350
324
  level,
351
325
  source,
352
326
  applied: changed,
353
- reasoningSupported: modelSupportsReasoning(input.model),
354
- variantLevels: Object.keys(extractModelVariants(input.model) ?? {}),
327
+ variant: selectEffortVariant(level, model) ?? null,
328
+ variantLevels: Object.keys(extractModelVariants(model) ?? {}),
355
329
  })
356
330
  if (!changed) {
357
331
  if (marker !== undefined) {
358
- const warningKey = `${input.sessionID}:${marker}`
332
+ const warningKey = `${event.sessionID}:${marker}`
359
333
  if (!warnedEffortLevels.has(warningKey)) {
360
334
  warnedEffortLevels.add(warningKey)
361
- await warnContentFree(
362
- client,
363
- `Reasoning effort ${marker} is not available for agent ${input.agent}; ` +
335
+ warnContentFree(
336
+ `Reasoning effort ${marker} is not available for agent ${event.agent}; ` +
364
337
  "request left unchanged."
365
338
  )
366
339
  }
367
340
  }
368
341
  return
369
342
  }
370
- output.options = applied
343
+ Object.assign(event.options, applied)
371
344
  } catch {
372
- await warnContentFree(client, "Could not apply a reasoning-effort level to a request.")
345
+ warnContentFree("Could not apply a reasoning-effort level to a request.")
373
346
  }
374
- },
375
- }
376
- }
347
+ })
348
+
349
+ // ---------------------------------------------------------------------
350
+ // Shell environment: export CODEOPS_PLUGIN_ROOT so skills can call
351
+ // scripts as: python3 "${CODEOPS_PLUGIN_ROOT}/scripts/codeops_plan.py".
352
+ // CODEOPS_TMPDIR is this runtime's scratch directory; skills and agents
353
+ // put every scratch file there and delete it when done.
354
+ // ---------------------------------------------------------------------
355
+ await ctx.shell.hook("create.before", (event) => {
356
+ event.env.CODEOPS_PLUGIN_ROOT = PACKAGE_ROOT
357
+ try {
358
+ event.env.CODEOPS_TMPDIR = ensureSessionTmpDir(runtimeID)
359
+ } catch {
360
+ // Best effort: a temp-directory failure must never block a shell.
361
+ }
362
+ })
363
+
364
+ // ---------------------------------------------------------------------
365
+ // Advisory guard: warn (non-blocking) if any edit tool targets
366
+ // codeops/.codeops.yml, which is owned exclusively by the setup-codeops
367
+ // skill.
368
+ // ---------------------------------------------------------------------
369
+ await ctx.tool.hook("execute.before", (event) => {
370
+ const editTools = ["write", "edit", "apply_patch", "multiedit", "patch"]
371
+ if (!editTools.includes(event.tool)) return
372
+
373
+ const input = isRecord(event.input) ? event.input : undefined
374
+ const filePath =
375
+ (typeof input?.filePath === "string" ? input.filePath : undefined) ??
376
+ (typeof input?.path === "string" ? input.path : undefined) ??
377
+ ""
378
+
379
+ if (filePath.includes("codeops/.codeops.yml")) {
380
+ process.stderr.write(
381
+ "CodeOps warning: codeops/.codeops.yml is the layout marker and is " +
382
+ "owned by setup-codeops. Edit it only through the setup/migration " +
383
+ "workflow (run the setup-codeops skill).\n"
384
+ )
385
+ }
386
+ })
387
+
388
+ // ---------------------------------------------------------------------
389
+ // Plugin cleanup: stop listening for events and remove the runtime's
390
+ // scratch directory. Both are best effort.
391
+ // ---------------------------------------------------------------------
392
+ return () => {
393
+ controller.abort()
394
+ try {
395
+ removeSessionTmpDir(runtimeID)
396
+ } catch {
397
+ // Best effort only.
398
+ }
399
+ }
400
+ },
401
+ })
@@ -45,10 +45,15 @@ comes from an execution plan's per-phase suggestion; a session level comes from
45
45
  when a role entry exists — with no entry the child inherits the parent variant.
46
46
 
47
47
  The suggestion vocabulary is `low`, `medium`, `high`, and `max`, while the routing field accepts
48
- the wider provider enum (`none`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`). The deepseek
49
- flash model exposes `low`/`medium`/`high`/`max`; prefer `low` for mechanical work, `medium` for
50
- bounded implementation, `high` for planning and review, and `max` only for adversarial analysis.
51
- An unsupported value is skipped at runtime, so a routing entry never produces a provider error.
48
+ the wider provider enum (`none`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`). A level the
49
+ model does not expose resolves to the nearest exposed variant (ties choose the higher level);
50
+ `none` is exact-match only. On the deepseek flash model in this environment
51
+ (`low`/`high`/`max`), `medium` therefore resolves to `high`. Confirm the active set with
52
+ `CODEOPS_EFFORT_TRACE=1` and `variantLevels` on an apply line (see
53
+ [../../_shared/reasoning-effort.md](../../_shared/reasoning-effort.md)). Prefer `low` for
54
+ mechanical work, `medium` (or the nearest exposed level) for bounded implementation, `high` for
55
+ planning and review, and `max` only for adversarial analysis. A value that cannot be mapped is
56
+ skipped, so a routing entry never produces a provider error.
52
57
 
53
58
  Model pins are optional per role. When omitted, OpenCode resolves the model from the explicit spawn, project defaults, and parent session. A missing pin must never block the workflow.
54
59
 
@@ -54,8 +54,8 @@ These apply to all code I write unless this project's `AGENTS.md` overrides a sp
54
54
 
55
55
  - **Own your temporary artifacts.** Verification logs, diffs, patch files, commit-message files,
56
56
  scratch directories, fixture trees, and downloaded artifacts are borrowed space. Put them under
57
- `$CODEOPS_TMPDIR` when the CodeOps plugin exports it (one directory per session); otherwise use
58
- the OS temp directory. Never write temporary files into the repository.
57
+ `$CODEOPS_TMPDIR` when the CodeOps plugin exports it (one directory per OpenCode runtime);
58
+ otherwise use the OS temp directory. Never write temporary files into the repository.
59
59
  - **Delete at completion.** Before reporting any task, phase, or session complete, delete every
60
60
  temporary artifact the run created — including verification logs. If the user asked for an
61
61
  artifact to be kept, name its exact path and keep that file only.