opencode-codeops 2.4.0 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  All notable changes to CodeOps are recorded here.
4
4
 
5
+ ## 2.5.0 — 2026-10-29
6
+
7
+ ### Features
8
+
9
+ - setup-codeops: add Discovery enable and disable toggles
10
+
5
11
  ## 2.4.0 — 2026-10-29
6
12
 
7
13
  ### Features
package/README.md CHANGED
@@ -191,8 +191,16 @@ The shared header carries a real breadcrumb and a **System / Light / Dark** them
191
191
  follows the operating-system setting; an explicit choice is remembered in the browser. If storage
192
192
  is unavailable, the choice still applies for the current tab.
193
193
 
194
- New projects created with `setup-codeops` start with Discovery enabled. For an existing project,
195
- run `analyze-project --with-discovery`, or add the setting to `codeops/codeops.json`:
194
+ New projects created with `setup-codeops` start with Discovery enabled. Existing projects can
195
+ toggle it without re-scaffolding:
196
+
197
+ ```text
198
+ /setup-codeops --enable-discovery
199
+ /setup-codeops --disable-discovery
200
+ ```
201
+
202
+ `analyze-project --with-discovery` remains available for the same config change. The setting lives
203
+ in `codeops/codeops.json`:
196
204
 
197
205
  ```json
198
206
  {
@@ -4,7 +4,8 @@
4
4
 
5
5
  Discovery moves large question rounds out of the chat scroll and into a local
6
6
  browser page. New projects created with `setup-codeops` enable it by default;
7
- existing projects opt in. It never replaces the Ambiguity Register. Use it only
7
+ existing projects toggle it with `setup-codeops --enable-discovery` or
8
+ `--disable-discovery`. It never replaces the Ambiguity Register. Use it only
8
9
  when this file's conditions hold; otherwise ask in chat exactly as before.
9
10
 
10
11
  ## When it applies
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-codeops",
3
- "version": "2.4.0",
3
+ "version": "2.5.0",
4
4
  "description": "Specification-first engineering for complex systems — CodeOps plugin for OpenCode",
5
5
  "type": "module",
6
6
  "engines": {
@@ -0,0 +1,236 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Enable or disable Discovery in an existing CodeOps project.
4
+ *
5
+ * The toggle is deliberately small and deterministic: it edits only
6
+ * `codeops/codeops.json` and, when the managed block exists, the Discovery
7
+ * line in `AGENTS.md`. It refuses symlinks and malformed configuration, and
8
+ * preserves every unrelated key.
9
+ *
10
+ * @module discovery-toggle
11
+ */
12
+
13
+ import { existsSync, lstatSync, readFileSync, writeFileSync } from "node:fs"
14
+ import { join, resolve } from "node:path"
15
+ import { fileURLToPath } from "node:url"
16
+
17
+ /** The managed AGENTS.md line for an enabled Discovery page. */
18
+ export const DISCOVERY_LINE =
19
+ "- Discovery: enabled — question rounds open in the local Discovery page"
20
+
21
+ const START_MARKER = "<!-- CODEOPS-PROJECT:START -->"
22
+ const END_MARKER = "<!-- CODEOPS-PROJECT:END -->"
23
+
24
+ /** A user-facing toggle failure. */
25
+ export class DiscoveryToggleError extends Error {}
26
+
27
+ /** True for a non-null, non-array object. */
28
+ function isPlainObject(value) {
29
+ return value !== null && typeof value === "object" && !Array.isArray(value)
30
+ }
31
+
32
+ /** Refuse a symlink or a missing regular file. */
33
+ function assertRegularFile(path, label) {
34
+ if (!existsSync(path)) {
35
+ throw new DiscoveryToggleError(`${label} does not exist.`)
36
+ }
37
+ const stats = lstatSync(path)
38
+ if (stats.isSymbolicLink()) {
39
+ throw new DiscoveryToggleError(`${label} is a symlink; refusing to write through it.`)
40
+ }
41
+ if (!stats.isFile()) {
42
+ throw new DiscoveryToggleError(`${label} is not a regular file.`)
43
+ }
44
+ }
45
+
46
+ /** Refuse a symlinked codeops directory. */
47
+ function assertDirectory(path, label) {
48
+ if (!existsSync(path)) {
49
+ throw new DiscoveryToggleError(`${label} does not exist; run setup-codeops first.`)
50
+ }
51
+ const stats = lstatSync(path)
52
+ if (stats.isSymbolicLink()) {
53
+ throw new DiscoveryToggleError(`${label} is a symlink; refusing to write through it.`)
54
+ }
55
+ if (!stats.isDirectory()) {
56
+ throw new DiscoveryToggleError(`${label} is not a directory.`)
57
+ }
58
+ }
59
+
60
+ /** Normalize a config's discovery object without discarding known keys. */
61
+ function nextDiscovery(existing, enable) {
62
+ if (existing !== undefined && !isPlainObject(existing)) {
63
+ throw new DiscoveryToggleError("codeops/codeops.json has a malformed discovery object.")
64
+ }
65
+ const discovery = { ...(existing ?? {}) }
66
+ discovery.enabled = enable
67
+ if (enable && discovery.chatMaxQuestions === undefined) {
68
+ discovery.chatMaxQuestions = 3
69
+ }
70
+ if (
71
+ discovery.chatMaxQuestions !== undefined &&
72
+ (!Number.isInteger(discovery.chatMaxQuestions) || discovery.chatMaxQuestions < 0)
73
+ ) {
74
+ throw new DiscoveryToggleError("discovery.chatMaxQuestions must be a non-negative integer.")
75
+ }
76
+ return discovery
77
+ }
78
+
79
+ /** Add or remove the managed Discovery line inside an AGENTS.md managed block. */
80
+ function updateAgents(text, enable) {
81
+ const eol = text.includes("\r\n") ? "\r\n" : "\n"
82
+ const lines = text.split(/\r?\n/)
83
+ const starts = lines
84
+ .map((line, index) => (line.trim() === START_MARKER ? index : -1))
85
+ .filter((index) => index >= 0)
86
+ const ends = lines
87
+ .map((line, index) => (line.trim() === END_MARKER ? index : -1))
88
+ .filter((index) => index >= 0)
89
+ if (starts.length !== 1 || ends.length !== 1 || starts[0] > ends[0]) {
90
+ return { status: "block-missing", changed: false, text }
91
+ }
92
+ const start = starts[0]
93
+ const end = ends[0]
94
+ const block = lines.slice(start, end + 1)
95
+ const existing = block.findIndex((line) => line.trim() === DISCOVERY_LINE)
96
+ if (enable) {
97
+ if (existing >= 0) return { status: "already-enabled", changed: false, text }
98
+ const anchor = block.findIndex((line) => line.trim().startsWith("- Specialist coverage:"))
99
+ if (anchor === -1) {
100
+ return { status: "anchor-missing", changed: false, text }
101
+ }
102
+ block.splice(anchor + 1, 0, DISCOVERY_LINE)
103
+ } else {
104
+ if (existing === -1) return { status: "already-disabled", changed: false, text }
105
+ block.splice(existing, 1)
106
+ }
107
+ lines.splice(start, end - start + 1, ...block)
108
+ return { status: enable ? "enabled" : "disabled", changed: true, text: lines.join(eol) }
109
+ }
110
+
111
+ /**
112
+ * Toggle Discovery for one project.
113
+ *
114
+ * @param {{ root?: string, enable: boolean, dryRun?: boolean }} options
115
+ * @returns {{ configPath: string, discovery: object, changed: boolean,
116
+ * agents: string, dryRun: boolean }}
117
+ */
118
+ export function toggleDiscovery(options) {
119
+ if (typeof options.enable !== "boolean") {
120
+ throw new DiscoveryToggleError("enable must be true or false.")
121
+ }
122
+ const root = resolve(options.root ?? process.cwd())
123
+ const dryRun = options.dryRun === true
124
+ const codeopsDir = join(root, "codeops")
125
+ assertDirectory(codeopsDir, "codeops/")
126
+ const configPath = join(codeopsDir, "codeops.json")
127
+ assertRegularFile(configPath, "codeops/codeops.json")
128
+
129
+ const original = readFileSync(configPath, "utf8")
130
+ let config
131
+ try {
132
+ config = JSON.parse(original)
133
+ } catch {
134
+ throw new DiscoveryToggleError("codeops/codeops.json is not valid JSON.")
135
+ }
136
+ if (!isPlainObject(config)) {
137
+ throw new DiscoveryToggleError("codeops/codeops.json must contain a JSON object.")
138
+ }
139
+ const discovery = nextDiscovery(config.discovery, options.enable)
140
+ const nextConfig = { ...config, discovery }
141
+ const nextText = JSON.stringify(nextConfig, null, 2) + "\n"
142
+ const configChanged = nextText !== original
143
+
144
+ const agentsPath = join(root, "AGENTS.md")
145
+ let agents = "missing"
146
+ let agentsText = null
147
+ let agentsChanged = false
148
+ if (existsSync(agentsPath)) {
149
+ const stats = lstatSync(agentsPath)
150
+ if (stats.isSymbolicLink()) {
151
+ agents = "symlink"
152
+ } else if (!stats.isFile()) {
153
+ agents = "not-a-file"
154
+ } else {
155
+ const result = updateAgents(readFileSync(agentsPath, "utf8"), options.enable)
156
+ agents = result.status
157
+ agentsChanged = result.changed
158
+ agentsText = result.text
159
+ }
160
+ }
161
+
162
+ if (!dryRun) {
163
+ if (configChanged) writeFileSync(configPath, nextText)
164
+ if (agentsChanged && agentsText !== null) writeFileSync(agentsPath, agentsText)
165
+ }
166
+
167
+ return {
168
+ configPath,
169
+ discovery,
170
+ changed: configChanged,
171
+ agents,
172
+ dryRun,
173
+ }
174
+ }
175
+
176
+ /** Print command usage. */
177
+ function printUsage() {
178
+ console.log(`Toggle Discovery in an existing CodeOps project.
179
+
180
+ Usage:
181
+ node scripts/discovery-toggle.mjs --root <repo> --enable-discovery [--dry-run]
182
+ node scripts/discovery-toggle.mjs --root <repo> --disable-discovery [--dry-run]
183
+
184
+ Options:
185
+ --root <dir> Repository root (defaults to the current directory)
186
+ --enable-discovery Set discovery.enabled to true
187
+ --disable-discovery Set discovery.enabled to false
188
+ --dry-run Show the result without writing files
189
+ -h, --help Show this help
190
+ `)
191
+ }
192
+
193
+ /** Run the command line. */
194
+ function main() {
195
+ const args = process.argv.slice(2)
196
+ if (args.includes("--help") || args.includes("-h")) {
197
+ printUsage()
198
+ return
199
+ }
200
+ const rootIndex = args.indexOf("--root")
201
+ const root = rootIndex >= 0 ? args[rootIndex + 1] : process.cwd()
202
+ if (root === undefined) {
203
+ throw new DiscoveryToggleError("--root requires a directory.")
204
+ }
205
+ const enable = args.includes("--enable-discovery")
206
+ const disable = args.includes("--disable-discovery")
207
+ if (enable === disable) {
208
+ throw new DiscoveryToggleError("Use exactly one of --enable-discovery or --disable-discovery.")
209
+ }
210
+ const result = toggleDiscovery({
211
+ root,
212
+ enable,
213
+ dryRun: args.includes("--dry-run"),
214
+ })
215
+ const action = enable ? "enabled" : "disabled"
216
+ console.log(
217
+ `${result.dryRun ? "[dry-run] " : ""}Discovery ${action} in ${result.configPath} ` +
218
+ `(chatMaxQuestions: ${result.discovery.chatMaxQuestions ?? "default 3"}).`,
219
+ )
220
+ console.log(`AGENTS.md: ${result.agents}.`)
221
+ if (result.dryRun && result.changed) {
222
+ console.log("No files were written.")
223
+ }
224
+ }
225
+
226
+ const invokedDirectly =
227
+ process.argv[1] !== undefined && resolve(process.argv[1]) === fileURLToPath(import.meta.url)
228
+
229
+ if (invokedDirectly) {
230
+ try {
231
+ main()
232
+ } catch (error) {
233
+ console.error(error instanceof Error ? error.message : String(error))
234
+ process.exitCode = 1
235
+ }
236
+ }
@@ -28,7 +28,7 @@ description: Analyze the current repository and create or refresh concise CodeOp
28
28
 
29
29
  ## Discovery switch
30
30
 
31
- `analyze-project` accepts `--with-discovery` and `--without-discovery`; passing both flags is a usage error. Both flags validate `codeops/codeops.json` against `schemas/codeops-config.schema.json` before changing anything: an invalid file stops the run with its schema errors and writes nothing (never discard malformed quality, routing, or metrics policy). Refuse to write when `codeops/` or `codeops/codeops.json` is a symlink or not a regular file; never write through a link.
31
+ `analyze-project` accepts `--with-discovery` and `--without-discovery`; passing both flags is a usage error. Existing projects can also use `setup-codeops --enable-discovery` / `--disable-discovery` for the config-only toggle. Both flags validate `codeops/codeops.json` against `schemas/codeops-config.schema.json` before changing anything: an invalid file stops the run with its schema errors and writes nothing (never discard malformed quality, routing, or metrics policy). Refuse to write when `codeops/` or `codeops/codeops.json` is a symlink or not a regular file; never write through a link.
32
32
 
33
33
  - `--with-discovery`: set `discovery.enabled: true`, keeping every other key. When no file exists, create the minimal AR-35 file with the layout resolved from `_shared/layout-convention.md` (`nested` → `codeops`, `flat` → `.`):
34
34
  ```json
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: setup-codeops
3
3
  description: >-
