opencode-codeops 1.10.0 → 1.10.2

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/CHANGELOG.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  All notable changes to CodeOps are recorded here.
4
4
 
5
+ ## 1.10.2 — 2026-10-04
6
+
7
+ ### Fixes
8
+
9
+ - effort: remember dispatch markers per session
10
+
11
+ ## 1.10.1 — 2026-10-04
12
+
13
+ ### Features
14
+
15
+ - effort: add opt-in applied-level trace
16
+
5
17
  ## 1.10.0 — 2026-10-04
6
18
 
7
19
  ### Documentation
@@ -43,6 +43,10 @@ A dispatch marker is one standalone line in a dispatch message:
43
43
  For quality-agent packets the marker follows the dispatch header; for packet kinds without a
44
44
  header it is the first line. `exec-plan` owns the composition rule.
45
45
 
46
+ A captured marker is remembered for the whole session: every request in that session uses it
47
+ until a newer marker replaces it or the session ends. This is what keeps the marker effective
48
+ when the host passes a later user message to the request hook.
49
+
46
50
  ## Precedence
47
51
 
48
52
  The most specific source wins:
@@ -126,6 +130,15 @@ Semantics:
126
130
  For `exec-plan`, a bare flag follows each phase's suggestion, while `--auto-effort=<level>`
127
131
  forces that level for the whole run, including every dispatch marker composed during it.
128
132
 
133
+ ## Optional tracing
134
+
135
+ Set `CODEOPS_EFFORT_TRACE=1` (or `true`) in the environment that starts OpenCode to record what
136
+ the plugin does. One content-free JSON line is appended per marker capture and per request to
137
+ `reasoning-effort-trace.jsonl` inside the session's scratch directory. Lines carry only a
138
+ timestamp, event name, message id, agent name, level, source, and applied flag — never prompt
139
+ text or file content. The trace lives with the rest of the session scratch and is removed with
140
+ it. Tracing is off by default and never affects a request.
141
+
129
142
  ## Suggestion-only guarantee
130
143
 
131
144
  | Allowed | Forbidden |
@@ -57,6 +57,19 @@ export declare function readSessionEffort(
57
57
  base?: string
58
58
  ): EffortLevel | undefined
59
59
 
60
+ /** Check whether the optional trace environment switch is on. */
61
+ export declare function isEffortTraceEnabled(value: unknown): boolean
62
+
63
+ /** Compute the trace-file path inside the session temp directory. */
64
+ export declare function sessionEffortTracePath(sessionID?: string, base?: string): string
65
+
66
+ /** Append one content-free trace entry to the session trace file. */
67
+ export declare function appendEffortTrace(
68
+ sessionID: string | undefined,
69
+ entry: unknown,
70
+ base?: string
71
+ ): boolean
72
+
60
73
  /** Read an agent's explicit reasoning entry from the project routing config. */
