opencode-harness-kit 0.1.0 → 0.2.1
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/GLOBAL.md +1 -1
- package/README.md +90 -10
- package/agents/build.md +2 -2
- package/bin/oc-harness.mjs +148 -42
- package/dist/index.js +197 -64
- package/package.json +8 -6
- package/profiles/index.json +18 -0
- package/profiles/muse-split-free.json +64 -0
- package/profiles/schema.json +29 -0
- package/profiles/space-bunny-free.json +1 -1
- package/skills/space-finish/SKILL.md +4 -14
- package/src/config.js +40 -8
- package/src/driver/run.mjs +237 -0
- package/src/profile.js +269 -14
- package/src/rules.js +3 -18
- package/src/tasks.js +248 -0
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// `oc-harness run` -- drive the space-plan -> build -> review -> finish loop
|
|
3
|
+
// without a human at the keyboard.
|
|
4
|
+
//
|
|
5
|
+
// The four skills already know how to do their own job. What was missing was
|
|
6
|
+
// the thing that decides *which* one runs next and *when to stop*. That is
|
|
7
|
+
// here, and it is deliberately small: it reads `.space/tasks.md`, hands one
|
|
8
|
+
// task at a time to `opencode run --agent <role>`, records the real verify
|
|
9
|
+
// output, and stops.
|
|
10
|
+
//
|
|
11
|
+
// What it will not do, ever:
|
|
12
|
+
// - push, open a PR, or cut a release. Publishing is a human decision, and an
|
|
13
|
+
// unattended loop that can do it is an unattended loop that will.
|
|
14
|
+
// - retry a task it already blocked, or start a task with no verify command.
|
|
15
|
+
// A task it cannot check is a task it cannot claim is done.
|
|
16
|
+
// - loop forever. Every stop condition is explicit and every one of them is
|
|
17
|
+
// reported, so a run that ends is never a mystery.
|
|
18
|
+
|
|
19
|
+
import { spawnSync } from "node:child_process"
|
|
20
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs"
|
|
21
|
+
import { join } from "node:path"
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
parseTasks,
|
|
25
|
+
nextPending,
|
|
26
|
+
markTask,
|
|
27
|
+
taskPrompt,
|
|
28
|
+
summarize,
|
|
29
|
+
isBlocked,
|
|
30
|
+
} from "../tasks.js"
|
|
31
|
+
|
|
32
|
+
const TASKS_PATH = join(".space", "tasks.md")
|
|
33
|
+
|
|
34
|
+
const bold = (s) => `\x1b[1m${s}\x1b[0m`
|
|
35
|
+
const stamp = (s) => `\x1b[2m${s}\x1b[0m`
|
|
36
|
+
const green = (s) => `\x1b[32m${s}\x1b[0m`
|
|
37
|
+
const yellow = (s) => `\x1b[33m${s}\x1b[0m`
|
|
38
|
+
const red = (s) => `\x1b[31m${s}\x1b[0m`
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Run one command to completion and report what happened.
|
|
42
|
+
*
|
|
43
|
+
* Never throws: the caller has to be able to write the failure into the task
|
|
44
|
+
* file, which is the only record anyone gets of why a task did not land.
|
|
45
|
+
*/
|
|
46
|
+
export function sh(command, { cwd, timeoutMs = 900_000 } = {}) {
|
|
47
|
+
const started = Date.now()
|
|
48
|
+
const res = spawnSync(command, {
|
|
49
|
+
shell: true,
|
|
50
|
+
cwd,
|
|
51
|
+
timeout: timeoutMs,
|
|
52
|
+
encoding: "utf8",
|
|
53
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
54
|
+
env: { ...process.env, NO_COLOR: process.env.NO_COLOR ?? "" },
|
|
55
|
+
})
|
|
56
|
+
const stdout = `${res.stdout ?? ""}${res.stderr ?? ""}`.trimEnd()
|
|
57
|
+
return {
|
|
58
|
+
ok: res.status === 0,
|
|
59
|
+
status: res.status,
|
|
60
|
+
// A spawn that never started has no stderr; say so instead of implying the
|
|
61
|
+
// command ran and said nothing.
|
|
62
|
+
output: stdout || (res.error ? String(res.error.message) : ""),
|
|
63
|
+
seconds: Math.round((Date.now() - started) / 1000),
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The tail of a verify run -- the last few lines are the verdict. */
|
|
68
|
+
export function tail(text, n = 12) {
|
|
69
|
+
const lines = String(text).split("\n").filter((l) => l.trim())
|
|
70
|
+
return lines.slice(-n).join("\n")
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Ask `opencode` to do the task. Returns the transcript so a human can read what
|
|
75
|
+
* the agent actually did rather than being told about it.
|
|
76
|
+
*/
|
|
77
|
+
export function invokeAgent(role, prompt, { cwd, timeoutMs }) {
|
|
78
|
+
const res = spawnSync(
|
|
79
|
+
"opencode",
|
|
80
|
+
["run", "--agent", role, "--format", "json", prompt],
|
|
81
|
+
{
|
|
82
|
+
cwd,
|
|
83
|
+
encoding: "utf8",
|
|
84
|
+
timeout: timeoutMs,
|
|
85
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
86
|
+
},
|
|
87
|
+
)
|
|
88
|
+
return {
|
|
89
|
+
ok: res.status === 0,
|
|
90
|
+
status: res.status,
|
|
91
|
+
output: `${res.stdout ?? ""}${res.stderr ?? ""}`.trimEnd(),
|
|
92
|
+
error: res.error ? String(res.error.message) : "",
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The agent's own instructions for one task, plus what the loop expects back. */
|
|
97
|
+
export function buildPrompt(task) {
|
|
98
|
+
return [
|
|
99
|
+
`You are the space-build agent. Do exactly this one task and stop.`,
|
|
100
|
+
``,
|
|
101
|
+
taskPrompt(task),
|
|
102
|
+
``,
|
|
103
|
+
task.verify
|
|
104
|
+
? `When the change is done, run this command yourself: ${task.verify}`
|
|
105
|
+
: `There is no verify command for this task. Do the work, say what you changed, and stop.`,
|
|
106
|
+
``,
|
|
107
|
+
`Rules for this run:`,
|
|
108
|
+
` - Change only the files named above. If the task is wrong or blocked, say so and stop.`,
|
|
109
|
+
` - Never run git push, never create a PR, never publish. A human does that.`,
|
|
110
|
+
` - Report the real output of the verify command. Never invent a passing result.`,
|
|
111
|
+
].join("\n")
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Run the loop.
|
|
116
|
+
*
|
|
117
|
+
* Stops at the first of: nothing pending, nothing verifiable, a blocked task, a
|
|
118
|
+
* failing verify, the round cap, or the time budget. Whichever it was gets
|
|
119
|
+
* printed, so a run that stops early always says why.
|
|
120
|
+
*/
|
|
121
|
+
export function runLoop({
|
|
122
|
+
cwd = process.cwd(),
|
|
123
|
+
role = "build",
|
|
124
|
+
maxRounds = 20,
|
|
125
|
+
budgetMinutes = 120,
|
|
126
|
+
runAgent = invokeAgent,
|
|
127
|
+
runVerify = sh,
|
|
128
|
+
} = {}) {
|
|
129
|
+
const started = Date.now()
|
|
130
|
+
const budgetMs = budgetMinutes * 60_000
|
|
131
|
+
const log = []
|
|
132
|
+
|
|
133
|
+
if (!existsSync(join(cwd, TASKS_PATH))) {
|
|
134
|
+
return stop(log, {
|
|
135
|
+
reason: `no task file at ${TASKS_PATH}. Run space-plan first -- this loop needs a plan to walk.`,
|
|
136
|
+
code: 1,
|
|
137
|
+
})
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
let text = readFileSync(join(cwd, TASKS_PATH), "utf8")
|
|
141
|
+
let rounds = 0
|
|
142
|
+
|
|
143
|
+
for (;;) {
|
|
144
|
+
const parsed = parseTasks(text)
|
|
145
|
+
const task = nextPending(parsed)
|
|
146
|
+
|
|
147
|
+
if (!task) {
|
|
148
|
+
const s = summarize(parsed)
|
|
149
|
+
return stop(log, {
|
|
150
|
+
reason:
|
|
151
|
+
s.blocked > 0
|
|
152
|
+
? `nothing left to run: ${s.done} done, ${s.blocked} blocked.`
|
|
153
|
+
: `every task is done (${s.done}/${s.total}).`,
|
|
154
|
+
code: s.blocked > 0 ? 2 : 0,
|
|
155
|
+
summary: s,
|
|
156
|
+
})
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (isBlocked(task)) {
|
|
160
|
+
return stop(log, { reason: `task is blocked and needs a person: ${task.title}`, code: 2 })
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (!task.verify) {
|
|
164
|
+
text = markTask(text, task, {
|
|
165
|
+
status: "blocked",
|
|
166
|
+
verifyOutput: "blocked by the loop: this task has no verify command, so nothing could prove it finished.",
|
|
167
|
+
})
|
|
168
|
+
writeFileSync(join(cwd, TASKS_PATH), text)
|
|
169
|
+
return stop(log, {
|
|
170
|
+
reason: `"${task.title}" has no verify command. It cannot be checked, so it cannot be run unattended. Add one, or do it yourself.`,
|
|
171
|
+
code: 2,
|
|
172
|
+
})
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
rounds++
|
|
176
|
+
if (rounds > maxRounds) {
|
|
177
|
+
return stop(log, { reason: `stopped at the round cap (${maxRounds}). Raise it with --max-rounds, or run fewer tasks.`, code: 2 })
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const elapsed = Date.now() - started
|
|
181
|
+
if (elapsed >= budgetMs) {
|
|
182
|
+
return stop(log, { reason: `stopped at the time budget (${budgetMinutes} min, ${Math.round(elapsed / 60000)} min used).`, code: 2 })
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
console.log(`\n ${bold(`[${rounds}]`)} ${task.title}`)
|
|
186
|
+
if (task.files) console.log(` ${stamp(task.files)}`)
|
|
187
|
+
|
|
188
|
+
const agent = runAgent(role, buildPrompt(task), {
|
|
189
|
+
cwd,
|
|
190
|
+
timeoutMs: Math.max(60_000, budgetMs - (Date.now() - started)),
|
|
191
|
+
})
|
|
192
|
+
if (!agent.ok) {
|
|
193
|
+
const why = agent.error || agent.output || `exit ${agent.status}`
|
|
194
|
+
text = markTask(text, task, {
|
|
195
|
+
status: "blocked",
|
|
196
|
+
verifyOutput: `blocked by the loop: the ${role} agent failed.\n${tail(why)}`,
|
|
197
|
+
})
|
|
198
|
+
writeFileSync(join(cwd, TASKS_PATH), text)
|
|
199
|
+
return stop(log, {
|
|
200
|
+
reason: `the ${role} agent failed on "${task.title}": ${tail(why, 3).split("\n")[0]}`,
|
|
201
|
+
code: 3,
|
|
202
|
+
})
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// The agent's claim is not the evidence. Re-run the verify here, because a
|
|
206
|
+
// loop that trusts the agent's own word about whether it passed is a loop
|
|
207
|
+
// that reports success it never checked.
|
|
208
|
+
console.log(` ${stamp(`verify: ${task.verify}`)}`)
|
|
209
|
+
const check = runVerify(task.verify, { cwd, timeoutMs: 1800_000 })
|
|
210
|
+
log.push({ round: rounds, task: task.title, ok: check.ok, seconds: check.seconds })
|
|
211
|
+
|
|
212
|
+
if (!check.ok) {
|
|
213
|
+
text = markTask(text, task, {
|
|
214
|
+
status: "blocked",
|
|
215
|
+
verifyOutput: `verify failed (exit ${check.status}, ${check.seconds}s)\n${tail(check.output)}`,
|
|
216
|
+
})
|
|
217
|
+
writeFileSync(join(cwd, TASKS_PATH), text)
|
|
218
|
+
return stop(log, {
|
|
219
|
+
reason: `verify failed on "${task.title}". Marked blocked -- the real output is in ${TASKS_PATH}.`,
|
|
220
|
+
code: 4,
|
|
221
|
+
})
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
text = markTask(text, task, {
|
|
225
|
+
status: "done",
|
|
226
|
+
verifyOutput: `exit ${check.status} in ${check.seconds}s\n${tail(check.output)}`,
|
|
227
|
+
})
|
|
228
|
+
writeFileSync(join(cwd, TASKS_PATH), text)
|
|
229
|
+
console.log(` ${green("done")} ${stamp(`${check.seconds}s`)}`)
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function stop(log, { reason, code, summary }) {
|
|
234
|
+
return { reason, code: code ?? 0, rounds: log.length, log, summary }
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export { TASKS_PATH }
|
package/src/profile.js
CHANGED
|
@@ -17,6 +17,7 @@ export const ROOT = resolve(HERE, "..")
|
|
|
17
17
|
/**
|
|
18
18
|
* @typedef {"zero-retention" | "may-train" | "unknown"} Retention
|
|
19
19
|
* @typedef {string | null} EffortRequest
|
|
20
|
+
* @typedef {{ model: string, levels?: string[] }} RoleModel
|
|
20
21
|
* @typedef {object} Profile
|
|
21
22
|
* @property {string} id
|
|
22
23
|
* @property {string} [title]
|
|
@@ -31,6 +32,7 @@ export const ROOT = resolve(HERE, "..")
|
|
|
31
32
|
* structuredOutput?: boolean, attachment?: boolean }} [supports]
|
|
32
33
|
* @property {{ levels?: string[], default?: string | null, reserveShare?: number }} [effort]
|
|
33
34
|
* @property {Record<string, EffortRequest>} [roles]
|
|
35
|
+
* @property {Record<string, RoleModel>} [roleModels]
|
|
34
36
|
* @property {{ enabled?: boolean, keepRecentToolCalls?: number,
|
|
35
37
|
* keepRecentTurns?: number, keepUserMessages?: boolean }} [prune]
|
|
36
38
|
* @property {string[]} [fallback]
|
|
@@ -135,6 +137,35 @@ export function validate(p, path = "<memory>") {
|
|
|
135
137
|
`${where}: effort.reserveShare must be between 0 and 0.5`,
|
|
136
138
|
)
|
|
137
139
|
}
|
|
140
|
+
for (const [role, rm] of Object.entries(p.roleModels ?? {})) {
|
|
141
|
+
if (!SPLITTABLE_ROLES.includes(role)) {
|
|
142
|
+
throw new ProfileError(
|
|
143
|
+
`${where}: roleModels.${role} is not one of ${SPLITTABLE_ROLES.join(", ")}`,
|
|
144
|
+
)
|
|
145
|
+
}
|
|
146
|
+
// resolveRoles walks `roles`, so a split for a role nobody asks for never
|
|
147
|
+
// reaches an agent. That reads as "split installed" while doing nothing.
|
|
148
|
+
if (!(role in (p.roles ?? {}))) {
|
|
149
|
+
throw new ProfileError(
|
|
150
|
+
`${where}: roleModels.${role} has no matching entry in roles, so it would never apply`,
|
|
151
|
+
)
|
|
152
|
+
}
|
|
153
|
+
if (!rm?.model || typeof rm.model !== "string") {
|
|
154
|
+
throw new ProfileError(`${where}: roleModels.${role} needs a model`)
|
|
155
|
+
}
|
|
156
|
+
if (rm.levels !== undefined && !Array.isArray(rm.levels)) {
|
|
157
|
+
throw new ProfileError(
|
|
158
|
+
`${where}: roleModels.${role}.levels must be an array`,
|
|
159
|
+
)
|
|
160
|
+
}
|
|
161
|
+
// retention.yml is the only place retention is decided, so a role model
|
|
162
|
+
// missing from it is a profile we cannot honestly write a header for.
|
|
163
|
+
if (!retentionFor(rm.model)) {
|
|
164
|
+
throw new ProfileError(
|
|
165
|
+
`${where}: roleModels.${role} names ${rm.model}, which has no retention.yml entry`,
|
|
166
|
+
)
|
|
167
|
+
}
|
|
168
|
+
}
|
|
138
169
|
}
|
|
139
170
|
|
|
140
171
|
// Canonical ordering for reasoning effort, used to snap a requested level onto
|
|
@@ -178,15 +209,45 @@ export function resolveEffort(levels, requested) {
|
|
|
178
209
|
return best ?? levels[levels.length - 1] ?? null
|
|
179
210
|
}
|
|
180
211
|
|
|
212
|
+
/**
|
|
213
|
+
* Model and effort levels for one role, or null when the role stays on the
|
|
214
|
+
* profile's base model.
|
|
215
|
+
*
|
|
216
|
+
* @param {Profile} p
|
|
217
|
+
* @param {string} role
|
|
218
|
+
* @returns {{ model: string, levels: string[] } | null}
|
|
219
|
+
*/
|
|
220
|
+
export function resolveRoleModel(p, role) {
|
|
221
|
+
const rm = p.roleModels?.[role]
|
|
222
|
+
if (!rm) return null
|
|
223
|
+
return { model: rm.model, levels: rm.levels ?? [] }
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Effort levels a role's own model offers. Falls back to the profile's when the
|
|
228
|
+
* role is not split out, so an unsplit profile behaves exactly as before.
|
|
229
|
+
*
|
|
230
|
+
* @param {Profile} p
|
|
231
|
+
* @param {string} role
|
|
232
|
+
* @returns {string[]}
|
|
233
|
+
*/
|
|
234
|
+
function levelsForRole(p, role) {
|
|
235
|
+
const rm = resolveRoleModel(p, role)
|
|
236
|
+
if (rm) return rm.levels
|
|
237
|
+
return p.effort?.levels ?? []
|
|
238
|
+
}
|
|
239
|
+
|
|
181
240
|
/**
|
|
182
241
|
* @param {Profile} p
|
|
183
242
|
* @returns {Record<string, string | null>}
|
|
184
243
|
*/
|
|
185
244
|
export function resolveRoles(p) {
|
|
186
|
-
const levels = p.effort?.levels ?? []
|
|
187
245
|
const out = /** @type {Record<string, string | null>} */ ({})
|
|
188
246
|
for (const [role, want] of Object.entries(p.roles ?? {})) {
|
|
189
|
-
|
|
247
|
+
// Resolve against the levels of the model this role actually runs on. A
|
|
248
|
+
// role asking for `max` on a model topping out at `xhigh` gets `xhigh`,
|
|
249
|
+
// which is only correct if the profile knows the role's real model.
|
|
250
|
+
out[role] = resolveEffort(levelsForRole(p, role), want)
|
|
190
251
|
}
|
|
191
252
|
return out
|
|
192
253
|
}
|
|
@@ -201,10 +262,39 @@ export function resolveDefaultVariant(p) {
|
|
|
201
262
|
* tool output. Scaled from the model, never hardcoded.
|
|
202
263
|
* @param {Profile} p @returns {number}
|
|
203
264
|
*/
|
|
265
|
+
// The roles a profile may split onto another model. Kept in step with
|
|
266
|
+
// `roleModels` in profiles/schema.json, which nothing validates at runtime.
|
|
267
|
+
const SPLITTABLE_ROLES = ["plan", "build", "reviewer", "locate", "verify"]
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* The model whose limits govern context management: the one with the widest
|
|
271
|
+
* input cap, not necessarily the base model.
|
|
272
|
+
*
|
|
273
|
+
* A split profile derives `reserved` and `prune` from a single `limits`, but
|
|
274
|
+
* the roles do not share it. Sizing for the narrowest input cap silently
|
|
275
|
+
* throttles every wider role to under half its real window, which is the whole
|
|
276
|
+
* point of running them on a bigger model. Sizing for the widest means no role
|
|
277
|
+
* can be under-reserved, which is the direction that has to be safe.
|
|
278
|
+
*
|
|
279
|
+
* @param {Profile} p
|
|
280
|
+
* @returns {number}
|
|
281
|
+
*/
|
|
282
|
+
export function inputCap(p) {
|
|
283
|
+
let cap = p.limits.input ?? p.limits.context
|
|
284
|
+
for (const s of roleSplitModels(p)) {
|
|
285
|
+
const other = s.limits?.input ?? s.limits?.context
|
|
286
|
+
if (typeof other === "number" && other > cap) cap = other
|
|
287
|
+
}
|
|
288
|
+
return cap
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* @param {Profile} p
|
|
293
|
+
* @returns {number}
|
|
294
|
+
*/
|
|
204
295
|
export function reservedTokens(p) {
|
|
205
|
-
const input = p.limits.input ?? p.limits.context
|
|
206
296
|
const share = p.effort?.reserveShare ?? 0.12
|
|
207
|
-
return Math.max(8000, Math.round(
|
|
297
|
+
return Math.max(8000, Math.round(inputCap(p) * share))
|
|
208
298
|
}
|
|
209
299
|
|
|
210
300
|
/**
|
|
@@ -212,7 +302,7 @@ export function reservedTokens(p) {
|
|
|
212
302
|
* @param {Profile} p @returns {number}
|
|
213
303
|
*/
|
|
214
304
|
export function pruneThreshold(p) {
|
|
215
|
-
return (p
|
|
305
|
+
return inputCap(p) - reservedTokens(p)
|
|
216
306
|
}
|
|
217
307
|
|
|
218
308
|
/**
|
|
@@ -296,6 +386,67 @@ export function retentionFor(id) {
|
|
|
296
386
|
return table[id] ?? table[id.includes("/") ? id : `opencode/${id}`]
|
|
297
387
|
}
|
|
298
388
|
|
|
389
|
+
/**
|
|
390
|
+
* Every model a profile can put behind the user, base plus any split roles.
|
|
391
|
+
*
|
|
392
|
+
* @param {Profile} p
|
|
393
|
+
* @returns {string[]}
|
|
394
|
+
*/
|
|
395
|
+
export function modelsInPlay(p) {
|
|
396
|
+
const out = [p.model]
|
|
397
|
+
for (const rm of Object.values(p.roleModels ?? {})) {
|
|
398
|
+
if (!out.includes(rm.model)) out.push(rm.model)
|
|
399
|
+
}
|
|
400
|
+
return out
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* The split roles, each with the limits of the model that role actually runs
|
|
405
|
+
* on. A role on a different model has a different output cap, and the header
|
|
406
|
+
* has to say so: reading "output cap 524,288" and assuming `build` can spend
|
|
407
|
+
* all of it is wrong whenever build is split onto a smaller model.
|
|
408
|
+
*
|
|
409
|
+
* @param {Profile} p
|
|
410
|
+
* @returns {{ role: string, model: string, levels: string[], limits: Profile["limits"] | null }[]}
|
|
411
|
+
*/
|
|
412
|
+
export function roleSplitModels(p) {
|
|
413
|
+
return Object.entries(p.roleModels ?? {}).map(([role, rm]) => {
|
|
414
|
+
let limits = null
|
|
415
|
+
try {
|
|
416
|
+
limits = loadProfile(rm.model)?.limits ?? null
|
|
417
|
+
} catch {
|
|
418
|
+
limits = null
|
|
419
|
+
}
|
|
420
|
+
return { role, model: rm.model, levels: rm.levels ?? [], limits }
|
|
421
|
+
})
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// Ordered safest-first: a profile is only as private as its least private model.
|
|
425
|
+
const RETENTION_SEVERITY = ["zero-retention", "unknown", "may-train"]
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* The retention that actually applies once every role is considered, which is
|
|
429
|
+
* what a config header has to print. `retention.yml` is the source of truth.
|
|
430
|
+
*
|
|
431
|
+
* @param {Profile} p
|
|
432
|
+
* @returns {{ retention: string, worst: string | null }}
|
|
433
|
+
*/
|
|
434
|
+
export function effectiveRetention(p) {
|
|
435
|
+
let worst = null
|
|
436
|
+
let retention = "zero-retention"
|
|
437
|
+
for (const id of modelsInPlay(p)) {
|
|
438
|
+
const declared = retentionFor(id)?.retention ?? "unknown"
|
|
439
|
+
if (declared === "may-train") {
|
|
440
|
+
return { retention: "may-train", worst: id }
|
|
441
|
+
}
|
|
442
|
+
if (!worst || RETENTION_SEVERITY.indexOf(declared) > RETENTION_SEVERITY.indexOf(worst)) {
|
|
443
|
+
worst = declared
|
|
444
|
+
}
|
|
445
|
+
retention = worst
|
|
446
|
+
}
|
|
447
|
+
return { retention, worst }
|
|
448
|
+
}
|
|
449
|
+
|
|
299
450
|
/** Where opencode keeps its config, honouring the same env var it does. */
|
|
300
451
|
export function configFilePath() {
|
|
301
452
|
const dir =
|
|
@@ -304,14 +455,118 @@ export function configFilePath() {
|
|
|
304
455
|
return join(dir, "opencode.jsonc")
|
|
305
456
|
}
|
|
306
457
|
|
|
458
|
+
/**
|
|
459
|
+
* Parse JSONC the way OpenCode does: comments and trailing commas are legal.
|
|
460
|
+
*
|
|
461
|
+
* A regex that strips `//` cannot be used here. The generated config carries
|
|
462
|
+
* `"$schema": "https://opencode.ai/config.json"`, so a naive strip corrupts the
|
|
463
|
+
* URL; and a trailing comma -- ordinary in a hand-edited `.jsonc` -- makes
|
|
464
|
+
* `JSON.parse` throw, which used to surface as an empty object and silently
|
|
465
|
+
* dropped every hand-tuned key in `oc-harness use`. This walks the text once,
|
|
466
|
+
* tracking string state, so both are handled correctly.
|
|
467
|
+
*
|
|
468
|
+
* @param {string} text
|
|
469
|
+
* @returns {Record<string, unknown> | null} null when the text is not valid JSONC
|
|
470
|
+
*/
|
|
471
|
+
export function parseJsonc(text) {
|
|
472
|
+
let out = ""
|
|
473
|
+
let i = 0
|
|
474
|
+
let inString = false
|
|
475
|
+
let escaped = false
|
|
476
|
+
/** True once a `,` has been emitted that a `}` or `]` may turn out to cancel. */
|
|
477
|
+
let pendingComma = false
|
|
478
|
+
|
|
479
|
+
const n = text.length
|
|
480
|
+
while (i < n) {
|
|
481
|
+
const ch = text[i]
|
|
482
|
+
|
|
483
|
+
if (inString) {
|
|
484
|
+
out += ch
|
|
485
|
+
if (escaped) escaped = false
|
|
486
|
+
else if (ch === "\\") escaped = true
|
|
487
|
+
else if (ch === '"') inString = false
|
|
488
|
+
i++
|
|
489
|
+
continue
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
if (ch === '"') {
|
|
493
|
+
if (pendingComma) {
|
|
494
|
+
out += ","
|
|
495
|
+
pendingComma = false
|
|
496
|
+
}
|
|
497
|
+
out += ch
|
|
498
|
+
inString = true
|
|
499
|
+
i++
|
|
500
|
+
continue
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
if (ch === "/" && text[i + 1] === "/") {
|
|
504
|
+
while (i < n && text[i] !== "\n") i++
|
|
505
|
+
continue
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
if (ch === "/" && text[i + 1] === "*") {
|
|
509
|
+
i += 2
|
|
510
|
+
while (i < n && !(text[i] === "*" && text[i + 1] === "/")) i++
|
|
511
|
+
i += 2
|
|
512
|
+
continue
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
if (ch === ",") {
|
|
516
|
+
// A second comma before any value is malformed. Repairing `,,,` into one
|
|
517
|
+
// separator would mean guessing at a file we are about to rewrite, which
|
|
518
|
+
// is the exact failure this parser exists to prevent.
|
|
519
|
+
if (pendingComma) return null
|
|
520
|
+
// Hold the comma back: if the next significant character closes an
|
|
521
|
+
// object or array, this comma was illegal trailing punctuation.
|
|
522
|
+
pendingComma = true
|
|
523
|
+
i++
|
|
524
|
+
continue
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
if (ch === " " || ch === "\t" || ch === "\n" || ch === "\r") {
|
|
528
|
+
out += ch
|
|
529
|
+
i++
|
|
530
|
+
continue
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
if (ch === "}" || ch === "]") {
|
|
534
|
+
// A closer cancels any comma held back before it.
|
|
535
|
+
pendingComma = false
|
|
536
|
+
out += ch
|
|
537
|
+
i++
|
|
538
|
+
continue
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// Every other significant character settles a comma held back across
|
|
542
|
+
// whitespace or a comment. Miss this on the string branch below and a
|
|
543
|
+
// comment after `1, // note` swallows the separator entirely.
|
|
544
|
+
if (pendingComma) {
|
|
545
|
+
out += ","
|
|
546
|
+
pendingComma = false
|
|
547
|
+
}
|
|
548
|
+
out += ch
|
|
549
|
+
i++
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
try {
|
|
553
|
+
const parsed = JSON.parse(out)
|
|
554
|
+
return parsed && typeof parsed === "object" ? parsed : null
|
|
555
|
+
} catch {
|
|
556
|
+
// An empty or comments-only file is not malformed JSONC, it is an empty
|
|
557
|
+
// config, and `oc-harness use` has to be able to fill one in. Only report
|
|
558
|
+
// a parse failure when there was actually something to parse.
|
|
559
|
+
if (!out.trim()) return {}
|
|
560
|
+
return null
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
307
564
|
/**
|
|
308
565
|
* Read the active profile id from the config file on disk.
|
|
309
566
|
*
|
|
310
|
-
*
|
|
311
|
-
*
|
|
312
|
-
*
|
|
313
|
-
* file survives; the in-memory config does not. Verified against the 1.18.35
|
|
314
|
-
* schema.
|
|
567
|
+
* Returns null when the file is absent, unreadable, or not valid JSONC. Callers
|
|
568
|
+
* fall back to the profile named in the `config` hook, so a null here degrades
|
|
569
|
+
* to the default profile rather than failing.
|
|
315
570
|
*
|
|
316
571
|
* @returns {string | null}
|
|
317
572
|
*/
|
|
@@ -322,10 +577,10 @@ export function profileIdFromConfig() {
|
|
|
322
577
|
const file = configFilePath()
|
|
323
578
|
if (!existsSync(file)) return null
|
|
324
579
|
try {
|
|
325
|
-
const
|
|
326
|
-
const
|
|
327
|
-
const
|
|
328
|
-
|
|
580
|
+
const parsed = parseJsonc(readFileSync(file, "utf8"))
|
|
581
|
+
const harness = parsed?.harness
|
|
582
|
+
const id =
|
|
583
|
+
harness && typeof harness === "object" ? /** @type {{profile?: unknown}} */ (harness).profile : null
|
|
329
584
|
return typeof id === "string" && id ? id : null
|
|
330
585
|
} catch {
|
|
331
586
|
return null
|
package/src/rules.js
CHANGED
|
@@ -349,24 +349,9 @@ export const BASH_RULES = [
|
|
|
349
349
|
test: /\b(curl|wget)\b[^|]*\|\s*(sudo\s+)?(sh|bash|zsh|python[0-9.]*)\b/i,
|
|
350
350
|
why: "downloads and executes a script in one step",
|
|
351
351
|
},
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
// A dry run publishes nothing, so it is not the thing being gated.
|
|
356
|
-
check: (seg) =>
|
|
357
|
-
gitSubcommand(seg) === "push" &&
|
|
358
|
-
tokenize(seg).some((w) => w === "--force" || w === "-f" || w.startsWith("--force")),
|
|
359
|
-
why: "overwrites published history",
|
|
360
|
-
},
|
|
361
|
-
{
|
|
362
|
-
name: "git push",
|
|
363
|
-
scope: "segment",
|
|
364
|
-
// Flags are matched as whole words: a `-f` in `my-feature-f` is a branch
|
|
365
|
-
// name, not a force flag.
|
|
366
|
-
check: (seg) =>
|
|
367
|
-
gitSubcommand(seg) === "push" && !tokenize(seg).some((w) => w === "--dry-run" || w === "-n"),
|
|
368
|
-
why: "publishing is a human decision",
|
|
369
|
-
},
|
|
352
|
+
// `git push` is ask-gated via permissions (`*` ask in the generated
|
|
353
|
+
// config), not guard-blocked. Publishing needs human approval, but the
|
|
354
|
+
// guard lets it through so opencode can prompt first.
|
|
370
355
|
{
|
|
371
356
|
name: "recursive delete",
|
|
372
357
|
scope: "segment",
|