4
- Sets up or upgrades CodeOps in the current git repo. It scaffolds a fresh nested codeops/ layout, auto-migrates a flat requirements/ + plans/ layout, and detects obsolete traceability.json workflow-state graphs in an existing nested project. Use when the user says "setup-codeops", "/setup-codeops", "set up codeops", "initialize codeops", "upgrade existing codeops", "migrate to the nested layout", or "scaffold the codeops structure". Supports --dry-run and unattended --yes. Every migration previews deterministically, refuses unsafe or ambiguous apply, requires clean Git state for writes, and is idempotent. setup-codeops is the SOLE writer of the layout marker.
4
+ Sets up or upgrades CodeOps in the current git repo. It scaffolds a fresh nested codeops/ layout, auto-migrates a flat requirements/ + plans/ layout, and detects obsolete traceability.json workflow-state graphs in an existing nested project. Use when the user says "setup-codeops", "/setup-codeops", "set up codeops", "initialize codeops", "upgrade existing codeops", "migrate to the nested layout", or "scaffold the codeops structure". Supports --dry-run, unattended --yes, and --enable-discovery/--disable-discovery for existing projects. Every migration previews deterministically, refuses unsafe or ambiguous apply, requires clean Git state for writes, and is idempotent. setup-codeops is the SOLE writer of the layout marker.
5
5
  ---