61
74
  export declare function readRoutingReasoning(
62
75
  config: unknown,
@@ -19,10 +19,10 @@
19
19
  * @module lib/reasoning-effort
20
20
  */
21
21
 
22
- import { lstatSync, readFileSync } from "node:fs"
22
+ import { appendFileSync, lstatSync, readFileSync } from "node:fs"
23
23
  import { join } from "node:path"
24
24
 
25
- import { sessionTmpDir } from "./tmp-hygiene.mjs"
25
+ import { ensureSessionTmpDir, sessionTmpDir } from "./tmp-hygiene.mjs"
26
26
 
27
27
  /**
28
28
  * The four levels a plan or skill may suggest.
@@ -198,6 +198,58 @@ export function readSessionEffort(sessionID, base) {
198
198
  }
199
199
  }
200
200
 
201
+ /**
202
+ * Check whether the optional trace environment switch is on.
203
+ *
204
+ * Tracing is opt-in and diagnostic: it is enabled only when the value is
205
+ * exactly `1` or `true`, compared case-insensitively. Every other value,
206
+ * including `undefined`, disables it.
207
+ *
208
+ * @param value - Raw `CODEOPS_EFFORT_TRACE` value
209
+ * @returns True only for `"1"` or `"true"` in any case
210
+ */
211
+ export function isEffortTraceEnabled(value) {
212
+ if (typeof value !== "string") return false
213
+ const normalized = value.trim().toLowerCase()
214
+ return normalized === "1" || normalized === "true"
215
+ }
216
+
217
+ /**
218
+ * Compute the trace-file path inside the session temp directory.
219
+ *
220
+ * @param sessionID - Session identifier
221
+ * @param base - Base temp directory (injectable for tests)
222
+ * @returns Absolute path to `reasoning-effort-trace.jsonl`
223
+ */
224
+ export function sessionEffortTracePath(sessionID, base) {
225
+ return join(sessionTmpDir(sessionID, base), "reasoning-effort-trace.jsonl")
226
+ }
227
+
228
+ /**
229
+ * Append one content-free trace entry to the session trace file.
230
+ *
231
+ * The entry is serialized as one compact JSON line. Nothing here inspects or
232
+ * interprets the entry; callers must never pass prompt text, file content, or
233
+ * secrets. The session directory is created when missing. Any failure — an
234
+ * unwritable path, an unserializable entry — returns false instead of
235
+ * throwing, because tracing must never affect a request.
236
+ *
237
+ * @param sessionID - Session identifier
238
+ * @param entry - Plain, content-free record to append
239
+ * @param base - Base temp directory (injectable for tests)
240
+ * @returns True when the line was appended, false otherwise
241
+ */
242
+ export function appendEffortTrace(sessionID, entry, base) {
243
+ try {
244
+ const line = `${JSON.stringify(entry)}\n`
245
+ ensureSessionTmpDir(sessionID, base)
246
+ appendFileSync(sessionEffortTracePath(sessionID, base), line, "utf-8")
247
+ return true
248
+ } catch {
249
+ return false
250
+ }
251
+ }
252
+
201
253
  /**
202
254
  * Read an agent's explicit reasoning entry from the project routing config.
203
255
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-codeops",
3
- "version": "1.10.0",
3
+ "version": "1.10.2",
4
4
  "description": "Specification-first engineering for complex systems — CodeOps plugin for OpenCode",
5
5
  "type": "module",
6
6
  "engines": {
package/plugin/index.ts CHANGED
@@ -10,8 +10,12 @@ import {
10
10
  removeSessionTmpDir,
11
11
  } from "../bin/lib/tmp-hygiene.mjs"
12
12
  import {
13
+ appendEffortTrace,
13
14
  applyEffort,
14
15
  findEffortMarker,
16
+ isEffortLevel,
17
+ isEffortTraceEnabled,
18
+ isRoutingReasoning,
15
19
  readRoutingReasoning,
16
20
  readSessionEffort,
17
21
  resolveEffort,
@@ -172,16 +176,31 @@ function readRoutingConfig(directory: string): unknown {
172
176
  }
173
177
  }
174
178
 
179
+ // ---------------------------------------------------------------------------
180
+ // Helper — append one content-free trace line when the optional
181
+ // CODEOPS_EFFORT_TRACE switch is on. Tracing is diagnostic only: it never
182
+ // affects a request and swallows its own failures.
183
+ // ---------------------------------------------------------------------------
184
+ function traceEffort(
185
+ enabled: boolean,
186
+ sessionID: string,
187
+ entry: Record<string, unknown>
188
+ ): void {
189
+ if (!enabled) return
190
+ appendEffortTrace(sessionID, { ts: new Date().toISOString(), ...entry })
191
+ }
192
+
175
193
  // ---------------------------------------------------------------------------
176
194
  // CodeOps plugin for OpenCode
177
195
  // Replaces: hooks/hooks.json + hook_session_context.sh + hook_marker_guard.sh
178
196
  // ---------------------------------------------------------------------------
179
197
  export const CodeOpsPlugin: Plugin = async ({ client, directory }) => {
180
198
  // Reasoning-effort state lives for the lifetime of this plugin instance:
181
- // one entry per user message that carried a dispatch marker, plus a
182
- // deduplication set for unsupported-level warnings.
183
- const effortMarkers = new Map<string, { sessionID: string; level: EffortLevel }>()
199
+ // the latest dispatch-marker level per session, plus a deduplication set
200
+ // for unsupported-level warnings.
201
+ const effortMarkers = new Map<string, EffortLevel>()
184
202
  const warnedEffortLevels = new Set<string>()
203
+ const effortTraceEnabled = isEffortTraceEnabled(process.env.CODEOPS_EFFORT_TRACE)
185
204
 
186
205
  return {
187
206
  // -----------------------------------------------------------------------
@@ -202,9 +221,7 @@ export const CodeOpsPlugin: Plugin = async ({ client, directory }) => {
202
221
  const info = (event.properties as { info: { id: string } }).info
203
222
  removeSessionTmpDir(info.id)
204
223
  try {
205
- for (const [messageID, entry] of effortMarkers) {
206
- if (entry.sessionID === info.id) effortMarkers.delete(messageID)
207
- }
224
+ effortMarkers.delete(info.id)
208
225
  } catch {
209
226
  await warnContentFree(client, "Could not clear captured reasoning-effort markers.")
210
227
  }
@@ -269,8 +286,9 @@ export const CodeOpsPlugin: Plugin = async ({ client, directory }) => {
269
286
 
270
287
  // -----------------------------------------------------------------------
271
288
  // Hook 6: capture a dispatch marker from an incoming user message. The
272
- // marker travels in the dispatch packet text; storing it by message id
273
- // lets the later chat.params hook apply it to the same request.
289
+ // marker travels in the dispatch packet text; it is remembered for the
290
+ // whole session, because the later chat.params hook receives the latest
291
+ // user message, which can differ from the message that carried it.
274
292
  // -----------------------------------------------------------------------
275
293
  "chat.message": async (input, output) => {
276
294
  try {
@@ -279,7 +297,12 @@ export const CodeOpsPlugin: Plugin = async ({ client, directory }) => {
279
297
  )
280
298
  const level = findEffortMarker(texts)
281
299
  if (level !== undefined) {
282
- effortMarkers.set(output.message.id, { sessionID: input.sessionID, level })
300
+ effortMarkers.set(input.sessionID, level)
301
+ traceEffort(effortTraceEnabled, input.sessionID, {
302
+ event: "capture",
303
+ messageID: output.message.id,
304
+ level,
305
+ })
283
306
  }
284
307
  } catch {
285
308
  await warnContentFree(client, "Could not scan a message for a reasoning-effort marker.")
@@ -293,15 +316,40 @@ export const CodeOpsPlugin: Plugin = async ({ client, directory }) => {
293
316
  // -----------------------------------------------------------------------
294
317
  "chat.params": async (input, output) => {
295
318
  try {
296
- const stored = effortMarkers.get(input.message.id)
297
- const marker = stored && stored.sessionID === input.sessionID ? stored.level : undefined
319
+ const marker = effortMarkers.get(input.sessionID)
298
320
  const session = readSessionEffort(input.sessionID)
299
321
  const routing = readRoutingReasoning(readRoutingConfig(directory), input.agent)
300
322
  const level = resolveEffort({ marker, session, routing })
301
- if (level === undefined) return
323
+ const source = isEffortLevel(marker)
324
+ ? "marker"
325
+ : isEffortLevel(session)
326
+ ? "session"
327
+ : isRoutingReasoning(routing)
328
+ ? "routing"
329
+ : "none"
330
+ if (level === undefined) {
331
+ traceEffort(effortTraceEnabled, input.sessionID, {
332
+ event: "apply",
333
+ messageID: input.message.id,
334
+ agent: input.agent,
335
+ level: null,
336
+ source,
337
+ applied: false,
338
+ })
339
+ return
340
+ }
302
341
 
303
342
  const applied = applyEffort(output.options, level, input.model)
304
- if (applied === output.options) {
343
+ const changed = applied !== output.options
344
+ traceEffort(effortTraceEnabled, input.sessionID, {
345
+ event: "apply",
346
+ messageID: input.message.id,
347
+ agent: input.agent,
348
+ level,
349
+ source,
350
+ applied: changed,
351
+ })
352
+ if (!changed) {
305
353
  if (marker !== undefined) {
306
354
  const warningKey = `${input.sessionID}:${marker}`
307
355
  if (!warnedEffortLevels.has(warningKey)) {