opencode-codeops 1.8.1 → 1.10.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 +31 -0
- package/README.md +18 -0
- package/_shared/quality-profile.md +9 -0
- package/_shared/reasoning-effort.md +135 -0
- package/_shared/workspace-hygiene.md +53 -0
- package/agent-templates/codebase-scout.md +6 -0
- package/agent-templates/concurrency-auditor.md +7 -0
- package/agent-templates/design-challenger.md +6 -0
- package/agent-templates/domain-specialist-executor.md +6 -0
- package/agent-templates/domain-specialist-reviewer.md +6 -0
- package/agent-templates/financial-integrity-auditor.md +7 -0
- package/agent-templates/perf-auditor.md +6 -0
- package/agent-templates/phase-reviewer.md +6 -0
- package/agent-templates/plan-task-executor-opus.md +6 -0
- package/agent-templates/plan-task-executor.md +6 -0
- package/agent-templates/preflight-auditor.md +6 -0
- package/agent-templates/security-auditor.md +6 -0
- package/agent-templates/semantics-reviewer.md +7 -0
- package/agent-templates/spec-test-author.md +6 -0
- package/agents/concurrency-auditor.md +7 -0
- package/agents/correctness-reviewer.md +6 -0
- package/agents/demanding-executor.md +6 -0
- package/agents/design-challenger.md +6 -0
- package/agents/executor.md +6 -0
- package/agents/explorer.md +6 -0
- package/agents/financial-integrity-auditor.md +7 -0
- package/agents/performance-auditor.md +6 -0
- package/agents/preflight-auditor.md +6 -0
- package/agents/security-auditor.md +6 -0
- package/agents/semantics-reviewer.md +7 -0
- package/agents/spec-test-author.md +6 -0
- package/bin/lib/reasoning-effort.d.mts +85 -0
- package/bin/lib/reasoning-effort.mjs +331 -0
- package/bin/lib/tmp-hygiene.d.mts +38 -0
- package/bin/lib/tmp-hygiene.mjs +180 -0
- package/package.json +1 -1
- package/plugin/index.ts +146 -3
- package/scripts/__pycache__/install_agents.cpython-312.pyc +0 -0
- package/scripts/codeops_effort.py +216 -0
- package/scripts/fixtures/catalog-executor.golden.md +6 -0
- package/skills/analyze-project/SKILL.md +8 -0
- package/skills/clean-comments/SKILL.md +8 -0
- package/skills/exec-plan/SKILL.md +21 -0
- package/skills/exec-plan/execution-protocol.md +52 -7
- package/skills/git-commit/SKILL.md +11 -2
- package/skills/github-issues/SKILL.md +8 -0
- package/skills/grill-me/SKILL.md +18 -0
- package/skills/make-plan/SKILL.md +25 -0
- package/skills/make-plan/templates.md +2 -0
- package/skills/make-requirements/SKILL.md +18 -0
- package/skills/outcome-review/SKILL.md +8 -0
- package/skills/preflight/SKILL.md +18 -0
- package/skills/retro-requirements/SKILL.md +18 -0
- package/skills/roadmap/SKILL.md +8 -0
- package/skills/setup-codeops/SKILL.md +8 -0
- package/skills/setup-routing/SKILL.md +10 -0
- package/skills/setup-routing/routing.md +16 -1
- package/skills/techdocs/SKILL.md +8 -0
- package/skills/upgrade-plan/SKILL.md +18 -0
- package/standards/coding-standards-full.md +19 -0
- package/standards/coding-standards.md +6 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type declarations for the CodeOps adaptive reasoning-effort helper.
|
|
3
|
+
*
|
|
4
|
+
* The helper is plain JavaScript so `node --test` can exercise it directly;
|
|
5
|
+
* these declarations give the TypeScript plugin entry point typed access.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** A level from the four-level suggestion vocabulary. */
|
|
9
|
+
export type EffortLevel = "low" | "medium" | "high" | "max"
|
|
10
|
+
|
|
11
|
+
/** A reasoning value accepted by `routing.roles.<agent>.reasoning`. */
|
|
12
|
+
export type RoutingReasoning =
|
|
13
|
+
| "none"
|
|
14
|
+
| "minimal"
|
|
15
|
+
| "low"
|
|
16
|
+
| "medium"
|
|
17
|
+
| "high"
|
|
18
|
+
| "xhigh"
|
|
19
|
+
| "max"
|
|
20
|
+
|
|
21
|
+
/** The four levels a plan or skill may suggest. */
|
|
22
|
+
export declare const EFFORT_LEVELS: readonly EffortLevel[]
|
|
23
|
+
|
|
24
|
+
/** Check whether a value is one of the four suggestion levels. */
|
|
25
|
+
export declare function isEffortLevel(value: unknown): value is EffortLevel
|
|
26
|
+
|
|
27
|
+
/** The reasoning values accepted by routing role entries. */
|
|
28
|
+
export declare const ROUTING_REASONING_VALUES: readonly RoutingReasoning[]
|
|
29
|
+
|
|
30
|
+
/** Check whether a value is a valid routing reasoning entry. */
|
|
31
|
+
export declare function isRoutingReasoning(value: unknown): value is RoutingReasoning
|
|
32
|
+
|
|
33
|
+
/** Find the first valid dispatch marker across message text parts. */
|
|
34
|
+
export declare function findEffortMarker(texts: unknown): EffortLevel | undefined
|
|
35
|
+
|
|
36
|
+
/** Candidate sources for {@link resolveEffort}. */
|
|
37
|
+
export interface ResolveEffortInput {
|
|
38
|
+
marker?: unknown
|
|
39
|
+
session?: unknown
|
|
40
|
+
routing?: unknown
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Resolve the effective level from the explicit sources, most specific first. */
|
|
44
|
+
export declare function resolveEffort(
|
|
45
|
+
input?: ResolveEffortInput
|
|
46
|
+
): EffortLevel | RoutingReasoning | undefined
|
|
47
|
+
|
|
48
|
+
/** Compute the session state-file path inside the session temp directory. */
|
|
49
|
+
export declare function sessionEffortPath(sessionID?: string, base?: string): string
|
|
50
|
+
|
|
51
|
+
/** Parse session state-file text into a validated level. */
|
|
52
|
+
export declare function parseStateFile(text: string): EffortLevel | undefined
|
|
53
|
+
|
|
54
|
+
/** Read the session's reasoning-effort state file. */
|
|
55
|
+
export declare function readSessionEffort(
|
|
56
|
+
sessionID?: string,
|
|
57
|
+
base?: string
|
|
58
|
+
): EffortLevel | undefined
|
|
59
|
+
|
|
60
|
+
/** Read an agent's explicit reasoning entry from the project routing config. */
|
|
61
|
+
export declare function readRoutingReasoning(
|
|
62
|
+
config: unknown,
|
|
63
|
+
agent: unknown
|
|
64
|
+
): RoutingReasoning | undefined
|
|
65
|
+
|
|
66
|
+
/** Extract the model's runtime variant record, when the host exposes one. */
|
|
67
|
+
export declare function extractModelVariants(
|
|
68
|
+
model: unknown
|
|
69
|
+
): Record<string, unknown> | undefined
|
|
70
|
+
|
|
71
|
+
/** Check whether a model advertises reasoning support. */
|
|
72
|
+
export declare function modelSupportsReasoning(model: unknown): boolean
|
|
73
|
+
|
|
74
|
+
/** Merge the model's variant options for a level into the request options. */
|
|
75
|
+
export declare function applyEffort(
|
|
76
|
+
options: Record<string, unknown>,
|
|
77
|
+
level: unknown,
|
|
78
|
+
model: unknown
|
|
79
|
+
): Record<string, unknown>
|
|
80
|
+
|
|
81
|
+
/** Recursively merge plain objects into a new object. */
|
|
82
|
+
export declare function deepMergePlain(
|
|
83
|
+
target: unknown,
|
|
84
|
+
source: unknown
|
|
85
|
+
): Record<string, unknown>
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Adaptive reasoning-effort helper for CodeOps sessions.
|
|
4
|
+
*
|
|
5
|
+
* The plugin needs one place to answer a simple question per request: "which
|
|
6
|
+
* reasoning level should this request use, if any?" This module answers it
|
|
7
|
+
* without any framework dependency so `node --test` can exercise every edge:
|
|
8
|
+
*
|
|
9
|
+
* - marker scanning over message text parts;
|
|
10
|
+
* - source precedence (dispatch marker, session flag, routing default);
|
|
11
|
+
* - routing-config lookup with hostile-shape tolerance;
|
|
12
|
+
* - provider-option application through the model's own variant record; and
|
|
13
|
+
* - reading the per-session state file written by the skills.
|
|
14
|
+
*
|
|
15
|
+
* Every function is deliberately total: malformed input yields "no override",
|
|
16
|
+
* never a thrown error. The plugin hooks sit on the request path, where a
|
|
17
|
+
* crash would break the user's session, so safety beats strictness here.
|
|
18
|
+
*
|
|
19
|
+
* @module lib/reasoning-effort
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { lstatSync, readFileSync } from "node:fs"
|
|
23
|
+
import { join } from "node:path"
|
|
24
|
+
|
|
25
|
+
import { sessionTmpDir } from "./tmp-hygiene.mjs"
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The four levels a plan or skill may suggest.
|
|
29
|
+
*
|
|
30
|
+
* These are the only values accepted in dispatch markers, session flags, and
|
|
31
|
+
* plan suggestions. Routing policy is project configuration and may name the
|
|
32
|
+
* wider provider enum instead.
|
|
33
|
+
*/
|
|
34
|
+
export const EFFORT_LEVELS = Object.freeze(["low", "medium", "high", "max"])
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The reasoning values accepted by `routing.roles.<agent>.reasoning` in the
|
|
38
|
+
* project configuration.
|
|
39
|
+
*
|
|
40
|
+
* The list mirrors the CodeOps config schema, which passes provider-native
|
|
41
|
+
* values through so a project can ask for `minimal` or `xhigh` even though the
|
|
42
|
+
* suggestion vocabulary only offers the four common levels.
|
|
43
|
+
*/
|
|
44
|
+
export const ROUTING_REASONING_VALUES = Object.freeze([
|
|
45
|
+
"none",
|
|
46
|
+
"minimal",
|
|
47
|
+
"low",
|
|
48
|
+
"medium",
|
|
49
|
+
"high",
|
|
50
|
+
"xhigh",
|
|
51
|
+
"max",
|
|
52
|
+
])
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Largest state file accepted by {@link readSessionEffort}.
|
|
56
|
+
*
|
|
57
|
+
* The real file is a few dozen bytes. The cap keeps a hostile or corrupt file
|
|
58
|
+
* from being slurped into memory if the temp directory is ever tampered with.
|
|
59
|
+
*/
|
|
60
|
+
const MAX_STATE_FILE_BYTES = 4096
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* One standalone marker line, anchored to the whole line.
|
|
64
|
+
*
|
|
65
|
+
* The marker is intentionally different from the human-readable plan line
|
|
66
|
+
* `> **Reasoning**: ...`, so quoting a plan can never act as a machine
|
|
67
|
+
* directive. Whitespace inside the brackets is tolerated; text before or after
|
|
68
|
+
* is not.
|
|
69
|
+
*/
|
|
70
|
+
const MARKER_PATTERN = /^\[codeops-effort:\s*(low|medium|high|max)\]\s*$/
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Check whether a value is one of the four suggestion levels.
|
|
74
|
+
*
|
|
75
|
+
* @param value - Value to inspect
|
|
76
|
+
* @returns True only for `"low"`, `"medium"`, `"high"`, or `"max"`
|
|
77
|
+
*
|
|
78
|
+
* @example
|
|
79
|
+
* isEffortLevel("high") // true
|
|
80
|
+
* isEffortLevel("xhigh") // false
|
|
81
|
+
*/
|
|
82
|
+
export function isEffortLevel(value) {
|
|
83
|
+
return typeof value === "string" && EFFORT_LEVELS.includes(value)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Check whether a value is a valid routing reasoning entry.
|
|
88
|
+
*
|
|
89
|
+
* @param value - Value to inspect
|
|
90
|
+
* @returns True for any value in the routing enum
|
|
91
|
+
*/
|
|
92
|
+
export function isRoutingReasoning(value) {
|
|
93
|
+
return typeof value === "string" && ROUTING_REASONING_VALUES.includes(value)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Find the first valid dispatch marker across a list of message text parts.
|
|
98
|
+
*
|
|
99
|
+
* Only string entries are inspected; non-string entries (tool parts, images,
|
|
100
|
+
* malformed structures) are skipped so a hostile part can never break the
|
|
101
|
+
* request path. Lines are split on `\n`, `\r\n`, and `\r`, then trimmed before
|
|
102
|
+
* the anchored pattern is applied, so surrounding whitespace is tolerated.
|
|
103
|
+
*
|
|
104
|
+
* @param texts - Candidate text parts (usually the text of a message's parts)
|
|
105
|
+
* @returns The first valid level, or `undefined` when none is present
|
|
106
|
+
*
|
|
107
|
+
* @example
|
|
108
|
+
* findEffortMarker(["run the task", "[codeops-effort: medium]"]) // "medium"
|
|
109
|
+
*/
|
|
110
|
+
export function findEffortMarker(texts) {
|
|
111
|
+
if (!Array.isArray(texts)) return undefined
|
|
112
|
+
for (const text of texts) {
|
|
113
|
+
if (typeof text !== "string") continue
|
|
114
|
+
for (const line of text.split(/\r\n|\r|\n/)) {
|
|
115
|
+
const match = MARKER_PATTERN.exec(line.trim())
|
|
116
|
+
if (match) return match[1]
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return undefined
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Resolve the effective level from the three explicit sources.
|
|
124
|
+
*
|
|
125
|
+
* Precedence is "most specific wins": a dispatch marker beats a session
|
|
126
|
+
* flag, which beats a routing default. Each source is validated before it is
|
|
127
|
+
* accepted, so an invalid value is ignored rather than propagated. A
|
|
128
|
+
* non-object argument yields `undefined` instead of throwing.
|
|
129
|
+
*
|
|
130
|
+
* @param input - Candidate sources; all optional
|
|
131
|
+
* @returns The first valid value, or `undefined` when no source applies
|
|
132
|
+
*
|
|
133
|
+
* @example
|
|
134
|
+
* resolveEffort({ session: "high", routing: "low" }) // "high"
|
|
135
|
+
*/
|
|
136
|
+
export function resolveEffort(input) {
|
|
137
|
+
if (!isPlainObject(input)) return undefined
|
|
138
|
+
const { marker, session, routing } = input
|
|
139
|
+
if (isEffortLevel(marker)) return marker
|
|
140
|
+
if (isEffortLevel(session)) return session
|
|
141
|
+
if (isRoutingReasoning(routing)) return routing
|
|
142
|
+
return undefined
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Compute the session state-file path inside the session temp directory.
|
|
147
|
+
*
|
|
148
|
+
* @param sessionID - Session identifier
|
|
149
|
+
* @param base - Base temp directory (injectable for tests)
|
|
150
|
+
* @returns Absolute path to `reasoning-effort.json` for the session
|
|
151
|
+
*/
|
|
152
|
+
export function sessionEffortPath(sessionID, base) {
|
|
153
|
+
return join(sessionTmpDir(sessionID, base), "reasoning-effort.json")
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Parse session state-file text into a validated level.
|
|
158
|
+
*
|
|
159
|
+
* The expected payload is `{"schema":1,"reasoning":"<level>"}` with any
|
|
160
|
+
* unknown keys ignored. Anything else — malformed JSON, the wrong schema, a
|
|
161
|
+
* level outside the four-level allowlist — yields `undefined`.
|
|
162
|
+
*
|
|
163
|
+
* @param text - Raw file contents
|
|
164
|
+
* @returns The validated level, or `undefined`
|
|
165
|
+
*/
|
|
166
|
+
export function parseStateFile(text) {
|
|
167
|
+
try {
|
|
168
|
+
const value = JSON.parse(text)
|
|
169
|
+
if (!isPlainObject(value)) return undefined
|
|
170
|
+
if (value.schema !== 1) return undefined
|
|
171
|
+
return isEffortLevel(value.reasoning) ? value.reasoning : undefined
|
|
172
|
+
} catch {
|
|
173
|
+
return undefined
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Read the session's reasoning-effort state file.
|
|
179
|
+
*
|
|
180
|
+
* The file is written by `scripts/codeops_effort.py` for `--auto-effort`
|
|
181
|
+
* runs. Only a small regular file is accepted: a missing file, a non-regular
|
|
182
|
+
* file (symlinks are never followed), an oversized file, and any read or parse
|
|
183
|
+
* failure all mean "no session effort" and return `undefined`.
|
|
184
|
+
*
|
|
185
|
+
* @param sessionID - Session identifier
|
|
186
|
+
* @param base - Base temp directory (injectable for tests)
|
|
187
|
+
* @returns The session level, or `undefined`
|
|
188
|
+
*/
|
|
189
|
+
export function readSessionEffort(sessionID, base) {
|
|
190
|
+
try {
|
|
191
|
+
const path = sessionEffortPath(sessionID, base)
|
|
192
|
+
const info = lstatSync(path)
|
|
193
|
+
if (!info.isFile()) return undefined
|
|
194
|
+
if (info.size > MAX_STATE_FILE_BYTES) return undefined
|
|
195
|
+
return parseStateFile(readFileSync(path, "utf-8"))
|
|
196
|
+
} catch {
|
|
197
|
+
return undefined
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Read an agent's explicit reasoning entry from the project routing config.
|
|
203
|
+
*
|
|
204
|
+
* Only an own `routing.roles.<agent>.reasoning` property counts; unknown
|
|
205
|
+
* agents, missing sections, inherited properties, and malformed shapes all
|
|
206
|
+
* return `undefined` instead of throwing.
|
|
207
|
+
*
|
|
208
|
+
* @param config - Parsed `codeops/codeops.json` content (any shape)
|
|
209
|
+
* @param agent - Dispatching agent name
|
|
210
|
+
* @returns The configured routing value, or `undefined`
|
|
211
|
+
*/
|
|
212
|
+
export function readRoutingReasoning(config, agent) {
|
|
213
|
+
if (!isPlainObject(config)) return undefined
|
|
214
|
+
const routing = config.routing
|
|
215
|
+
if (!isPlainObject(routing)) return undefined
|
|
216
|
+
const roles = routing.roles
|
|
217
|
+
if (!isPlainObject(roles)) return undefined
|
|
218
|
+
if (typeof agent !== "string" || agent.length === 0) return undefined
|
|
219
|
+
if (!Object.prototype.hasOwnProperty.call(roles, agent)) return undefined
|
|
220
|
+
const entry = roles[agent]
|
|
221
|
+
if (!isPlainObject(entry)) return undefined
|
|
222
|
+
return isRoutingReasoning(entry.reasoning) ? entry.reasoning : undefined
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Extract the model's runtime variant record, when the host exposes one.
|
|
227
|
+
*
|
|
228
|
+
* The installed SDK type does not declare `variants`, but the running host
|
|
229
|
+
* attaches it to every model. The `in` check keeps this graceful when the
|
|
230
|
+
* property is absent, and a cast is never used.
|
|
231
|
+
*
|
|
232
|
+
* @param model - Model object from the hook input
|
|
233
|
+
* @returns The variants record when it is a plain object, else `undefined`
|
|
234
|
+
*/
|
|
235
|
+
export function extractModelVariants(model) {
|
|
236
|
+
if (!isPlainObject(model)) return undefined
|
|
237
|
+
if (!("variants" in model)) return undefined
|
|
238
|
+
return isPlainObject(model.variants) ? model.variants : undefined
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Check whether a model advertises reasoning support.
|
|
243
|
+
*
|
|
244
|
+
* @param model - Model object from the hook input
|
|
245
|
+
* @returns True only when `capabilities.reasoning` is exactly `true`
|
|
246
|
+
*/
|
|
247
|
+
export function modelSupportsReasoning(model) {
|
|
248
|
+
if (!isPlainObject(model)) return false
|
|
249
|
+
const capabilities = model.capabilities
|
|
250
|
+
if (!isPlainObject(capabilities)) return false
|
|
251
|
+
return capabilities.reasoning === true
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Merge the model's variant options for a level into the request options.
|
|
256
|
+
*
|
|
257
|
+
* The runtime model carries a `variants` record whose entries are the exact
|
|
258
|
+
* provider options for each level (for example `reasoningEffort`, or a nested
|
|
259
|
+
* `reasoning.effort`). This function is the only place that mapping is
|
|
260
|
+
* consumed, so the plugin never hardcodes a provider key. The function is
|
|
261
|
+
* pure: it returns a new object when a change applies and the original object
|
|
262
|
+
* reference otherwise.
|
|
263
|
+
*
|
|
264
|
+
* @param options - Current provider options
|
|
265
|
+
* @param level - Candidate level from {@link resolveEffort}
|
|
266
|
+
* @param model - Model object from the hook input
|
|
267
|
+
* @returns The original options, or a new merged object when a change applies
|
|
268
|
+
*/
|
|
269
|
+
export function applyEffort(options, level, model) {
|
|
270
|
+
if (!isRoutingReasoning(level)) return options
|
|
271
|
+
if (!modelSupportsReasoning(model)) return options
|
|
272
|
+
|
|
273
|
+
const variants = extractModelVariants(model)
|
|
274
|
+
if (variants !== undefined) {
|
|
275
|
+
if (!Object.prototype.hasOwnProperty.call(variants, level)) return options
|
|
276
|
+
const variantOptions = variants[level]
|
|
277
|
+
if (!isPlainObject(variantOptions)) return options
|
|
278
|
+
return deepMergePlain(options, variantOptions)
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return { ...options, reasoningEffort: level }
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Recursively merge plain objects into a new object.
|
|
286
|
+
*
|
|
287
|
+
* Values that are not plain objects — arrays, class instances, primitives —
|
|
288
|
+
* are replaced, not merged. Neither input is mutated. The result is created
|
|
289
|
+
* with data properties, so a hostile `__proto__` key in a variant can never
|
|
290
|
+
* change the result's prototype chain.
|
|
291
|
+
*
|
|
292
|
+
* @param target - Base object
|
|
293
|
+
* @param source - Overrides to merge on top
|
|
294
|
+
* @returns A new merged plain object
|
|
295
|
+
*/
|
|
296
|
+
export function deepMergePlain(target, source) {
|
|
297
|
+
const result = isPlainObject(target) ? { ...target } : {}
|
|
298
|
+
if (!isPlainObject(source)) return result
|
|
299
|
+
|
|
300
|
+
for (const key of Object.keys(source)) {
|
|
301
|
+
const incoming = source[key]
|
|
302
|
+
const current = Object.prototype.hasOwnProperty.call(result, key)
|
|
303
|
+
? result[key]
|
|
304
|
+
: undefined
|
|
305
|
+
const merged =
|
|
306
|
+
isPlainObject(incoming) && isPlainObject(current)
|
|
307
|
+
? deepMergePlain(current, incoming)
|
|
308
|
+
: incoming
|
|
309
|
+
Object.defineProperty(result, key, {
|
|
310
|
+
value: merged,
|
|
311
|
+
writable: true,
|
|
312
|
+
enumerable: true,
|
|
313
|
+
configurable: true,
|
|
314
|
+
})
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
return result
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Check whether a value is a plain object (not an array, class instance, or
|
|
322
|
+
* `null`).
|
|
323
|
+
*
|
|
324
|
+
* @param value - Value to inspect
|
|
325
|
+
* @returns True for `{}`-shaped objects and objects with a null prototype
|
|
326
|
+
*/
|
|
327
|
+
function isPlainObject(value) {
|
|
328
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false
|
|
329
|
+
const prototype = Object.getPrototypeOf(value)
|
|
330
|
+
return prototype === Object.prototype || prototype === null
|
|
331
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type declarations for the CodeOps temporary-directory lifecycle helper.
|
|
3
|
+
*
|
|
4
|
+
* The helper is plain JavaScript so `node --test` can exercise it directly;
|
|
5
|
+
* these declarations give the TypeScript plugin entry point typed access.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Age after which a session directory is treated as abandoned. */
|
|
9
|
+
export declare const DEFAULT_MAX_AGE_MS: number
|
|
10
|
+
|
|
11
|
+
/** Directory name used when no usable session identifier exists. */
|
|
12
|
+
export declare const SHARED_SESSION_NAME: string
|
|
13
|
+
|
|
14
|
+
/** Resolve the CodeOps-owned temp root under a base temp directory. */
|
|
15
|
+
export declare function codeopsTmpRoot(base?: string): string
|
|
16
|
+
|
|
17
|
+
/** Reduce a session identifier to a safe single path segment. */
|
|
18
|
+
export declare function sanitizeSessionId(sessionID?: string): string
|
|
19
|
+
|
|
20
|
+
/** Compute the temp directory for one session. */
|
|
21
|
+
export declare function sessionTmpDir(sessionID?: string, base?: string): string
|
|
22
|
+
|
|
23
|
+
/** Create the session temp directory when it does not exist yet. */
|
|
24
|
+
export declare function ensureSessionTmpDir(sessionID?: string, base?: string): string
|
|
25
|
+
|
|
26
|
+
/** Inputs for a stale-directory sweep. */
|
|
27
|
+
export interface CleanStaleTmpDirsOptions {
|
|
28
|
+
sessionID?: string
|
|
29
|
+
base?: string
|
|
30
|
+
maxAgeMs?: number
|
|
31
|
+
now?: number
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Remove session directories abandoned by interrupted or crashed runs. */
|
|
35
|
+
export declare function cleanStaleTmpDirs(options?: CleanStaleTmpDirsOptions): string[]
|
|
36
|
+
|
|
37
|
+
/** Remove exactly one session's temp directory. */
|
|
38
|
+
export declare function removeSessionTmpDir(sessionID?: string, base?: string): boolean
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Temporary-directory lifecycle for CodeOps sessions.
|
|
4
|
+
*
|
|
5
|
+
* The workspace-hygiene protocol asks every skill and agent to put scratch
|
|
6
|
+
* files under `$CODEOPS_TMPDIR` and delete them when the run completes. This
|
|
7
|
+
* module provides the safety net around that protocol:
|
|
8
|
+
*
|
|
9
|
+
* - it computes the per-session directory the plugin exports as
|
|
10
|
+
* `$CODEOPS_TMPDIR`, under a CodeOps-owned root inside the OS temp directory;
|
|
11
|
+
* - it sweeps directories abandoned by interrupted runs once they are older
|
|
12
|
+
* than a safety cap; and
|
|
13
|
+
* - it removes exactly one session's directory when that session is deleted.
|
|
14
|
+
*
|
|
15
|
+
* Safety is the whole point: every path derives from a sanitized session
|
|
16
|
+
* identifier, the sweep walks only the CodeOps-owned root, symlinked entries
|
|
17
|
+
* are treated as foreign, and cleanup is best effort so a failure never breaks
|
|
18
|
+
* a session.
|
|
19
|
+
*
|
|
20
|
+
* @module lib/tmp-hygiene
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { lstatSync, mkdirSync, readdirSync, rmSync } from "node:fs"
|
|
24
|
+
import { tmpdir } from "node:os"
|
|
25
|
+
import { join, resolve, sep } from "node:path"
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Age after which a session directory is treated as abandoned.
|
|
29
|
+
*
|
|
30
|
+
* A crashed or interrupted run leaves its directory behind; once it is this
|
|
31
|
+
* old, no live session can still need it, so the next sweep may remove it.
|
|
32
|
+
*/
|
|
33
|
+
export const DEFAULT_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000
|
|
34
|
+
|
|
35
|
+
/** Directory name used when no usable session identifier exists. */
|
|
36
|
+
export const SHARED_SESSION_NAME = "shared"
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Resolve the CodeOps-owned temp root under a base temp directory.
|
|
40
|
+
*
|
|
41
|
+
* The plugin owns this subtree and nothing else under the system temp
|
|
42
|
+
* directory; keeping all CodeOps scratch under one root is what makes the
|
|
43
|
+
* automatic sweep safe.
|
|
44
|
+
*
|
|
45
|
+
* @param base - Base temp directory (injectable for tests)
|
|
46
|
+
* @returns Path to the CodeOps temp root
|
|
47
|
+
*/
|
|
48
|
+
export function codeopsTmpRoot(base = tmpdir()) {
|
|
49
|
+
return join(base, "opencode", "codeops")
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Reduce a session identifier to a safe single path segment.
|
|
54
|
+
*
|
|
55
|
+
* Session identifiers come from the host and must never influence the path
|
|
56
|
+
* shape: every character outside `A-Z`, `a-z`, `0-9`, `_`, and `-` is removed,
|
|
57
|
+
* so a value like `../../etc` cannot escape the temp root. An empty result
|
|
58
|
+
* falls back to a shared directory name.
|
|
59
|
+
*
|
|
60
|
+
* @param sessionID - Raw session identifier (may be missing)
|
|
61
|
+
* @returns A safe directory name
|
|
62
|
+
*/
|
|
63
|
+
export function sanitizeSessionId(sessionID) {
|
|
64
|
+
const cleaned = String(sessionID ?? "").replace(/[^A-Za-z0-9_-]/g, "")
|
|
65
|
+
return cleaned.length > 0 ? cleaned : SHARED_SESSION_NAME
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Compute the temp directory for one session.
|
|
70
|
+
*
|
|
71
|
+
* @param sessionID - Raw session identifier (may be missing)
|
|
72
|
+
* @param base - Base temp directory (injectable for tests)
|
|
73
|
+
* @returns Path to the session's temp directory
|
|
74
|
+
*/
|
|
75
|
+
export function sessionTmpDir(sessionID, base = tmpdir()) {
|
|
76
|
+
return join(codeopsTmpRoot(base), sanitizeSessionId(sessionID))
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Create the session temp directory when it does not exist yet.
|
|
81
|
+
*
|
|
82
|
+
* Called while exporting the shell environment, so the directory is ready
|
|
83
|
+
* before the first command needs it.
|
|
84
|
+
*
|
|
85
|
+
* @param sessionID - Raw session identifier (may be missing)
|
|
86
|
+
* @param base - Base temp directory (injectable for tests)
|
|
87
|
+
* @returns Path to the session's temp directory
|
|
88
|
+
* @throws {Error} When the directory cannot be created
|
|
89
|
+
*/
|
|
90
|
+
export function ensureSessionTmpDir(sessionID, base = tmpdir()) {
|
|
91
|
+
const dir = sessionTmpDir(sessionID, base)
|
|
92
|
+
mkdirSync(dir, { recursive: true })
|
|
93
|
+
return dir
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Remove session directories abandoned by interrupted or crashed runs.
|
|
98
|
+
*
|
|
99
|
+
* Only direct children of the CodeOps temp root are considered. Fresh
|
|
100
|
+
* directories, the current session's directory, plain files, and symlinks are
|
|
101
|
+
* left untouched; a symlink is never followed, so a link planted inside the
|
|
102
|
+
* root can never redirect a delete outside it. Failures are swallowed because
|
|
103
|
+
* a sweep is maintenance, not a task requirement.
|
|
104
|
+
*
|
|
105
|
+
* @param options - Sweep inputs
|
|
106
|
+
* @param options.sessionID - Current session identifier to preserve
|
|
107
|
+
* @param options.base - Base temp directory (injectable for tests)
|
|
108
|
+
* @param options.maxAgeMs - Abandonment age in milliseconds
|
|
109
|
+
* @param options.now - Current time in milliseconds (injectable for tests)
|
|
110
|
+
* @returns Paths of the directories that were removed
|
|
111
|
+
*/
|
|
112
|
+
export function cleanStaleTmpDirs({
|
|
113
|
+
sessionID,
|
|
114
|
+
base = tmpdir(),
|
|
115
|
+
maxAgeMs = DEFAULT_MAX_AGE_MS,
|
|
116
|
+
now = Date.now(),
|
|
117
|
+
} = {}) {
|
|
118
|
+
const root = codeopsTmpRoot(base)
|
|
119
|
+
const keep = resolve(sessionTmpDir(sessionID, base))
|
|
120
|
+
const removed = []
|
|
121
|
+
|
|
122
|
+
let entries
|
|
123
|
+
try {
|
|
124
|
+
entries = readdirSync(root, { withFileTypes: true })
|
|
125
|
+
} catch {
|
|
126
|
+
return removed
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
for (const entry of entries) {
|
|
130
|
+
// Directory entries are lstat-based, so a symlink to a directory reports
|
|
131
|
+
// as a symlink and is skipped here.
|
|
132
|
+
if (!entry.isDirectory()) continue
|
|
133
|
+
|
|
134
|
+
const full = join(root, entry.name)
|
|
135
|
+
if (resolve(full) === keep) continue
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
const info = lstatSync(full)
|
|
139
|
+
if (!info.isDirectory()) continue
|
|
140
|
+
if (now - info.mtimeMs < maxAgeMs) continue
|
|
141
|
+
rmSync(full, { recursive: true, force: true })
|
|
142
|
+
removed.push(full)
|
|
143
|
+
} catch {
|
|
144
|
+
// Best effort: a directory that vanished mid-sweep is already gone.
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return removed
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Remove exactly one session's temp directory.
|
|
153
|
+
*
|
|
154
|
+
* Used when a session is deleted: its scratch can no longer be needed. The
|
|
155
|
+
* removal refuses anything that is not a regular directory strictly inside
|
|
156
|
+
* the CodeOps temp root, so a symlink or an unexpected path is left alone.
|
|
157
|
+
*
|
|
158
|
+
* @param sessionID - Session identifier whose directory should be removed
|
|
159
|
+
* @param base - Base temp directory (injectable for tests)
|
|
160
|
+
* @returns True when the directory was removed
|
|
161
|
+
*/
|
|
162
|
+
export function removeSessionTmpDir(sessionID, base = tmpdir()) {
|
|
163
|
+
const root = resolve(codeopsTmpRoot(base))
|
|
164
|
+
const dir = sessionTmpDir(sessionID, base)
|
|
165
|
+
|
|
166
|
+
if (!resolve(dir).startsWith(root + sep)) return false
|
|
167
|
+
|
|
168
|
+
try {
|
|
169
|
+
if (!lstatSync(dir).isDirectory()) return false
|
|
170
|
+
} catch {
|
|
171
|
+
return false
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
try {
|
|
175
|
+
rmSync(dir, { recursive: true, force: true })
|
|
176
|
+
return true
|
|
177
|
+
} catch {
|
|
178
|
+
return false
|
|
179
|
+
}
|
|
180
|
+
}
|