6
6
 
7
7
  # CodeOps Layout Setup (`setup-codeops`)
@@ -21,8 +21,9 @@ it is the single source of truth for the layout. Do not re-encode paths here.
21
21
  - This skill sets up the *structure*. It never authors requirements, plans, or roadmaps — that is
22
22
  `make-requirements` / `make-plan` / `roadmap`, which then resolve paths via the convention doc.
23
23
  - A fresh scaffold enables Discovery by default (`discovery.enabled: true`,
24
- `chatMaxQuestions: 3`). Existing projects keep their current setting; enable it with
25
- `analyze-project --with-discovery` or by adding the setting manually.
24
+ `chatMaxQuestions: 3`). Existing projects can toggle it with
25
+ `setup-codeops --enable-discovery` / `--disable-discovery`; `analyze-project --with-discovery`
26
+ remains available for the same config change with guidance refresh.
26
27
 
27
28
  ## Dispatch — detect repo state, then branch
28
29
 
@@ -59,6 +60,31 @@ fresh scaffold should live in version control) and suggest `git init` first.
59
60
  | *(none)* | Interactive: scaffold creates the skeleton; either migration previews then asks for one confirmation before applying. |
60
61
  | `--dry-run` | Preview only — compute and show what would happen; change **nothing**. |
61
62
  | `--yes` | Apply an unblocked migration without confirmation, then verify it. Safety refusals still apply. |
