opencode-harness-kit 0.4.3 → 0.4.4
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 +527 -18
- package/CONTRIBUTING.md +242 -0
- package/GLOBAL.md +34 -5
- package/MIGRATION.md +151 -0
- package/README.md +307 -37
- package/SECURITY.md +30 -0
- package/bin/baseline.mjs +15 -7
- package/bin/git-hooks/pre-commit +41 -9
- package/bin/oc-harness.mjs +1265 -82
- package/dist/config.d.ts +127 -0
- package/dist/denylist.d.ts +33 -0
- package/dist/effort.d.ts +64 -0
- package/dist/guard.d.ts +23 -0
- package/dist/health.d.ts +43 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +532 -149
- package/dist/plugin.d.ts +15 -0
- package/dist/profile.d.ts +399 -0
- package/dist/project.d.ts +8 -0
- package/dist/prune.d.ts +6 -0
- package/dist/router.d.ts +3 -0
- package/dist/rules.d.ts +109 -0
- package/dist/safe.d.ts +7 -0
- package/dist/telemetry-store.d.ts +12 -0
- package/dist/telemetry.d.ts +3 -0
- package/docs/EXIT-CODES.md +71 -0
- package/docs/KNOWN-LIMITS.md +45 -21
- package/docs/RESEARCH.md +9 -4
- package/docs/ROUTING.md +53 -8
- package/eval/RESULTS.md +443 -0
- package/eval/ab.mjs +743 -0
- package/eval/attribute.mjs +170 -0
- package/eval/flags.mjs +88 -0
- package/eval/measure-install.mjs +323 -0
- package/eval/measure-prune.mjs +339 -0
- package/eval/metrics.js +79 -0
- package/eval/premises.mjs +200 -0
- package/eval/prepare.mjs +233 -0
- package/eval/run.mjs +376 -0
- package/eval/tasks-self.json +81 -0
- package/eval/tasks-ts.json +50 -0
- package/eval/tasks.json +83 -0
- package/eval/thresholds.mjs +134 -0
- package/eval/verify.mjs +287 -0
- package/package.json +44 -17
- package/profiles/fleet-all.json +6 -1
- package/profiles/fleet-safe.json +7 -3
- package/profiles/schema.json +182 -51
- package/prompts/build.md +4 -2
- package/skills/space-build/SKILL.md +4 -2
- package/skills/space-finish/SKILL.md +6 -2
- package/skills/space-plan/reference/task-template.md +13 -5
- package/src/config.js +182 -32
- package/src/denylist.js +95 -0
- package/src/driver/run.mjs +81 -16
- package/src/effort.js +15 -4
- package/src/guard.ts +165 -13
- package/src/health.js +80 -0
- package/src/index.ts +15 -4
- package/src/lane-usage.js +72 -18
- package/src/plugin-type-check.ts +38 -0
- package/src/plugin.ts +39 -0
- package/src/profile.js +225 -26
- package/src/project.js +47 -0
- package/src/prune.ts +110 -31
- package/src/router.ts +32 -63
- package/src/rules.js +99 -7
- package/src/safe.ts +27 -0
- package/src/sqlite.js +144 -0
- package/src/tasks.js +112 -29
- package/src/telemetry-store.ts +62 -0
- package/src/telemetry.ts +60 -57
- package/src/trust.js +70 -9
package/dist/plugin.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A plugin: a function opencode calls once, which returns the hooks it
|
|
3
|
+
* wants registered.
|
|
4
|
+
*
|
|
5
|
+
* The context and the options are opencode's, and the kit does not name
|
|
6
|
+
* their fields - it uses what it needs and tolerates the rest.
|
|
7
|
+
*/
|
|
8
|
+
export type Plugin = (ctx: any, options?: any) => Promise<Record<string, any>>;
|
|
9
|
+
/**
|
|
10
|
+
* The hooks a plugin returns.
|
|
11
|
+
*
|
|
12
|
+
* opencode calls each one by name; the kit registers `config`, `tool.execute.before`
|
|
13
|
+
* and `chat.message`. A hook that is not in this list is not a hook.
|
|
14
|
+
*/
|
|
15
|
+
export type Hooks = Record<string, (...args: any[]) => any>;
|
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
export declare const ROOT: string;
|
|
2
|
+
export type Retention = "zero-retention" | "retained" | "may-train" | "unknown";
|
|
3
|
+
export type EffortRequest = string | null;
|
|
4
|
+
export type RoleModel = {
|
|
5
|
+
model: string;
|
|
6
|
+
levels?: string[];
|
|
7
|
+
chain?: string[];
|
|
8
|
+
};
|
|
9
|
+
export type Lane = {
|
|
10
|
+
description: string;
|
|
11
|
+
mode?: string;
|
|
12
|
+
steps?: number;
|
|
13
|
+
permission?: Record<string, any>;
|
|
14
|
+
prompt: string;
|
|
15
|
+
};
|
|
16
|
+
export type Profile = {
|
|
17
|
+
id: string;
|
|
18
|
+
title?: string;
|
|
19
|
+
provider: string;
|
|
20
|
+
model: string;
|
|
21
|
+
family?: string | null;
|
|
22
|
+
/**
|
|
23
|
+
* the route for opencode's short auxiliary calls
|
|
24
|
+
*/
|
|
25
|
+
utility?: {
|
|
26
|
+
model: string;
|
|
27
|
+
why?: string;
|
|
28
|
+
};
|
|
29
|
+
verified?: string;
|
|
30
|
+
retention: Retention;
|
|
31
|
+
maxRetention?: Retention;
|
|
32
|
+
notes?: string;
|
|
33
|
+
limits: {
|
|
34
|
+
context: number;
|
|
35
|
+
input?: number;
|
|
36
|
+
output: number;
|
|
37
|
+
};
|
|
38
|
+
supports?: {
|
|
39
|
+
reasoning?: boolean;
|
|
40
|
+
temperature?: boolean;
|
|
41
|
+
toolCall?: boolean;
|
|
42
|
+
structuredOutput?: boolean;
|
|
43
|
+
attachment?: boolean;
|
|
44
|
+
};
|
|
45
|
+
effort?: {
|
|
46
|
+
levels?: string[];
|
|
47
|
+
default?: string | null;
|
|
48
|
+
reserveShare?: number;
|
|
49
|
+
};
|
|
50
|
+
roles?: Record<string, EffortRequest>;
|
|
51
|
+
roleModels?: Record<string, RoleModel>;
|
|
52
|
+
prune?: {
|
|
53
|
+
enabled?: boolean;
|
|
54
|
+
keepRecentToolCalls?: number;
|
|
55
|
+
keepRecentTurns?: number;
|
|
56
|
+
keepUserMessages?: boolean;
|
|
57
|
+
};
|
|
58
|
+
fallback?: string[];
|
|
59
|
+
};
|
|
60
|
+
export type RetentionRecord = {
|
|
61
|
+
retention: Retention;
|
|
62
|
+
note?: string;
|
|
63
|
+
verified?: string;
|
|
64
|
+
source?: string;
|
|
65
|
+
};
|
|
66
|
+
/**
|
|
67
|
+
* @typedef {"zero-retention" | "retained" | "may-train" | "unknown"} Retention
|
|
68
|
+
* @typedef {string | null} EffortRequest
|
|
69
|
+
* @typedef {{ model: string, levels?: string[], chain?: string[] }} RoleModel
|
|
70
|
+
* @typedef {{ description: string, mode?: string, steps?: number, permission?: Record<string, any>, prompt: string }} Lane
|
|
71
|
+
* @typedef {object} Profile
|
|
72
|
+
* @property {string} id
|
|
73
|
+
* @property {string} [title]
|
|
74
|
+
* @property {string} provider
|
|
75
|
+
* @property {string} model
|
|
76
|
+
* @property {string | null} [family]
|
|
77
|
+
* @property {{ model: string, why?: string }} [utility] the route for opencode's short auxiliary calls
|
|
78
|
+
* @property {string} [verified]
|
|
79
|
+
* @property {Retention} retention
|
|
80
|
+
* @property {Retention} [maxRetention]
|
|
81
|
+
* @property {string} [notes]
|
|
82
|
+
* @property {{ context: number, input?: number, output: number }} limits
|
|
83
|
+
* @property {{ reasoning?: boolean, temperature?: boolean, toolCall?: boolean,
|
|
84
|
+
* structuredOutput?: boolean, attachment?: boolean }} [supports]
|
|
85
|
+
* @property {{ levels?: string[], default?: string | null, reserveShare?: number }} [effort]
|
|
86
|
+
* @property {Record<string, EffortRequest>} [roles]
|
|
87
|
+
* @property {Record<string, RoleModel>} [roleModels]
|
|
88
|
+
* @property {{ enabled?: boolean, keepRecentToolCalls?: number,
|
|
89
|
+
* keepRecentTurns?: number, keepUserMessages?: boolean }} [prune]
|
|
90
|
+
* @property {string[]} [fallback]
|
|
91
|
+
* @typedef {{ retention: Retention, note?: string, verified?: string, source?: string }} RetentionRecord
|
|
92
|
+
*/
|
|
93
|
+
export declare class ProfileError extends Error {
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* The lanes a profile may enable. Five are workflow positions in the plan ->
|
|
97
|
+
* build -> review -> finish loop; five are capability lanes that exist because
|
|
98
|
+
* one free model can do a job none of the other nine can.
|
|
99
|
+
*/
|
|
100
|
+
export declare const LANE_NAMES: string[];
|
|
101
|
+
/** The lanes that are part of the plan -> build -> review -> finish loop. */
|
|
102
|
+
export declare const WORKFLOW_LANES: string[];
|
|
103
|
+
/**
|
|
104
|
+
* How bad a retention level is, as a number. Bigger is worse.
|
|
105
|
+
*
|
|
106
|
+
* `unknown` is the worst of them. `retentionAllowed` refuses it outright - a
|
|
107
|
+
* lane that would run on a model nobody has stated the terms of is worse
|
|
108
|
+
* than a lane that runs on a worse model with known terms - so a severity
|
|
109
|
+
* table that ranked it as milder than `may-train` contradicted the rule it
|
|
110
|
+
* sits next to.
|
|
111
|
+
*
|
|
112
|
+
* @param {string} level
|
|
113
|
+
* @returns {number} the rank, or -1 for a level that is not one of them
|
|
114
|
+
*/
|
|
115
|
+
export declare function retentionSeverity(level: string): number;
|
|
116
|
+
/**
|
|
117
|
+
* Profiles live in profiles/ (verified). schema.json and index.json are not
|
|
118
|
+
* profiles.
|
|
119
|
+
*/
|
|
120
|
+
export declare function profileDirs(): string[];
|
|
121
|
+
/** @param {string} id @returns {string | null} */
|
|
122
|
+
export declare function profilePath(id: string): string | null;
|
|
123
|
+
/** @returns {string[]} */
|
|
124
|
+
export declare function listProfileIds(): string[];
|
|
125
|
+
/** @param {string} id @returns {Profile} */
|
|
126
|
+
export declare function loadProfile(id: string): Profile;
|
|
127
|
+
/**
|
|
128
|
+
* Fill in derived fields so nothing downstream has to guess.
|
|
129
|
+
* @param {Profile} p @returns {Profile}
|
|
130
|
+
*/
|
|
131
|
+
export declare function normalize(p: Profile): Profile;
|
|
132
|
+
/**
|
|
133
|
+
* The lane table: what each job is for, and nothing about models.
|
|
134
|
+
* @returns {Record<string, Lane>}
|
|
135
|
+
*/
|
|
136
|
+
export declare function loadLanes(): Record<string, Lane>;
|
|
137
|
+
/**
|
|
138
|
+
* @param {string} name
|
|
139
|
+
* @returns {Lane | null}
|
|
140
|
+
*/
|
|
141
|
+
export declare function laneMeta(name: string): Lane | null;
|
|
142
|
+
/** @returns {string[]} */
|
|
143
|
+
export declare function laneNames(): string[];
|
|
144
|
+
export declare function loadRetention(): {
|
|
145
|
+
models: Record<string, RetentionRecord>;
|
|
146
|
+
defaults: Record<string, string>;
|
|
147
|
+
};
|
|
148
|
+
/**
|
|
149
|
+
* Retention as declared in retention.yml, which wins over the profile copy.
|
|
150
|
+
* Accepts either a bare profile id or an already-qualified provider/model.
|
|
151
|
+
* @param {string} id
|
|
152
|
+
* @returns {RetentionRecord | undefined}
|
|
153
|
+
*/
|
|
154
|
+
export declare function retentionFor(id: string): RetentionRecord | undefined;
|
|
155
|
+
/**
|
|
156
|
+
* The retention of a model id, out of a retention table.
|
|
157
|
+
*
|
|
158
|
+
* Pure, so a test can hand it a table whose `defaults.unknown` says
|
|
159
|
+
* something else. The shipped table says "unknown", which is what the
|
|
160
|
+
* hardcoded fallback used to be - so a test that only checked the value
|
|
161
|
+
* could not tell the two apart.
|
|
162
|
+
*
|
|
163
|
+
* @param {{ models?: Record<string, RetentionRecord>, defaults?: Record<string, string> }} table
|
|
164
|
+
* @param {string} model
|
|
165
|
+
* @returns {Retention}
|
|
166
|
+
*/
|
|
167
|
+
export declare function retentionOf(table: {
|
|
168
|
+
models?: Record<string, RetentionRecord>;
|
|
169
|
+
defaults?: Record<string, string>;
|
|
170
|
+
}, model: string): Retention;
|
|
171
|
+
/**
|
|
172
|
+
* The retention of a model id, qualified or not.
|
|
173
|
+
*
|
|
174
|
+
* A model with no entry in retention.yml gets the `defaults.unknown` value
|
|
175
|
+
* from that file, which is `unknown` today. The fallback used to be
|
|
176
|
+
* hardcoded here, so the `defaults:` block in retention.yml looked like
|
|
177
|
+
* configuration and was not.
|
|
178
|
+
*
|
|
179
|
+
* @param {string} model
|
|
180
|
+
* @returns {Retention}
|
|
181
|
+
*/
|
|
182
|
+
export declare function modelRetention(model: string): Retention;
|
|
183
|
+
/**
|
|
184
|
+
* Whether a model's retention is at or below a ceiling.
|
|
185
|
+
*
|
|
186
|
+
* `unknown` is never allowed: a lane that would run on a model nobody has
|
|
187
|
+
* stated the terms of is worse than a lane that runs on a worse model with
|
|
188
|
+
* known terms.
|
|
189
|
+
*
|
|
190
|
+
* @param {string} model
|
|
191
|
+
* @param {Retention} ceiling
|
|
192
|
+
* @returns {boolean}
|
|
193
|
+
*/
|
|
194
|
+
export declare function retentionAllowed(model: string, ceiling: Retention): boolean;
|
|
195
|
+
/**
|
|
196
|
+
* The ordered models that may serve a lane.
|
|
197
|
+
*
|
|
198
|
+
* A lane with no `roleModels` entry runs on the profile's base model, which
|
|
199
|
+
* is the single-model profile shape. A lane with a `chain` walks it.
|
|
200
|
+
*
|
|
201
|
+
* @param {Profile} p
|
|
202
|
+
* @param {string} role
|
|
203
|
+
* @returns {string[]}
|
|
204
|
+
*/
|
|
205
|
+
export declare function resolveLaneChain(p: Profile, role: string): string[];
|
|
206
|
+
/**
|
|
207
|
+
* The model that actually serves a lane right now.
|
|
208
|
+
*
|
|
209
|
+
* The first rung of the chain that is allowed by the retention ceiling and is
|
|
210
|
+
* not marked unhealthy wins. When no rung qualifies, the lane falls back to
|
|
211
|
+
* the profile's base model, so a lane is never left without a model - and a
|
|
212
|
+
* base model that is itself spent is the last resort rather than the first.
|
|
213
|
+
*
|
|
214
|
+
* @param {Profile} p
|
|
215
|
+
* @param {string} role
|
|
216
|
+
* @param {{ unhealthy?: Set<string> }} [opts]
|
|
217
|
+
* @returns {{ model: string, levels: string[] }}
|
|
218
|
+
*/
|
|
219
|
+
export declare function resolveLaneModel(p: Profile, role: string, opts?: {
|
|
220
|
+
unhealthy?: Set<string>;
|
|
221
|
+
}): {
|
|
222
|
+
model: string;
|
|
223
|
+
levels: string[];
|
|
224
|
+
};
|
|
225
|
+
/**
|
|
226
|
+
* The effort levels a model publishes, read from that model's own profile.
|
|
227
|
+
*
|
|
228
|
+
* @param {string} model
|
|
229
|
+
* @returns {string[]}
|
|
230
|
+
*/
|
|
231
|
+
export declare function levelsOfModel(model: string): string[];
|
|
232
|
+
/**
|
|
233
|
+
* Model and effort levels for one lane, or null when the lane stays on the
|
|
234
|
+
* profile's base model with no chain.
|
|
235
|
+
*
|
|
236
|
+
* @param {Profile} p
|
|
237
|
+
* @param {string} role
|
|
238
|
+
* @param {{ unhealthy?: Set<string> }} [opts]
|
|
239
|
+
* @returns {{ model: string, levels: string[] } | null}
|
|
240
|
+
*/
|
|
241
|
+
export declare function resolveRoleModel(p: Profile, role: string, opts?: {
|
|
242
|
+
unhealthy?: Set<string>;
|
|
243
|
+
}): {
|
|
244
|
+
model: string;
|
|
245
|
+
levels: string[];
|
|
246
|
+
} | null;
|
|
247
|
+
/**
|
|
248
|
+
* @param {Profile} p
|
|
249
|
+
* @param {{ unhealthy?: Set<string> }} [opts]
|
|
250
|
+
* @returns {Record<string, string | null>}
|
|
251
|
+
*/
|
|
252
|
+
export declare function resolveRoles(p: Profile, opts?: {
|
|
253
|
+
unhealthy?: Set<string>;
|
|
254
|
+
}): Record<string, string | null>;
|
|
255
|
+
/** @param {Profile} p @returns {string | null} */
|
|
256
|
+
export declare function resolveDefaultVariant(p: Profile): string | null;
|
|
257
|
+
/**
|
|
258
|
+
* Pick the available level closest to what a role asked for.
|
|
259
|
+
*
|
|
260
|
+
* A role asking for `max` on a model topping out at `xhigh` gets `xhigh`. A
|
|
261
|
+
* model with no tunable effort resolves to null, meaning "emit no variant"
|
|
262
|
+
* rather than "emit a level the model does not have".
|
|
263
|
+
*
|
|
264
|
+
* @param {string[]} levels
|
|
265
|
+
* @param {EffortRequest} requested
|
|
266
|
+
* @returns {string | null}
|
|
267
|
+
*/
|
|
268
|
+
export declare function resolveEffort(levels: string[], requested: EffortRequest): string | null;
|
|
269
|
+
/** @param {Profile} p @param {string} [path] */
|
|
270
|
+
export declare function validate(p: Profile, path?: string): void;
|
|
271
|
+
/**
|
|
272
|
+
* The model whose limits govern context management: the NARROWEST one in play.
|
|
273
|
+
*
|
|
274
|
+
* A fleet mixes models with a 1M window and models with a 200k one, and
|
|
275
|
+
* `compaction.reserved` is a single number for the whole config. Sizing it by
|
|
276
|
+
* the widest model would reserve a sixth of a 1M window - 125k tokens - and
|
|
277
|
+
* leave a 200k lane with 74k usable, which is where the lane stops working.
|
|
278
|
+
* Sizing it by the narrowest model means every lane keeps a workable window,
|
|
279
|
+
* and a lane with a wide window simply compacts a little later.
|
|
280
|
+
*
|
|
281
|
+
* @param {Profile} p
|
|
282
|
+
* @param {{ unhealthy?: Set<string> }} [opts]
|
|
283
|
+
* @returns {number}
|
|
284
|
+
*/
|
|
285
|
+
export declare function inputCap(p: Profile, opts?: {
|
|
286
|
+
unhealthy?: Set<string>;
|
|
287
|
+
}): number;
|
|
288
|
+
/**
|
|
289
|
+
* Tokens held back from the input ceiling for reasoning growth and in-flight
|
|
290
|
+
* tool output. Scaled from the model, never hardcoded.
|
|
291
|
+
* @param {Profile} p
|
|
292
|
+
* @param {{ unhealthy?: Set<string> }} [opts]
|
|
293
|
+
* @returns {number}
|
|
294
|
+
*/
|
|
295
|
+
export declare function reservedTokens(p: Profile, opts?: {
|
|
296
|
+
unhealthy?: Set<string>;
|
|
297
|
+
}): number;
|
|
298
|
+
/**
|
|
299
|
+
* Context budget above which pruning should already be working.
|
|
300
|
+
* @param {Profile} p
|
|
301
|
+
* @param {{ unhealthy?: Set<string> }} [opts]
|
|
302
|
+
* @returns {number}
|
|
303
|
+
*/
|
|
304
|
+
export declare function pruneThreshold(p: Profile, opts?: {
|
|
305
|
+
unhealthy?: Set<string>;
|
|
306
|
+
}): number;
|
|
307
|
+
/**
|
|
308
|
+
* Every model a profile can put behind the user: the base model plus the model
|
|
309
|
+
* each lane actually resolves to.
|
|
310
|
+
*
|
|
311
|
+
* @param {Profile} p
|
|
312
|
+
* @param {{ unhealthy?: Set<string> }} [opts]
|
|
313
|
+
* @returns {string[]}
|
|
314
|
+
*/
|
|
315
|
+
export declare function modelsInPlay(p: Profile, opts?: {
|
|
316
|
+
unhealthy?: Set<string>;
|
|
317
|
+
}): string[];
|
|
318
|
+
/**
|
|
319
|
+
* The split lanes, each with the limits of the model that lane actually runs
|
|
320
|
+
* on. A lane on a different model has a different output cap, and the header
|
|
321
|
+
* has to say so: reading "output cap 524,288" and assuming `build` can spend
|
|
322
|
+
* all of it is wrong whenever build is split onto a smaller model.
|
|
323
|
+
*
|
|
324
|
+
* @param {Profile} p
|
|
325
|
+
* @param {{ unhealthy?: Set<string> }} [opts]
|
|
326
|
+
* @returns {{ role: string, model: string, levels: string[], limits: Profile["limits"] | null }[]}
|
|
327
|
+
*/
|
|
328
|
+
export declare function roleSplitModels(p: Profile, opts?: {
|
|
329
|
+
unhealthy?: Set<string>;
|
|
330
|
+
}): {
|
|
331
|
+
role: string;
|
|
332
|
+
model: string;
|
|
333
|
+
levels: string[];
|
|
334
|
+
limits: Profile["limits"] | null;
|
|
335
|
+
}[];
|
|
336
|
+
/**
|
|
337
|
+
* The retention that actually applies once every lane is considered, which is
|
|
338
|
+
* what a config header has to print. `retention.yml` is the source of truth.
|
|
339
|
+
*
|
|
340
|
+
* `worst` is the MODEL responsible for that level, not the level itself. The
|
|
341
|
+
* two used to be the same field with two different meanings - a retention
|
|
342
|
+
* class on one path and a model id on the other - and the header prints it as
|
|
343
|
+
* a model name, so a fleet that resolved to a `retained` lane said
|
|
344
|
+
* "but retained makes the whole file retained".
|
|
345
|
+
*
|
|
346
|
+
* @param {Profile} p
|
|
347
|
+
* @param {{ unhealthy?: Set<string> }} [opts]
|
|
348
|
+
* @returns {{ retention: string, worst: string | null }}
|
|
349
|
+
*/
|
|
350
|
+
export declare function effectiveRetention(p: Profile, opts?: {
|
|
351
|
+
unhealthy?: Set<string>;
|
|
352
|
+
}): {
|
|
353
|
+
retention: string;
|
|
354
|
+
worst: string | null;
|
|
355
|
+
};
|
|
356
|
+
/** Where opencode keeps its config, honouring the same env var it does. */
|
|
357
|
+
export declare function configDir(): string;
|
|
358
|
+
/** @returns {string} */
|
|
359
|
+
export declare function configFilePath(): string;
|
|
360
|
+
/**
|
|
361
|
+
* The config directory written the way opencode's `{file:...}` references
|
|
362
|
+
* want it: a tilde when it is the default, an absolute path otherwise.
|
|
363
|
+
*
|
|
364
|
+
* A relative reference is silently ignored by opencode, so a prompt file that
|
|
365
|
+
* resolves to nothing leaves the lane with no instructions and no error.
|
|
366
|
+
*
|
|
367
|
+
* @returns {string}
|
|
368
|
+
*/
|
|
369
|
+
export declare function configDirRef(): string;
|
|
370
|
+
/**
|
|
371
|
+
* The reference opencode resolves for a lane's prompt file.
|
|
372
|
+
* @param {string} lane
|
|
373
|
+
* @returns {string}
|
|
374
|
+
*/
|
|
375
|
+
export declare function promptRef(lane: string): string;
|
|
376
|
+
/**
|
|
377
|
+
* Parse JSONC the way OpenCode does: comments and trailing commas are legal.
|
|
378
|
+
*
|
|
379
|
+
* A regex that strips `//` cannot be used here. The generated config carries
|
|
380
|
+
* `"$schema": "https://opencode.ai/config.json"`, so a naive strip corrupts the
|
|
381
|
+
* URL; and a trailing comma -- ordinary in a hand-edited `.jsonc` -- makes
|
|
382
|
+
* `JSON.parse` throw, which used to surface as an empty object and silently
|
|
383
|
+
* dropped every hand-tuned key in `oc-harness use`. This walks the text once,
|
|
384
|
+
* tracking string state, so both are handled correctly.
|
|
385
|
+
*
|
|
386
|
+
* @param {string} text
|
|
387
|
+
* @returns {Record<string, unknown> | null} null when the text is not valid JSONC
|
|
388
|
+
*/
|
|
389
|
+
export declare function parseJsonc(text: string): Record<string, unknown> | null;
|
|
390
|
+
/**
|
|
391
|
+
* Read the active profile id from the config file on disk.
|
|
392
|
+
*
|
|
393
|
+
* Returns null when the file is absent, unreadable, or not valid JSONC. Callers
|
|
394
|
+
* fall back to the profile named in the `config` hook, so a null here degrades
|
|
395
|
+
* to the default profile rather than failing.
|
|
396
|
+
*
|
|
397
|
+
* @returns {string | null}
|
|
398
|
+
*/
|
|
399
|
+
export declare function profileIdFromConfig(): string | null;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The root of the project a session is working in.
|
|
3
|
+
*
|
|
4
|
+
* @param {string | null | undefined} directory the session's working directory
|
|
5
|
+
* @param {string | null | undefined} worktree opencode's worktree, as a fallback
|
|
6
|
+
* @returns {string}
|
|
7
|
+
*/
|
|
8
|
+
export declare function projectRoot(directory: string | null | undefined, worktree: string | null | undefined): string;
|
package/dist/prune.d.ts
ADDED
package/dist/router.d.ts
ADDED
package/dist/rules.d.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a write target stays inside the project. A relative path is resolved
|
|
3
|
+
* against the project root, which is where the agent's working directory is.
|
|
4
|
+
* @param {string} target
|
|
5
|
+
* @param {string} root
|
|
6
|
+
* @returns {boolean}
|
|
7
|
+
*/
|
|
8
|
+
export declare function insideRoot(target: string, root: string): boolean;
|
|
9
|
+
export type Rule = {
|
|
10
|
+
name: string;
|
|
11
|
+
test?: RegExp;
|
|
12
|
+
check?: (text: string) => boolean;
|
|
13
|
+
why: string;
|
|
14
|
+
scope?: "segment" | "command";
|
|
15
|
+
unless?: RegExp;
|
|
16
|
+
};
|
|
17
|
+
export type Hit = {
|
|
18
|
+
name: string;
|
|
19
|
+
why: string;
|
|
20
|
+
segment: string;
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* Split a shell command into the individual commands it runs. Separators
|
|
24
|
+
* inside quotes, and heredoc bodies, are not command boundaries.
|
|
25
|
+
* @param {string} command
|
|
26
|
+
* @returns {string[]}
|
|
27
|
+
*/
|
|
28
|
+
export declare function segments(command: string): string[];
|
|
29
|
+
/**
|
|
30
|
+
* Split one segment into words, honouring quotes so that a quoted phrase is
|
|
31
|
+
* one argument rather than several.
|
|
32
|
+
* @param {string} segment
|
|
33
|
+
* @returns {string[]}
|
|
34
|
+
*/
|
|
35
|
+
export declare function tokenize(segment: string): string[];
|
|
36
|
+
/**
|
|
37
|
+
* A segment with its prefixes removed, so a rule anchored at `^` sees the
|
|
38
|
+
* command it is actually about: `sudo rm -rf /` becomes `rm -rf /`.
|
|
39
|
+
* Segment rules are matched against this rather than the raw text, because
|
|
40
|
+
* without it every anchored rule is one `sudo` away from being decorative.
|
|
41
|
+
* @param {string} segment
|
|
42
|
+
* @returns {string} empty when the segment names no command
|
|
43
|
+
*/
|
|
44
|
+
export declare function stripPrefixes(segment: string): string;
|
|
45
|
+
/**
|
|
46
|
+
* Resolve the git subcommand a segment actually runs, or null if the segment
|
|
47
|
+
* does not run git.
|
|
48
|
+
* @param {string} segment
|
|
49
|
+
* @returns {string | null}
|
|
50
|
+
*/
|
|
51
|
+
export declare function gitSubcommand(segment: string): string | null;
|
|
52
|
+
/**
|
|
53
|
+
* The command line hidden inside `sh -c "..."` or `eval "..."`.
|
|
54
|
+
* @param {string} segment
|
|
55
|
+
* @returns {string | null}
|
|
56
|
+
*/
|
|
57
|
+
export declare function shellPayload(segment: string): string | null;
|
|
58
|
+
/**
|
|
59
|
+
* Commands the guard deliberately does NOT block.
|
|
60
|
+
*
|
|
61
|
+
* `git push` is ask-gated through opencode's permission rules rather than
|
|
62
|
+
* guard-blocked, so it is the one command the agent reaches for that the
|
|
63
|
+
* guard has a policy about and lets through. It lives here, beside the rules,
|
|
64
|
+
* so there is ONE place that says what the guard's policy is - a keyword list
|
|
65
|
+
* in the telemetry was a second copy of it, and it drifted.
|
|
66
|
+
*
|
|
67
|
+
* @type {RegExp[]}
|
|
68
|
+
*/
|
|
69
|
+
export declare const ASK_GATED: RegExp[];
|
|
70
|
+
/** @type {Rule[]} */
|
|
71
|
+
export declare const BASH_RULES: Rule[];
|
|
72
|
+
/** @type {Rule[]} */
|
|
73
|
+
export declare const WRITE_RULES: Rule[];
|
|
74
|
+
/**
|
|
75
|
+
* The shared denylist, checked the way the rules are checked.
|
|
76
|
+
*
|
|
77
|
+
* The denylist used to be matched against the RAW command string, which is
|
|
78
|
+
* what lets `cd /tmp && npm publish` through: the pattern is `npm publish*`
|
|
79
|
+
* and the command does not start with it. It also let `sudo npm publish` and
|
|
80
|
+
* `sh -c "npm publish"` through, for the same reason.
|
|
81
|
+
*
|
|
82
|
+
* So the denylist is checked per SEGMENT, with the wrappers stripped, and
|
|
83
|
+
* the payload of a `bash -c` is checked too - exactly the way the rules are
|
|
84
|
+
* checked. One list, one way of reading it.
|
|
85
|
+
*
|
|
86
|
+
* @param {string} command
|
|
87
|
+
* @param {number} [depth]
|
|
88
|
+
* @returns {{ pattern: string, segment: string } | null}
|
|
89
|
+
*/
|
|
90
|
+
export declare function checkDenied(command: string, depth?: number): {
|
|
91
|
+
pattern: string;
|
|
92
|
+
segment: string;
|
|
93
|
+
} | null;
|
|
94
|
+
/**
|
|
95
|
+
* @param {string} command
|
|
96
|
+
* @param {Rule[]} [rules]
|
|
97
|
+
* @param {number} [depth]
|
|
98
|
+
* @returns {Hit | null}
|
|
99
|
+
*/
|
|
100
|
+
export declare function checkBash(command: string, rules?: Rule[], depth?: number): Hit | null;
|
|
101
|
+
/**
|
|
102
|
+
* @param {string} target
|
|
103
|
+
* @param {Rule[]} [rules]
|
|
104
|
+
* @param {string} [root] the project directory, when the host knows it
|
|
105
|
+
* @returns {Hit | null}
|
|
106
|
+
*/
|
|
107
|
+
export declare function checkWrite(target: string, rules?: Rule[], root?: string): Hit | null;
|
|
108
|
+
/** Names of every rule, for `oc-harness show` and documentation. */
|
|
109
|
+
export declare function ruleNames(): string[];
|
package/dist/safe.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Rotate above this, so a long-running project cannot fill its disk. */
|
|
2
|
+
export declare const MAX_BYTES: number;
|
|
3
|
+
export declare function logPath(dir: string): string;
|
|
4
|
+
/** The telemetry file and its rotated predecessors, oldest first. */
|
|
5
|
+
export declare function logFiles(dir: string): string[];
|
|
6
|
+
/**
|
|
7
|
+
* Append one record.
|
|
8
|
+
*
|
|
9
|
+
* Nothing here is allowed to throw. Telemetry must never be load-bearing: a
|
|
10
|
+
* full disk or a read-only project must not break a turn.
|
|
11
|
+
*/
|
|
12
|
+
export declare function write(dir: string, record: Record<string, unknown>): void;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# Exit codes
|
|
2
|
+
|
|
3
|
+
Every command that can fail says so in its exit code, so a script can tell
|
|
4
|
+
"the kit says no" from "the kit broke". A command that succeeded exits 0 and
|
|
5
|
+
prints nothing to stderr.
|
|
6
|
+
|
|
7
|
+
## `oc-harness run`
|
|
8
|
+
|
|
9
|
+
The unattended loop. It walks `space-tasks.md`, asks the agent for one task
|
|
10
|
+
at a time, and re-runs the task's own verify command before believing it.
|
|
11
|
+
|
|
12
|
+
| Code | Meaning |
|
|
13
|
+
|---|---|
|
|
14
|
+
| 0 | Every task is done. |
|
|
15
|
+
| 1 | There is no task file. The loop needs a plan to walk. |
|
|
16
|
+
| 2 | Stopped early and a person is needed: a task is blocked, a task has no verify command, the round cap was reached, or the time budget ran out. |
|
|
17
|
+
| 3 | The agent itself failed. The reason is the agent's own last words, and the task is marked blocked with them. |
|
|
18
|
+
| 4 | The verify command failed. The task is marked blocked and the real output is in `space-tasks.md` - not in the exit code, which is no place for a stack trace. |
|
|
19
|
+
|
|
20
|
+
Code 2 is the "asked for something impossible" code: nothing about it is a
|
|
21
|
+
bug, and the reason is printed. Codes 3 and 4 mean the work did not land.
|
|
22
|
+
|
|
23
|
+
## `oc-harness doctor`
|
|
24
|
+
|
|
25
|
+
| Code | Meaning |
|
|
26
|
+
|---|---|
|
|
27
|
+
| 0 | Everything checks out. |
|
|
28
|
+
| 1 | Something does not. The problems are listed, one per line. |
|
|
29
|
+
|
|
30
|
+
`--check-models` exits 1 as soon as a profile has drifted from the live
|
|
31
|
+
catalogue. `--watch` never exits: it polls, and reports only what CHANGED.
|
|
32
|
+
|
|
33
|
+
A failed catalogue fetch is not a finding. It is reported, the models are not
|
|
34
|
+
announced as withdrawn, and the poll carries on - a catalogue that cannot be
|
|
35
|
+
read says nothing about the models in it.
|
|
36
|
+
|
|
37
|
+
With `--json` the report is the only thing on stdout: a script that pipes it
|
|
38
|
+
into a parser gets JSON, and a failure is a JSON object with an `error` in
|
|
39
|
+
it, not a parse error.
|
|
40
|
+
|
|
41
|
+
## The measurement gates
|
|
42
|
+
|
|
43
|
+
`npm run measure:check` runs two measurements and both have to pass.
|
|
44
|
+
|
|
45
|
+
| Code | Meaning |
|
|
46
|
+
|---|---|
|
|
47
|
+
| 0 | Every measurement that could be compared is inside its threshold. |
|
|
48
|
+
| 1 | One is not. The report names the threshold and the number. |
|
|
49
|
+
|
|
50
|
+
A measurement that could NOT be compared - the two windows do not hold the
|
|
51
|
+
same work, so the difference is confounded - is reported as a NOTE and NOT
|
|
52
|
+
gated. The attributable measurement of the same thing, the pruner replayed
|
|
53
|
+
over real stored sessions, is the one that gates.
|
|
54
|
+
|
|
55
|
+
A gate that fires for reasons nobody can act on is a gate people switch off,
|
|
56
|
+
so the incomparable case is reported loudly and gated never, and the
|
|
57
|
+
attributable measurement carries the gate.
|
|
58
|
+
|
|
59
|
+
## `oc-harness install` and `oc-harness use`
|
|
60
|
+
|
|
61
|
+
| Code | Meaning |
|
|
62
|
+
|---|---|
|
|
63
|
+
| 0 | The install is in place, or the change was confirmed. |
|
|
64
|
+
| 1 | The install was refused, left alone, or a step failed. |
|
|
65
|
+
|
|
66
|
+
`doctor` also exits 1 when there is no config, or no profile recorded in
|
|
67
|
+
it: a kit that is installed but points at nothing is a broken install, and
|
|
68
|
+
a check that passes it is not a check.
|
|
69
|
+
|
|
70
|
+
Nothing is overwritten without a confirmation, unless `--yes` is given. With
|
|
71
|
+
`--yes`, the old config is still written to the backup directory first.
|