opencode-codeops 1.11.0 → 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/CHANGELOG.md +10 -0
- package/README.md +32 -18
- package/_shared/reasoning-effort.md +17 -13
- package/_shared/workspace-hygiene.md +10 -6
- package/bin/index.mjs +7 -1
- package/bin/lib/opencode-plugin.mjs +122 -24
- package/bin/lib/reasoning-effort.d.mts +5 -0
- package/bin/lib/reasoning-effort.mjs +38 -0
- package/package.json +7 -6
- package/plugin/index.ts +235 -212
- package/scripts/__pycache__/install_agents.cpython-312.pyc +0 -0
- package/standards/coding-standards-full.md +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to CodeOps are recorded here.
|
|
4
4
|
|
|
5
|
+
## 2.0.0 — 2026-10-06
|
|
6
|
+
|
|
7
|
+
### Breaking Changes
|
|
8
|
+
|
|
9
|
+
- port plugin to the OpenCode 2 plugin API
|
|
10
|
+
|
|
11
|
+
### Fixes
|
|
12
|
+
|
|
13
|
+
- docs: use a placeholder version in the plugin CLI example
|
|
14
|
+
|
|
5
15
|
## 1.11.0 — 2026-10-04
|
|
6
16
|
|
|
7
17
|
### Features
|
package/README.md
CHANGED
|
@@ -46,8 +46,8 @@ npx -y opencode-codeops@latest update # alias of install
|
|
|
46
46
|
```
|
|
47
47
|
|
|
48
48
|
`install`/`update` writes the skills and subagents onto the filesystem (OpenCode discovers those
|
|
49
|
-
only from disk), then registers the plugin in the OpenCode config by calling OpenCode's own
|
|
50
|
-
`opencode plugin` command, so standards injection and `CODEOPS_PLUGIN_ROOT` are enabled. Restart
|
|
49
|
+
only from disk), then registers the plugin in the OpenCode config by calling OpenCode 2's own
|
|
50
|
+
`opencode plugin add` command, so standards injection and `CODEOPS_PLUGIN_ROOT` are enabled. Restart
|
|
51
51
|
OpenCode after installing for the plugin to load. Pass `--no-plugin` to manage the config yourself.
|
|
52
52
|
|
|
53
53
|
The installer also places the shared `_shared/` and `references/` documents beside the installed
|
|
@@ -56,16 +56,18 @@ them resolve at their installed location. An existing directory with either name
|
|
|
56
56
|
with `--force`.
|
|
57
57
|
|
|
58
58
|
The scope is auto-detected: inside a CodeOps project (a git repo with `.opencode/` or
|
|
59
|
-
`codeops/.codeops.yml`) it installs into `./.opencode
|
|
59
|
+
`codeops/.codeops.yml`) it installs into `./.opencode` and registers the plugin in the project
|
|
60
60
|
config; anywhere else it installs globally into `~/.config/opencode` and the global config. Pass
|
|
61
|
-
`--project` or `--global` to force one.
|
|
61
|
+
`--project` or `--global` to force one. OpenCode 2's CLI manages package plugins globally, so a
|
|
62
|
+
project-scope install registers the plugin globally with a note while the skills and agents stay
|
|
63
|
+
project-scoped.
|
|
62
64
|
|
|
63
|
-
If you prefer to register the plugin manually instead, add it to your `opencode.
|
|
65
|
+
If you prefer to register the plugin manually instead, add it to your `opencode.jsonc`:
|
|
64
66
|
|
|
65
67
|
```json
|
|
66
68
|
{
|
|
67
69
|
"$schema": "https://opencode.ai/config.json",
|
|
68
|
-
"
|
|
70
|
+
"plugins": ["opencode-codeops"]
|
|
69
71
|
}
|
|
70
72
|
```
|
|
71
73
|
|
|
@@ -92,23 +94,26 @@ npx -y opencode-codeops@latest uninstall
|
|
|
92
94
|
`status` reports the installed skills and agents versions and the configured plugin entry, so a
|
|
93
95
|
mismatch is visible. Use `--dry-run` to preview an install; a same-named file the package does not
|
|
94
96
|
own is skipped with a warning, and `--force` replaces it. `uninstall` removes the skills and
|
|
95
|
-
subagents but leaves the plugin entry in your config; remove `opencode-codeops` from the `
|
|
97
|
+
subagents but leaves the plugin entry in your config; remove `opencode-codeops` from the `plugins`
|
|
96
98
|
array by hand to fully disable it.
|
|
97
99
|
|
|
98
100
|
### Local development
|
|
99
101
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
102
|
+
OpenCode 2's `opencode plugin add` accepts npm or Git package specifiers only, so a checkout is
|
|
103
|
+
loaded through a local plugin file. Place a one-line re-export in a plugin directory and link the
|
|
104
|
+
installed files to the checkout so edits are picked up without reinstalling:
|
|
103
105
|
|
|
104
106
|
```bash
|
|
105
|
-
# Plugin
|
|
106
|
-
|
|
107
|
+
# Plugin — project scope (use ~/.config/opencode/plugins/ for every project)
|
|
108
|
+
mkdir -p .opencode/plugins
|
|
109
|
+
printf 'export { default } from "%s/plugin/index.ts"\n' "$PWD" > .opencode/plugins/codeops.ts
|
|
107
110
|
|
|
108
111
|
# Skills and agents — link instead of copy
|
|
109
|
-
node
|
|
112
|
+
node bin/index.mjs install --link --global --no-plugin
|
|
110
113
|
```
|
|
111
114
|
|
|
115
|
+
Pass `--no-plugin` so the checkout entry is not overwritten by a registered npm plugin.
|
|
116
|
+
|
|
112
117
|
## Setup
|
|
113
118
|
|
|
114
119
|
After installing the plugin and the files, initialize CodeOps in your project:
|
|
@@ -128,7 +133,8 @@ git commit -m "chore: initialize CodeOps"
|
|
|
128
133
|
|
|
129
134
|
## What the plugin does automatically
|
|
130
135
|
|
|
131
|
-
|
|
136
|
+
The plugin adds the CodeOps standards to every model request as system instructions, and also to
|
|
137
|
+
every compaction summary, so they are always active:
|
|
132
138
|
- `standards/coding-standards.md` — coding quality, security, testing, and working-style rules
|
|
133
139
|
- `standards/output-style.md` — how to report findings, format tables, and recommend next steps
|
|
134
140
|
|
|
@@ -136,17 +142,24 @@ These standards are active without any user action. They do not need to be copie
|
|
|
136
142
|
|
|
137
143
|
The plugin also warns (non-blocking) if any tool attempts to edit `codeops/.codeops.yml` directly — that file is managed exclusively by the `setup-codeops` skill — and if the installed skills version differs from the plugin version, so a stale install is visible.
|
|
138
144
|
|
|
139
|
-
Every session also gets a workspace-hygiene guard: the plugin exports `CODEOPS_TMPDIR`, a
|
|
145
|
+
Every session also gets a workspace-hygiene guard: the plugin exports `CODEOPS_TMPDIR`, a scratch
|
|
146
|
+
directory under the OS temp directory that belongs to the running OpenCode plugin instance. Skills
|
|
147
|
+
and agents put temporary files there and delete them when the run completes; the plugin removes the
|
|
148
|
+
directory when it unloads and sweeps directories abandoned by interrupted runs at startup, so
|
|
149
|
+
CodeOps does not accumulate scratch data. Because OpenCode 2 does not expose a session id to shell
|
|
150
|
+
hooks, concurrent sessions served by one instance share this directory. The full protocol is in
|
|
151
|
+
`_shared/workspace-hygiene.md`.
|
|
140
152
|
|
|
141
153
|
## Agent model configuration
|
|
142
154
|
|
|
143
155
|
All CodeOps subagents inherit the model of the primary agent that invoked them. No provider-specific configuration is required out of the box.
|
|
144
156
|
|
|
145
|
-
To pin specific models per role, use the `setup-routing` skill or add overrides directly in
|
|
157
|
+
To pin specific models per role, use the `setup-routing` skill or add overrides directly in
|
|
158
|
+
`opencode.jsonc`:
|
|
146
159
|
|
|
147
160
|
```json
|
|
148
161
|
{
|
|
149
|
-
"
|
|
162
|
+
"agents": {
|
|
150
163
|
"demanding-executor": { "model": "anthropic/claude-opus-4-5" },
|
|
151
164
|
"executor": { "model": "anthropic/claude-haiku-4" }
|
|
152
165
|
}
|
|
@@ -204,7 +217,8 @@ updates the index.
|
|
|
204
217
|
|
|
205
218
|
## Requirements
|
|
206
219
|
|
|
207
|
-
- OpenCode (
|
|
220
|
+
- OpenCode 2.0+ (this release uses the OpenCode 2 plugin API; on OpenCode 1, use
|
|
221
|
+
`opencode-codeops@1`)
|
|
208
222
|
- Bash
|
|
209
223
|
- Python 3.8+
|
|
210
224
|
- Git
|
|
@@ -17,13 +17,15 @@ contract consumed by the plugin and the skills. It is advisory by design and nev
|
|
|
17
17
|
| `max` | Adversarial or high-risk analysis where a missed detail is costly | Thorough preflight, complex or sensitive phases |
|
|
18
18
|
|
|
19
19
|
The four levels are the complete suggestion vocabulary. A level is applied through the model's
|
|
20
|
-
own variant
|
|
21
|
-
provider scale, with ties resolving to the higher level.
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
20
|
+
own variant options: the exact variant when it exists, otherwise the nearest exposed level on the
|
|
21
|
+
provider scale, with ties resolving to the higher level. On OpenCode 2 the plugin reads those
|
|
22
|
+
options from the registered model's `variants` collection and merges the chosen entry's settings
|
|
23
|
+
into the request. `none` stays exact-match only, because mapping it to a reasoning level would
|
|
24
|
+
enable reasoning the caller disabled. When no variant can be chosen — the model exposes no
|
|
25
|
+
matching variant — the request is left unchanged, and it never raises a provider error. Routing
|
|
26
|
+
policy is project configuration, not a suggestion:
|
|
27
|
+
`routing.roles.<agent>.reasoning` may name any value from the provider enum (`none`, `minimal`,
|
|
28
|
+
`low`, `medium`, `high`, `xhigh`, `max`).
|
|
27
29
|
|
|
28
30
|
The deepseek flash model in this environment exposes `low`, `high`, and `max`, so a `medium`
|
|
29
31
|
suggestion resolves to `high` there. Confirm the active model's set by enabling
|
|
@@ -127,10 +129,12 @@ invalid `=<level>` is an argument error.
|
|
|
127
129
|
Semantics:
|
|
128
130
|
|
|
129
131
|
1. Announce `Auto-effort active — reasoning <level> applied for this run`.
|
|
130
|
-
2. Record the level for the
|
|
132
|
+
2. Record the level for the run in the runtime scratch directory through
|
|
133
|
+
`scripts/codeops_effort.py`. When one runtime serves more than one concurrent session, the
|
|
134
|
+
most recently recorded level applies to all of them.
|
|
131
135
|
3. Clear it at run completion, before the final summary.
|
|
132
136
|
4. Run-scoped: the level applies from the point it is set until cleared or the session ends.
|
|
133
|
-
5. Fail-open: when the
|
|
137
|
+
5. Fail-open: when the runtime scratch directory is unavailable or the helper fails, print an
|
|
134
138
|
advise-only note and continue; the skill never blocks.
|
|
135
139
|
6. Never a gate: effort affects cost and latency only. No readiness check, verification step,
|
|
136
140
|
reviewer requirement, or finding gate may read it.
|
|
@@ -142,10 +146,10 @@ forces that level for the whole run, including every dispatch marker composed du
|
|
|
142
146
|
|
|
143
147
|
Set `CODEOPS_EFFORT_TRACE=1` (or `true`) in the environment that starts OpenCode to record what
|
|
144
148
|
the plugin does. One content-free JSON line is appended per marker capture and per request to
|
|
145
|
-
`reasoning-effort-trace.jsonl` inside the
|
|
146
|
-
timestamp, event name, message id, agent name, level, source, chosen variant, and
|
|
147
|
-
never prompt text or file content. The trace lives with the rest of the
|
|
148
|
-
removed with it. Tracing is off by default and never affects a request.
|
|
149
|
+
`reasoning-effort-trace.jsonl` inside the runtime's scratch directory. Lines carry only a
|
|
150
|
+
timestamp, session id, event name, message id, agent name, level, source, chosen variant, and
|
|
151
|
+
applied flag — never prompt text or file content. The trace lives with the rest of the runtime
|
|
152
|
+
scratch and is removed with it. Tracing is off by default and never affects a request.
|
|
149
153
|
|
|
150
154
|
## Suggestion-only guarantee
|
|
151
155
|
|
|
@@ -15,13 +15,17 @@ outputs are deliverables — not temporary artifacts — and they are never dele
|
|
|
15
15
|
|
|
16
16
|
## Where temporary artifacts live
|
|
17
17
|
|
|
18
|
-
1. `$CODEOPS_TMPDIR` when the CodeOps plugin exports it (one directory per
|
|
19
|
-
every scratch file.
|
|
18
|
+
1. `$CODEOPS_TMPDIR` when the CodeOps plugin exports it (one directory per OpenCode runtime).
|
|
19
|
+
Use it for every scratch file.
|
|
20
20
|
2. Otherwise, the operating system's temp directory.
|
|
21
21
|
3. Never inside the repository. A repository path is durable space; scratch does not belong there.
|
|
22
22
|
|
|
23
23
|
Create subdirectories under the temp root lazily, only when the task needs them.
|
|
24
24
|
|
|
25
|
+
OpenCode 2 does not expose a session identifier to shell hooks, so the plugin owns one scratch
|
|
26
|
+
directory per runtime instead of one per session. Sessions served by the same runtime share it;
|
|
27
|
+
keep scratch file names specific to the run to avoid collisions.
|
|
28
|
+
|
|
25
29
|
## Lifecycle
|
|
26
30
|
|
|
27
31
|
1. **Create lazily** — write a temporary file only when the task needs it.
|
|
@@ -47,7 +51,7 @@ Create subdirectories under the temp root lazily, only when the task needs them.
|
|
|
47
51
|
|
|
48
52
|
## Automatic safety net
|
|
49
53
|
|
|
50
|
-
The CodeOps plugin exports `$CODEOPS_TMPDIR` for each
|
|
51
|
-
when the
|
|
52
|
-
seven days, so an interrupted run cannot leak storage forever. The safety net is a
|
|
53
|
-
substitute: the run itself still performs the cleanup pass above.
|
|
54
|
+
The CodeOps plugin exports `$CODEOPS_TMPDIR` for each runtime and removes that runtime's
|
|
55
|
+
directory when the plugin unloads. At startup it also removes CodeOps temp directories older
|
|
56
|
+
than seven days, so an interrupted run cannot leak storage forever. The safety net is a
|
|
57
|
+
fallback, not a substitute: the run itself still performs the cleanup pass above.
|
package/bin/index.mjs
CHANGED
|
@@ -135,11 +135,17 @@ async function runCombined(command, rest, io) {
|
|
|
135
135
|
const result = registerPlugin({ scope, version, cwd, run: io.run })
|
|
136
136
|
if (result.ok) {
|
|
137
137
|
console.log(`Plugin: registered ${result.spec} in the ${scope} OpenCode config.`)
|
|
138
|
+
if (scope === "project" && result.dialect === "add") {
|
|
139
|
+
console.log(
|
|
140
|
+
"Note: the OpenCode 2 CLI manages package plugins globally; " +
|
|
141
|
+
"skills and agents remain project-scoped."
|
|
142
|
+
)
|
|
143
|
+
}
|
|
138
144
|
console.log("Restart OpenCode to load the plugin.")
|
|
139
145
|
} else {
|
|
140
146
|
console.log(
|
|
141
147
|
`Plugin: not registered (${result.reason}). ` +
|
|
142
|
-
`Add "${PLUGIN_NAME}" to the "
|
|
148
|
+
`Add "${PLUGIN_NAME}" to the "plugins" array in your opencode.jsonc.`
|
|
143
149
|
)
|
|
144
150
|
}
|
|
145
151
|
}
|
|
@@ -2,13 +2,16 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* Registers (or inspects) the CodeOps plugin in OpenCode's own config.
|
|
4
4
|
*
|
|
5
|
-
* OpenCode ships a command that installs a plugin and updates the config
|
|
6
|
-
* safely, including JSON/JSONC formatting and version replacement
|
|
5
|
+
* OpenCode 2 ships a command that installs a plugin and updates the config
|
|
6
|
+
* safely, including JSON/JSONC formatting and version replacement. Two CLI
|
|
7
|
+
* shapes exist across releases:
|
|
7
8
|
*
|
|
8
|
-
* opencode plugin <module>
|
|
9
|
+
* opencode plugin add <module> (OpenCode 2 subcommand form)
|
|
10
|
+
* opencode plugin <module> [--global] (older form; still used by some builds)
|
|
9
11
|
*
|
|
10
|
-
* The installer
|
|
11
|
-
*
|
|
12
|
+
* The installer probes the installed CLI once and uses the shape it accepts.
|
|
13
|
+
* This CodeOps version requires OpenCode 2; running it against OpenCode 1
|
|
14
|
+
* fails with a direct pointer to the 1.x line, because the plugin API changed.
|
|
12
15
|
* All calls are best-effort: if the `opencode` executable is missing or the
|
|
13
16
|
* command fails, the caller still completes the file install and tells the user
|
|
14
17
|
* how to register the plugin manually.
|
|
@@ -21,6 +24,9 @@ import { spawnSync } from "node:child_process"
|
|
|
21
24
|
/** The npm package name of the plugin. */
|
|
22
25
|
export const PLUGIN_NAME = "opencode-codeops"
|
|
23
26
|
|
|
27
|
+
/** The OpenCode major version this package requires. */
|
|
28
|
+
export const REQUIRED_OPENCODE_MAJOR = 2
|
|
29
|
+
|
|
24
30
|
/**
|
|
25
31
|
* Runs a command and normalizes the result.
|
|
26
32
|
*
|
|
@@ -39,37 +45,84 @@ export function defaultRun(command, args, options = {}) {
|
|
|
39
45
|
}
|
|
40
46
|
}
|
|
41
47
|
|
|
48
|
+
/**
|
|
49
|
+
* Extract the OpenCode major version from `opencode --version` output.
|
|
50
|
+
*
|
|
51
|
+
* @param versionText - Raw version output
|
|
52
|
+
* @returns The major version, or `undefined` when no semver is present
|
|
53
|
+
*
|
|
54
|
+
* @example
|
|
55
|
+
* parseOpenCodeMajor("2.0.24") // 2
|
|
56
|
+
*/
|
|
57
|
+
export function parseOpenCodeMajor(versionText) {
|
|
58
|
+
const match = /(\d+)\.\d+\.\d+/.exec(String(versionText ?? ""))
|
|
59
|
+
return match ? Number(match[1]) : undefined
|
|
60
|
+
}
|
|
61
|
+
|
|
42
62
|
/**
|
|
43
63
|
* Builds the arguments for `opencode plugin`.
|
|
44
64
|
*
|
|
45
65
|
* @param details - Plugin registration inputs
|
|
46
|
-
* @param details.scope - `"global"` adds `--global
|
|
66
|
+
* @param details.scope - `"global"` adds `--global` when the CLI supports it
|
|
47
67
|
* @param details.version - Exact version to pin, or `undefined` for the bare name
|
|
68
|
+
* @param details.dialect - `"add"` for `plugin add <spec>`, `"positional"` otherwise
|
|
69
|
+
* @param details.supportsGlobal - Whether the chosen dialect accepts `--global`
|
|
48
70
|
* @returns Arguments after the `opencode` executable
|
|
71
|
+
*
|
|
49
72
|
* @example
|
|
50
|
-
* buildPluginArgs({ scope: "global", version: "1.
|
|
51
|
-
* // ["plugin", "opencode-codeops@1.
|
|
73
|
+
* buildPluginArgs({ scope: "global", version: "1.2.3", dialect: "add", supportsGlobal: true })
|
|
74
|
+
* // ["plugin", "add", "opencode-codeops@1.2.3", "--global"]
|
|
52
75
|
*/
|
|
53
|
-
export function buildPluginArgs({ scope, version }) {
|
|
76
|
+
export function buildPluginArgs({ scope, version, dialect = "positional", supportsGlobal = true }) {
|
|
54
77
|
const module = version ? `${PLUGIN_NAME}@${version}` : PLUGIN_NAME
|
|
78
|
+
const global = scope === "global" && supportsGlobal
|
|
79
|
+
|
|
80
|
+
if (dialect === "add") {
|
|
81
|
+
const args = ["plugin", "add", module]
|
|
82
|
+
if (global) args.push("--global")
|
|
83
|
+
return args
|
|
84
|
+
}
|
|
85
|
+
|
|
55
86
|
const args = ["plugin", module]
|
|
56
|
-
if (
|
|
87
|
+
if (global) args.push("--global")
|
|
57
88
|
args.push("--force")
|
|
58
89
|
return args
|
|
59
90
|
}
|
|
60
91
|
|
|
92
|
+
/**
|
|
93
|
+
* Detect which `opencode plugin` shape the installed CLI accepts.
|
|
94
|
+
*
|
|
95
|
+
* The subcommand form answers `opencode plugin add --help`; older builds treat
|
|
96
|
+
* `add` as a module name and fail. The help text also tells whether the
|
|
97
|
+
* subcommand accepts an explicit `--global` flag.
|
|
98
|
+
*
|
|
99
|
+
* @param details - Detection inputs
|
|
100
|
+
* @param details.cwd - Working directory
|
|
101
|
+
* @param details.run - Command runner, injectable for tests
|
|
102
|
+
* @returns The dialect and whether it supports `--global`
|
|
103
|
+
*/
|
|
104
|
+
export function detectPluginDialect({ cwd, run = defaultRun }) {
|
|
105
|
+
const help = run("opencode", ["plugin", "add", "--help"], { cwd })
|
|
106
|
+
if (!help.error && help.status === 0) {
|
|
107
|
+
const text = `${help.stdout}\n${help.stderr}`
|
|
108
|
+
return { dialect: "add", supportsGlobal: /--global\b/.test(text) }
|
|
109
|
+
}
|
|
110
|
+
return { dialect: "positional", supportsGlobal: true }
|
|
111
|
+
}
|
|
112
|
+
|
|
61
113
|
/**
|
|
62
114
|
* Registers the plugin in the OpenCode config.
|
|
63
115
|
*
|
|
64
|
-
* Tries the exact version first so plugin and files stay
|
|
65
|
-
* rejects the versioned spec, falls back to the bare
|
|
116
|
+
* Requires OpenCode 2. Tries the exact version first so plugin and files stay
|
|
117
|
+
* in sync; if the CLI rejects the versioned spec, falls back to the bare
|
|
118
|
+
* package name. Never throws.
|
|
66
119
|
*
|
|
67
120
|
* @param details - Registration inputs
|
|
68
121
|
* @param details.scope - `"global"` or `"project"`
|
|
69
122
|
* @param details.version - Version to pin
|
|
70
123
|
* @param details.cwd - Working directory (used for project scope)
|
|
71
124
|
* @param details.run - Command runner, injectable for tests
|
|
72
|
-
* @returns Whether registration succeeded, the spec used, and a reason on failure
|
|
125
|
+
* @returns Whether registration succeeded, the spec used, the dialect, and a reason on failure
|
|
73
126
|
*/
|
|
74
127
|
export function registerPlugin({ scope, version, cwd, run = defaultRun }) {
|
|
75
128
|
try {
|
|
@@ -78,30 +131,69 @@ export function registerPlugin({ scope, version, cwd, run = defaultRun }) {
|
|
|
78
131
|
return { ok: false, spec: null, reason: "opencode CLI not found on PATH" }
|
|
79
132
|
}
|
|
80
133
|
|
|
81
|
-
const
|
|
82
|
-
if (
|
|
83
|
-
return {
|
|
134
|
+
const major = parseOpenCodeMajor(probe.stdout)
|
|
135
|
+
if (major !== undefined && major < REQUIRED_OPENCODE_MAJOR) {
|
|
136
|
+
return {
|
|
137
|
+
ok: false,
|
|
138
|
+
spec: null,
|
|
139
|
+
reason:
|
|
140
|
+
`OpenCode ${REQUIRED_OPENCODE_MAJOR} is required (found ${probe.stdout.trim()}); ` +
|
|
141
|
+
"on OpenCode 1, use opencode-codeops@1",
|
|
142
|
+
}
|
|
84
143
|
}
|
|
85
144
|
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
145
|
+
const { dialect, supportsGlobal } = detectPluginDialect({ cwd, run })
|
|
146
|
+
let lastMessage = "opencode plugin command failed"
|
|
147
|
+
|
|
148
|
+
for (const candidate of [version, null]) {
|
|
149
|
+
const args = buildPluginArgs({ scope, version: candidate, dialect, supportsGlobal })
|
|
150
|
+
const result = run("opencode", args, { cwd })
|
|
151
|
+
if (!result.error && result.status === 0) {
|
|
152
|
+
return {
|
|
153
|
+
ok: true,
|
|
154
|
+
spec: candidate ? `${PLUGIN_NAME}@${candidate}` : PLUGIN_NAME,
|
|
155
|
+
dialect,
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
const message = (result.stderr || result.stdout || "").trim()
|
|
159
|
+
lastMessage = message.split("\n").pop() || lastMessage
|
|
89
160
|
}
|
|
90
161
|
|
|
91
|
-
|
|
92
|
-
return { ok: false, spec: null, reason: message.split("\n").pop() || "opencode plugin failed" }
|
|
162
|
+
return { ok: false, spec: null, reason: lastMessage }
|
|
93
163
|
} catch (caught) {
|
|
94
164
|
return { ok: false, spec: null, reason: caught.message }
|
|
95
165
|
}
|
|
96
166
|
}
|
|
97
167
|
|
|
98
168
|
/**
|
|
99
|
-
*
|
|
169
|
+
* Normalize one configured plugin entry to its package spec.
|
|
170
|
+
*
|
|
171
|
+
* OpenCode accepts plain spec strings, `[spec, options]` tuples, and
|
|
172
|
+
* `{ package, options }` objects across versions, so the status readout must
|
|
173
|
+
* understand all three.
|
|
174
|
+
*
|
|
175
|
+
* @param item - Raw config entry
|
|
176
|
+
* @returns The spec string, or `undefined` when the entry is unusable
|
|
177
|
+
*/
|
|
178
|
+
function entrySpec(item) {
|
|
179
|
+
if (typeof item === "string") return item
|
|
180
|
+
if (Array.isArray(item) && typeof item[0] === "string") return item[0]
|
|
181
|
+
if (item && typeof item === "object" && typeof item.package === "string") {
|
|
182
|
+
return item.package
|
|
183
|
+
}
|
|
184
|
+
return undefined
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Reads the resolved plugin list from OpenCode's effective config.
|
|
189
|
+
*
|
|
190
|
+
* OpenCode 2 uses `plugins`; the legacy singular `plugin` key is still read as
|
|
191
|
+
* a fallback so mixed configs are reported correctly.
|
|
100
192
|
*
|
|
101
193
|
* @param details - Inspection inputs
|
|
102
194
|
* @param details.cwd - Working directory
|
|
103
195
|
* @param details.run - Command runner, injectable for tests
|
|
104
|
-
* @returns The plugin
|
|
196
|
+
* @returns The plugin spec strings, or `undefined` when the config cannot be read
|
|
105
197
|
*/
|
|
106
198
|
export function readConfiguredPlugin({ cwd, run = defaultRun } = {}) {
|
|
107
199
|
try {
|
|
@@ -109,7 +201,13 @@ export function readConfiguredPlugin({ cwd, run = defaultRun } = {}) {
|
|
|
109
201
|
if (result.error || result.status !== 0) return undefined
|
|
110
202
|
|
|
111
203
|
const config = JSON.parse(result.stdout)
|
|
112
|
-
|
|
204
|
+
const raw = Array.isArray(config.plugins)
|
|
205
|
+
? config.plugins
|
|
206
|
+
: Array.isArray(config.plugin)
|
|
207
|
+
? config.plugin
|
|
208
|
+
: []
|
|
209
|
+
|
|
210
|
+
return raw.map(entrySpec).filter((spec) => typeof spec === "string")
|
|
113
211
|
} catch {
|
|
114
212
|
return undefined
|
|
115
213
|
}
|
|
@@ -81,6 +81,11 @@ export declare function extractModelVariants(
|
|
|
81
81
|
model: unknown
|
|
82
82
|
): Record<string, unknown> | undefined
|
|
83
83
|
|
|
84
|
+
/** Normalize a host variant collection (v2 array or v1 record) into a record. */
|
|
85
|
+
export declare function normalizeModelVariants(
|
|
86
|
+
variants: unknown
|
|
87
|
+
): Record<string, unknown>
|
|
88
|
+
|
|
84
89
|
/** Check whether a model advertises reasoning support. */
|
|
85
90
|
export declare function modelSupportsReasoning(model: unknown): boolean
|
|
86
91
|
|
|
@@ -406,6 +406,44 @@ export function applyEffort(options, level, model) {
|
|
|
406
406
|
return { ...options, reasoningEffort: level }
|
|
407
407
|
}
|
|
408
408
|
|
|
409
|
+
/**
|
|
410
|
+
* Normalize a host model's variant collection into a keyed record.
|
|
411
|
+
*
|
|
412
|
+
* OpenCode 2 exposes model variants as an array of entries shaped
|
|
413
|
+
* `{ id, settings }`, while {@link applyEffort} consumes a record keyed by
|
|
414
|
+
* variant id. This adapter bridges the two without changing the core logic.
|
|
415
|
+
* A record input is passed through unchanged, so v1-style callers and tests
|
|
416
|
+
* keep working. Malformed entries — non-objects, missing ids, non-object
|
|
417
|
+
* settings — are skipped; anything that is not an array or plain object yields
|
|
418
|
+
* an empty record.
|
|
419
|
+
*
|
|
420
|
+
* @param variants - Variant collection from the host (any shape)
|
|
421
|
+
* @returns A record mapping variant id to its settings object
|
|
422
|
+
*
|
|
423
|
+
* @example
|
|
424
|
+
* normalizeModelVariants([{ id: "high", settings: { reasoningEffort: "high" } }])
|
|
425
|
+
* // { high: { reasoningEffort: "high" } }
|
|
426
|
+
*/
|
|
427
|
+
export function normalizeModelVariants(variants) {
|
|
428
|
+
if (isPlainObject(variants)) return { ...variants }
|
|
429
|
+
if (!Array.isArray(variants)) return {}
|
|
430
|
+
|
|
431
|
+
const record = {}
|
|
432
|
+
for (const entry of variants) {
|
|
433
|
+
if (!isPlainObject(entry)) continue
|
|
434
|
+
const id = entry.id
|
|
435
|
+
if (typeof id !== "string" || id.length === 0) continue
|
|
436
|
+
const settings = isPlainObject(entry.settings) ? entry.settings : {}
|
|
437
|
+
Object.defineProperty(record, id, {
|
|
438
|
+
value: settings,
|
|
439
|
+
writable: true,
|
|
440
|
+
enumerable: true,
|
|
441
|
+
configurable: true,
|
|
442
|
+
})
|
|
443
|
+
}
|
|
444
|
+
return record
|
|
445
|
+
}
|
|
446
|
+
|
|
409
447
|
/**
|
|
410
448
|
* Recursively merge plain objects into a new object.
|
|
411
449
|
*
|
package/package.json
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-codeops",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "Specification-first engineering for complex systems — CodeOps plugin for OpenCode",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
7
|
-
"node": ">=18"
|
|
7
|
+
"node": ">=18",
|
|
8
|
+
"opencode": ">=2 <3"
|
|
8
9
|
},
|
|
9
10
|
"main": "./plugin/index.ts",
|
|
10
11
|
"exports": {
|
|
11
|
-
".": "./plugin/index.ts"
|
|
12
|
+
".": "./plugin/index.ts",
|
|
13
|
+
"./server": "./plugin/index.ts"
|
|
12
14
|
},
|
|
13
15
|
"bin": {
|
|
14
16
|
"opencode-codeops": "bin/index.mjs"
|
|
@@ -62,11 +64,10 @@
|
|
|
62
64
|
"bugs": {
|
|
63
65
|
"url": "https://github.com/blendsdk/opencode-codeops/issues"
|
|
64
66
|
},
|
|
65
|
-
"
|
|
66
|
-
"@opencode
|
|
67
|
+
"dependencies": {
|
|
68
|
+
"@opencode/plugin": "^2.0.24"
|
|
67
69
|
},
|
|
68
70
|
"devDependencies": {
|
|
69
|
-
"@opencode-ai/plugin": "1.18.30",
|
|
70
71
|
"@types/node": "^22.20.2",
|
|
71
72
|
"typescript": "^5.0.0"
|
|
72
73
|
}
|
package/plugin/index.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import
|
|
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 {
|
|
5
|
+
import { dirname, join } from "node:path"
|
|
5
6
|
import { fileURLToPath } from "node:url"
|
|
6
7
|
|
|
7
8
|
import {
|
|
@@ -17,7 +18,7 @@ import {
|
|
|
17
18
|
isEffortLevel,
|
|
18
19
|
isEffortTraceEnabled,
|
|
19
20
|
isRoutingReasoning,
|
|
20
|
-
|
|
21
|
+
normalizeModelVariants,
|
|
21
22
|
readRoutingReasoning,
|
|
22
23
|
readSessionEffort,
|
|
23
24
|
resolveEffort,
|
|
@@ -52,7 +53,8 @@ function readPackageVersion(): string {
|
|
|
52
53
|
}
|
|
53
54
|
|
|
54
55
|
// ---------------------------------------------------------------------------
|
|
55
|
-
// Load standards at startup (once). Both files are injected into every
|
|
56
|
+
// Load standards at startup (once). Both files are injected into every request
|
|
57
|
+
// as system instructions, so they also survive context compaction.
|
|
56
58
|
// ---------------------------------------------------------------------------
|
|
57
59
|
const codingStandards = readFileSync(
|
|
58
60
|
join(PACKAGE_ROOT, "standards", "coding-standards.md"),
|
|
@@ -65,50 +67,49 @@ const outputStyle = readFileSync(
|
|
|
65
67
|
const standardsText = `${codingStandards}\n\n${outputStyle}`
|
|
66
68
|
|
|
67
69
|
// ---------------------------------------------------------------------------
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
//
|
|
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.
|
|
72
76
|
// ---------------------------------------------------------------------------
|
|
73
|
-
|
|
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)"
|
|
74
81
|
|
|
75
82
|
/**
|
|
76
|
-
*
|
|
77
|
-
*
|
|
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
|
|
78
88
|
*/
|
|
79
|
-
function
|
|
80
|
-
|
|
81
|
-
sweptStaleTmpDirs = true
|
|
82
|
-
try {
|
|
83
|
-
cleanStaleTmpDirs()
|
|
84
|
-
} catch {
|
|
85
|
-
// Best effort only.
|
|
86
|
-
}
|
|
89
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
90
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
87
91
|
}
|
|
88
92
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
await client.session.prompt({
|
|
98
|
-
path: { id: sessionId },
|
|
99
|
-
body: {
|
|
100
|
-
noReply: true,
|
|
101
|
-
parts: [{ type: "text", text: standardsText }],
|
|
102
|
-
},
|
|
103
|
-
})
|
|
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}`)
|
|
104
101
|
}
|
|
105
102
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
+
*/
|
|
112
113
|
function installedSkillsVersion(skillsDir: string): string | undefined {
|
|
113
114
|
try {
|
|
114
115
|
const marker = JSON.parse(
|
|
@@ -120,16 +121,15 @@ function installedSkillsVersion(skillsDir: string): string | undefined {
|
|
|
120
121
|
}
|
|
121
122
|
}
|
|
122
123
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
): 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 {
|
|
133
133
|
const skillsDirs = [
|
|
134
134
|
join(homedir(), ".config", "opencode", "skills"),
|
|
135
135
|
join(directory, ".opencode", "skills"),
|
|
@@ -139,38 +139,21 @@ async function warnOnVersionSkew(
|
|
|
139
139
|
const installed = installedSkillsVersion(skillsDir)
|
|
140
140
|
if (!installed || installed === packageVersion) continue
|
|
141
141
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
`CodeOps skills at ${skillsDir} are version ${installed}, ` +
|
|
148
|
-
`but the plugin is version ${packageVersion}. ` +
|
|
149
|
-
`Run \`npx opencode-codeops@${packageVersion} update\` to match them.`,
|
|
150
|
-
},
|
|
151
|
-
})
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
// ---------------------------------------------------------------------------
|
|
156
|
-
// Helper — log one content-free warning. Logging is best effort: a failed log
|
|
157
|
-
// must never break a request.
|
|
158
|
-
// ---------------------------------------------------------------------------
|
|
159
|
-
async function warnContentFree(
|
|
160
|
-
client: Parameters<Plugin>[0]["client"],
|
|
161
|
-
message: string
|
|
162
|
-
): Promise<void> {
|
|
163
|
-
try {
|
|
164
|
-
await client.app.log({ body: { service: "codeops", level: "warn", message } })
|
|
165
|
-
} catch {
|
|
166
|
-
// 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
|
+
)
|
|
167
147
|
}
|
|
168
148
|
}
|
|
169
149
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
+
*/
|
|
174
157
|
function readRoutingConfig(directory: string): unknown {
|
|
175
158
|
try {
|
|
176
159
|
return JSON.parse(readFileSync(join(directory, "codeops", "codeops.json"), "utf8"))
|
|
@@ -179,149 +162,139 @@ function readRoutingConfig(directory: string): unknown {
|
|
|
179
162
|
}
|
|
180
163
|
}
|
|
181
164
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
+
*/
|
|
187
174
|
function traceEffort(
|
|
188
175
|
enabled: boolean,
|
|
189
176
|
sessionID: string,
|
|
190
177
|
entry: Record<string, unknown>
|
|
191
178
|
): void {
|
|
192
179
|
if (!enabled) return
|
|
193
|
-
appendEffortTrace(
|
|
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
|
+
}
|
|
194
209
|
}
|
|
195
210
|
|
|
196
211
|
// ---------------------------------------------------------------------------
|
|
197
|
-
// CodeOps plugin for OpenCode
|
|
212
|
+
// CodeOps plugin for OpenCode 2
|
|
198
213
|
// Replaces: hooks/hooks.json + hook_session_context.sh + hook_marker_guard.sh
|
|
199
214
|
// ---------------------------------------------------------------------------
|
|
200
|
-
export
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
const effortMarkers = new Map<string, EffortLevel>()
|
|
205
|
-
const warnedEffortLevels = new Set<string>()
|
|
206
|
-
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
|
|
207
219
|
|
|
208
|
-
|
|
209
|
-
//
|
|
210
|
-
//
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
// Also the hygiene lifecycle: a new top-level session sweeps abandoned
|
|
215
|
-
// temp directories once, and a deleted session's temp directory is removed.
|
|
216
|
-
// -----------------------------------------------------------------------
|
|
217
|
-
event: async ({ event }) => {
|
|
218
|
-
if (event.type === "session.created") {
|
|
219
|
-
const info = (event.properties as { info: { id: string; parentID?: string } }).info
|
|
220
|
-
await injectStandards(client, info.id)
|
|
221
|
-
await warnOnVersionSkew(client, directory)
|
|
222
|
-
if (!info.parentID) sweepStaleTmpDirsOnce()
|
|
223
|
-
} else if (event.type === "session.deleted") {
|
|
224
|
-
const info = (event.properties as { info: { id: string } }).info
|
|
225
|
-
removeSessionTmpDir(info.id)
|
|
226
|
-
try {
|
|
227
|
-
effortMarkers.delete(info.id)
|
|
228
|
-
} catch {
|
|
229
|
-
await warnContentFree(client, "Could not clear captured reasoning-effort markers.")
|
|
230
|
-
}
|
|
231
|
-
} else if (event.type === "session.compacted") {
|
|
232
|
-
const sessionId: string = (event.properties as { sessionID: string }).sessionID
|
|
233
|
-
await injectStandards(client, sessionId)
|
|
234
|
-
}
|
|
235
|
-
},
|
|
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)
|
|
236
226
|
|
|
237
|
-
//
|
|
238
|
-
//
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
standardsText
|
|
245
|
-
)
|
|
246
|
-
},
|
|
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
|
+
}
|
|
247
234
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
//
|
|
251
|
-
//
|
|
252
|
-
//
|
|
253
|
-
//
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
"shell.env": async (input, output) => {
|
|
257
|
-
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 () => {
|
|
258
243
|
try {
|
|
259
|
-
|
|
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
|
+
}
|
|
260
249
|
} catch {
|
|
261
|
-
//
|
|
250
|
+
// The stream ended or the plugin unloaded; nothing to do.
|
|
262
251
|
}
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
// -----------------------------------------------------------------------
|
|
266
|
-
// Hook 5: advisory guard — warn (non-blocking) if any edit tool targets
|
|
267
|
-
// codeops/.codeops.yml, which is owned exclusively by the setup-codeops
|
|
268
|
-
// skill. Equivalent to Codex PreToolUse hook_marker_guard.sh.
|
|
269
|
-
// args are on the output parameter per the OpenCode plugin type signature.
|
|
270
|
-
// -----------------------------------------------------------------------
|
|
271
|
-
"tool.execute.before": async (input, output) => {
|
|
272
|
-
const editTools = ["write", "edit", "apply_patch"]
|
|
273
|
-
if (!editTools.includes(input.tool)) return
|
|
252
|
+
})()
|
|
274
253
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
+
})
|
|
280
262
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
"owned by setup-codeops. Edit it only through the setup/migration " +
|
|
285
|
-
"workflow (run the setup-codeops skill).\n"
|
|
286
|
-
)
|
|
287
|
-
}
|
|
288
|
-
},
|
|
263
|
+
await ctx.session.hook("compaction", (event) => {
|
|
264
|
+
event.system.push({ type: "text", text: `${STANDARDS_HEADING}\n\n${standardsText}` })
|
|
265
|
+
})
|
|
289
266
|
|
|
290
|
-
//
|
|
291
|
-
//
|
|
292
|
-
//
|
|
293
|
-
//
|
|
294
|
-
//
|
|
295
|
-
|
|
296
|
-
"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) => {
|
|
297
273
|
try {
|
|
298
|
-
const
|
|
299
|
-
part?.type === "text" ? part.text : undefined
|
|
300
|
-
)
|
|
301
|
-
const level = findEffortMarker(texts)
|
|
274
|
+
const level = findEffortMarker([event.prompt.text])
|
|
302
275
|
if (level !== undefined) {
|
|
303
|
-
effortMarkers.set(
|
|
304
|
-
traceEffort(effortTraceEnabled,
|
|
276
|
+
effortMarkers.set(event.sessionID, level)
|
|
277
|
+
traceEffort(effortTraceEnabled, event.sessionID, {
|
|
305
278
|
event: "capture",
|
|
306
|
-
messageID:
|
|
279
|
+
messageID: event.messageID,
|
|
307
280
|
level,
|
|
308
281
|
})
|
|
309
282
|
}
|
|
310
283
|
} catch {
|
|
311
|
-
|
|
284
|
+
warnContentFree("Could not scan a message for a reasoning-effort marker.")
|
|
312
285
|
}
|
|
313
|
-
}
|
|
286
|
+
})
|
|
314
287
|
|
|
315
|
-
//
|
|
316
|
-
//
|
|
317
|
-
// session flag, then routing default) and merge the model's own
|
|
318
|
-
// options. Any failure leaves the request unchanged.
|
|
319
|
-
//
|
|
320
|
-
|
|
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) => {
|
|
321
294
|
try {
|
|
322
|
-
const marker = effortMarkers.get(
|
|
323
|
-
const session = readSessionEffort(
|
|
324
|
-
const routing = readRoutingReasoning(readRoutingConfig(directory),
|
|
295
|
+
const marker = effortMarkers.get(event.sessionID)
|
|
296
|
+
const session = readSessionEffort(runtimeID)
|
|
297
|
+
const routing = readRoutingReasoning(readRoutingConfig(directory), event.agent)
|
|
325
298
|
const level = resolveEffort({ marker, session, routing })
|
|
326
299
|
const source = isEffortLevel(marker)
|
|
327
300
|
? "marker"
|
|
@@ -331,10 +304,9 @@ export const CodeOpsPlugin: Plugin = async ({ client, directory }) => {
|
|
|
331
304
|
? "routing"
|
|
332
305
|
: "none"
|
|
333
306
|
if (level === undefined) {
|
|
334
|
-
traceEffort(effortTraceEnabled,
|
|
307
|
+
traceEffort(effortTraceEnabled, event.sessionID, {
|
|
335
308
|
event: "apply",
|
|
336
|
-
|
|
337
|
-
agent: input.agent,
|
|
309
|
+
agent: event.agent,
|
|
338
310
|
level: null,
|
|
339
311
|
source,
|
|
340
312
|
applied: false,
|
|
@@ -342,37 +314,88 @@ export const CodeOpsPlugin: Plugin = async ({ client, directory }) => {
|
|
|
342
314
|
return
|
|
343
315
|
}
|
|
344
316
|
|
|
345
|
-
const
|
|
346
|
-
const
|
|
347
|
-
|
|
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, {
|
|
348
322
|
event: "apply",
|
|
349
|
-
|
|
350
|
-
agent: input.agent,
|
|
323
|
+
agent: event.agent,
|
|
351
324
|
level,
|
|
352
325
|
source,
|
|
353
326
|
applied: changed,
|
|
354
|
-
variant: selectEffortVariant(level,
|
|
355
|
-
|
|
356
|
-
variantLevels: Object.keys(extractModelVariants(input.model) ?? {}),
|
|
327
|
+
variant: selectEffortVariant(level, model) ?? null,
|
|
328
|
+
variantLevels: Object.keys(extractModelVariants(model) ?? {}),
|
|
357
329
|
})
|
|
358
330
|
if (!changed) {
|
|
359
331
|
if (marker !== undefined) {
|
|
360
|
-
const warningKey = `${
|
|
332
|
+
const warningKey = `${event.sessionID}:${marker}`
|
|
361
333
|
if (!warnedEffortLevels.has(warningKey)) {
|
|
362
334
|
warnedEffortLevels.add(warningKey)
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
`Reasoning effort ${marker} is not available for agent ${input.agent}; ` +
|
|
335
|
+
warnContentFree(
|
|
336
|
+
`Reasoning effort ${marker} is not available for agent ${event.agent}; ` +
|
|
366
337
|
"request left unchanged."
|
|
367
338
|
)
|
|
368
339
|
}
|
|
369
340
|
}
|
|
370
341
|
return
|
|
371
342
|
}
|
|
372
|
-
|
|
343
|
+
Object.assign(event.options, applied)
|
|
373
344
|
} catch {
|
|
374
|
-
|
|
345
|
+
warnContentFree("Could not apply a reasoning-effort level to a request.")
|
|
375
346
|
}
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
|
|
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
|
+
})
|
|
Binary file
|
|
@@ -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
|
|
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.
|