63
+ | `--enable-discovery` | Enable Discovery in an existing project without re-scaffolding. Preserves every other config key. |
64
+ | `--disable-discovery` | Disable Discovery, keeping `chatMaxQuestions` and every other config key. |
65
+
66
+ `--enable-discovery` and `--disable-discovery` are mutually exclusive. Validate that before the
67
+ layout branch so a usage error never changes the repository.
68
+
69
+ ## Discovery toggle
70
+
71
+ After the layout branch (no-op, migration, or fresh scaffold), apply exactly one discovery flag
72
+ when present:
73
+
74
+ ```bash
75
+ node "${CODEOPS_PLUGIN_ROOT}/scripts/discovery-toggle.mjs" --root . --enable-discovery
76
+ node "${CODEOPS_PLUGIN_ROOT}/scripts/discovery-toggle.mjs" --root . --disable-discovery
77
+ ```
78
+
79
+ Add `--dry-run` to preview. The script validates the JSON, refuses symlinked `codeops/` or
80
+ `codeops/codeops.json`, preserves unrelated policy, and adds or removes the managed line
81
+ `- Discovery: enabled — question rounds open in the local Discovery page` when the
82
+ `CODEOPS-PROJECT` block exists. If `AGENTS.md` is absent or has no managed block, the config still
83
+ changes; report that guidance should be refreshed with `analyze-project`.
84
+
85
+ A fresh scaffold starts enabled. Passing `--disable-discovery` during scaffold turns it off; passing
86
+ `--enable-discovery` is idempotent. Without either flag, an existing project keeps its current
87
+ setting and a fresh scaffold stays enabled.
62
88
 
63
89
  ## Migration engines (delegation — do not re-implement)
64
90
 
@@ -86,6 +112,7 @@ deletion, and idempotency.
86
112
 
87
113
  - [scaffold.md](scaffold.md) — the minimal fresh-repo skeleton.
88
114
  - [migration.md](migration.md) — flat-layout and legacy workflow-state migration UX.
115
+ - `scripts/discovery-toggle.mjs` — deterministic config and managed-line toggle.
89
116
  - [_shared/layout-convention.md](../../_shared/layout-convention.md) — the layout/path/ID/marker source of truth.
90
117
 
91
118
  ## Grounded Options & Recommendations