opencode-harness-kit 0.4.2 → 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.
Files changed (73) hide show
  1. package/CHANGELOG.md +544 -19
  2. package/CONTRIBUTING.md +242 -0
  3. package/GLOBAL.md +34 -5
  4. package/MIGRATION.md +151 -0
  5. package/README.md +294 -37
  6. package/SECURITY.md +30 -0
  7. package/bin/baseline.mjs +15 -7
  8. package/bin/git-hooks/pre-commit +41 -9
  9. package/bin/oc-harness.mjs +1265 -82
  10. package/dist/config.d.ts +127 -0
  11. package/dist/denylist.d.ts +33 -0
  12. package/dist/effort.d.ts +64 -0
  13. package/dist/guard.d.ts +23 -0
  14. package/dist/health.d.ts +43 -0
  15. package/dist/index.d.ts +9 -0
  16. package/dist/index.js +532 -149
  17. package/dist/plugin.d.ts +15 -0
  18. package/dist/profile.d.ts +399 -0
  19. package/dist/project.d.ts +8 -0
  20. package/dist/prune.d.ts +6 -0
  21. package/dist/router.d.ts +3 -0
  22. package/dist/rules.d.ts +109 -0
  23. package/dist/safe.d.ts +7 -0
  24. package/dist/telemetry-store.d.ts +12 -0
  25. package/dist/telemetry.d.ts +3 -0
  26. package/docs/EXIT-CODES.md +71 -0
  27. package/docs/KNOWN-LIMITS.md +45 -21
  28. package/docs/RESEARCH.md +9 -4
  29. package/docs/ROUTING.md +53 -8
  30. package/eval/RESULTS.md +443 -0
  31. package/eval/ab.mjs +743 -0
  32. package/eval/attribute.mjs +170 -0
  33. package/eval/flags.mjs +88 -0
  34. package/eval/measure-install.mjs +323 -0
  35. package/eval/measure-prune.mjs +339 -0
  36. package/eval/metrics.js +79 -0
  37. package/eval/premises.mjs +200 -0
  38. package/eval/prepare.mjs +233 -0
  39. package/eval/run.mjs +376 -0
  40. package/eval/tasks-self.json +81 -0
  41. package/eval/tasks-ts.json +50 -0
  42. package/eval/tasks.json +83 -0
  43. package/eval/thresholds.mjs +134 -0
  44. package/eval/verify.mjs +287 -0
  45. package/package.json +44 -17
  46. package/profiles/fleet-all.json +6 -1
  47. package/profiles/fleet-safe.json +7 -3
  48. package/profiles/schema.json +182 -51
  49. package/prompts/build.md +4 -2
  50. package/skills/space-build/SKILL.md +4 -2
  51. package/skills/space-finish/SKILL.md +6 -2
  52. package/skills/space-plan/reference/task-template.md +13 -5
  53. package/src/config.js +182 -32
  54. package/src/denylist.js +95 -0
  55. package/src/driver/run.mjs +81 -16
  56. package/src/effort.js +15 -4
  57. package/src/guard.ts +165 -13
  58. package/src/health.js +80 -0
  59. package/src/index.ts +15 -4
  60. package/src/lane-usage.js +72 -18
  61. package/src/plugin-type-check.ts +38 -0
  62. package/src/plugin.ts +39 -0
  63. package/src/profile.js +225 -26
  64. package/src/project.js +47 -0
  65. package/src/prune.ts +110 -31
  66. package/src/router.ts +32 -63
  67. package/src/rules.js +99 -7
  68. package/src/safe.ts +27 -0
  69. package/src/sqlite.js +144 -0
  70. package/src/tasks.js +112 -29
  71. package/src/telemetry-store.ts +62 -0
  72. package/src/telemetry.ts +60 -57
  73. package/src/trust.js +70 -9
@@ -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;
@@ -0,0 +1,6 @@
1
+ import type { Plugin } from "./plugin.js";
2
+ export declare const PrunePlugin: Plugin;
3
+ export declare const pruneMeta: {
4
+ note: string;
5
+ };
6
+ export default PrunePlugin;
@@ -0,0 +1,3 @@
1
+ import type { Plugin } from "./plugin.js";
2
+ export declare const RouterPlugin: Plugin;
3
+ export default RouterPlugin;
@@ -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,7 @@
1
+ /**
2
+ * Stringify a value, never throwing.
3
+ *
4
+ * @param {unknown} value
5
+ * @returns {string}
6
+ */
7
+ export declare function safeStringify(value: unknown): string;
@@ -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,3 @@
1
+ import type { Plugin } from "./plugin.js";
2
+ export declare const TelemetryPlugin: Plugin;
3
+ export default TelemetryPlugin;
@@ -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.