opencode-harness-kit 0.0.0-stage → 0.2.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/GLOBAL.md +110 -0
- package/LICENSE +21 -0
- package/README.md +464 -2
- package/agents/build.md +66 -0
- package/agents/locate.md +48 -0
- package/agents/plan.md +61 -0
- package/agents/reviewer.md +62 -0
- package/agents/verify.md +54 -0
- package/bin/baseline.mjs +367 -0
- package/bin/git-hooks/pre-commit +30 -0
- package/bin/oc-harness.mjs +1025 -0
- package/dist/index.js +1038 -0
- package/docs/KNOWN-LIMITS.md +188 -0
- package/docs/RESEARCH.md +210 -0
- package/docs/demo/demo.gif +0 -0
- package/docs/demo/demo.mp4 +0 -0
- package/docs/demo/render.py +146 -0
- package/examples/muse-spark-1.3-contributor-free.json +41 -0
- package/package.json +81 -4
- package/profiles/deepseek-v4-flash-free.json +41 -0
- package/profiles/index.json +78 -0
- package/profiles/longcat-2.5-preview-free.json +41 -0
- package/profiles/muse-split-free.json +64 -0
- package/profiles/schema.json +161 -0
- package/profiles/space-bunny-free.json +42 -0
- package/profiles/x-preview-f-free.json +41 -0
- package/retention.yml +110 -0
- package/skills/space-build/SKILL.md +54 -0
- package/skills/space-finish/SKILL.md +63 -0
- package/skills/space-plan/SKILL.md +101 -0
- package/skills/space-plan/reference/task-template.md +47 -0
- package/skills/space-review/SKILL.md +53 -0
- package/src/config.js +358 -0
- package/src/effort.js +98 -0
- package/src/guard.ts +59 -0
- package/src/index.ts +80 -0
- package/src/profile.js +484 -0
- package/src/prune.ts +258 -0
- package/src/router.ts +192 -0
- package/src/rules.js +481 -0
- package/src/telemetry.ts +98 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: space-finish
|
|
3
|
+
description: Close out a task: confirm checks, summarise the change, and hand the publish decision to a human. Use when work is implemented and reviewed.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Finish
|
|
7
|
+
|
|
8
|
+
Reached only when `space-review` pass 1 was clean. If there is an open
|
|
9
|
+
blocker, you are at the wrong step: send it to `space-build` instead. Finishing
|
|
10
|
+
over an unreviewed or blocked change is the failure this skill exists to stop.
|
|
11
|
+
|
|
12
|
+
## Steps
|
|
13
|
+
|
|
14
|
+
1. **Read `.space/tasks.md` first.** Any task still `pending` or `blocked`
|
|
15
|
+
means the work is unfinished: stop and say which one. The file is the
|
|
16
|
+
requirement, same as it was for `space-review`.
|
|
17
|
+
2. **Run the checks one final time, on the final state of the tree.** A green
|
|
18
|
+
suite from before the last edit is not a green suite. Use `verify`.
|
|
19
|
+
3. **Read the diff once more as a whole.** Reviewers read task by task; you
|
|
20
|
+
read the change as someone who has never seen it will.
|
|
21
|
+
4. **Check the documentation.** Did this change make a document describe
|
|
22
|
+
something that no longer exists? Fix the document, do not note it for later.
|
|
23
|
+
5. **Write the summary** a person needs in order to trust the change without
|
|
24
|
+
reading the whole diff. Anything the Decisions section of `.space/tasks.md`
|
|
25
|
+
marked as deliberately not done belongs in the summary, with its reason.
|
|
26
|
+
6. **Check the install is still whole.** If this project is driven by a generated
|
|
27
|
+
config or profile, run its verify command. A hand-edit survives a regeneration
|
|
28
|
+
silently, and that is how a zero-retention claim ends up running on a
|
|
29
|
+
may-train model.
|
|
30
|
+
2. **Read the diff once more as a whole.** Reviewers read task by task; you
|
|
31
|
+
read the change as someone who has never seen it will.
|
|
32
|
+
3. **Check the documentation.** Did this change make a document describe
|
|
33
|
+
something that no longer exists? Fix the document, do not note it for later.
|
|
34
|
+
4. **Write the summary** a person needs in order to trust the change without
|
|
35
|
+
reading the whole diff.
|
|
36
|
+
5. **Check the install is still whole.** If this project is driven by a generated
|
|
37
|
+
config or profile, run its verify command. A hand-edit survives a regeneration
|
|
38
|
+
silently, and that is how a zero-retention claim ends up running on a
|
|
39
|
+
may-train model.
|
|
40
|
+
|
|
41
|
+
## Summary shape
|
|
42
|
+
|
|
43
|
+
- What changed, in one sentence.
|
|
44
|
+
- Which checks ran and their results, verbatim.
|
|
45
|
+
- What was deliberately not done, and why.
|
|
46
|
+
- Anything risky or irreversible.
|
|
47
|
+
|
|
48
|
+
## The publish boundary
|
|
49
|
+
|
|
50
|
+
Publishing needs human approval. Ask before `git push`, opening a pull request,
|
|
51
|
+
publishing a package, or changing repository visibility, even if the task
|
|
52
|
+
seems to imply it. Permissions default to ask so opencode prompts first, but
|
|
53
|
+
the boundary is yours to hold, not only the hook's.
|
|
54
|
+
|
|
55
|
+
Leave the tree in a state a human can review: on a branch, with the change
|
|
56
|
+
visible in `git diff`.
|
|
57
|
+
|
|
58
|
+
## Do not
|
|
59
|
+
|
|
60
|
+
- Do not declare success over a failing check.
|
|
61
|
+
- Do not squash, rebase, or force-push to tidy history.
|
|
62
|
+
- Do not delete a failing test to make the suite green. A reported failure is
|
|
63
|
+
information; a deleted test is lost information.
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: space-plan
|
|
3
|
+
description: Turn a vague request into an executable plan before any code is written. Use when a task is ambiguous, touches several files, or two people could disagree about done.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Plan before building
|
|
7
|
+
|
|
8
|
+
Most wasted agent time is not bad code, it is solving the wrong problem well.
|
|
9
|
+
|
|
10
|
+
## The loop these four skills form
|
|
11
|
+
|
|
12
|
+
```
|
|
13
|
+
space-plan ─one task─▶ space-build ─verify passes─▶ space-review ─pass 1 clean─▶ space-finish ─▶ human
|
|
14
|
+
▲ │
|
|
15
|
+
└──── back to build, on a blocker: the file:line, the requirement
|
|
16
|
+
missed, and the failing output
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
The only backwards edge is `space-review → space-build`: a blocker is the loop
|
|
20
|
+
working, not the loop failing, and the only way out of a blocker is to fix the
|
|
21
|
+
thing it names. `space-plan` is never a backwards edge. Each forward edge names
|
|
22
|
+
what it hands over:
|
|
23
|
+
|
|
24
|
+
| From | To | Hands over |
|
|
25
|
+
|---|---|---|
|
|
26
|
+
| `space-plan` | `space-build` | one task: exact paths, the concrete change, the verify command |
|
|
27
|
+
| `space-build` | `space-review` | the command that ran, and its verbatim output |
|
|
28
|
+
| `space-review` | `space-build` | on a blocker: the `file:line`, the requirement missed, the failing output |
|
|
29
|
+
| `space-review` | `space-finish` | pass 1 clean: the diff as a whole |
|
|
30
|
+
| `space-finish` | human | the summary, the checks, and what was deliberately left undone |
|
|
31
|
+
|
|
32
|
+
**Re-plan only when the request itself was wrong**, not when a task turns out
|
|
33
|
+
harder than estimated. The second case is a build failure and belongs in
|
|
34
|
+
`space-build`.
|
|
35
|
+
|
|
36
|
+
## Write the plan down
|
|
37
|
+
|
|
38
|
+
A plan that only lives in the context gets lost the first time the context is
|
|
39
|
+
pruned, which is exactly the moment the loop needs it. So the plan ends as a
|
|
40
|
+
file, and every later step reads the file instead of the conversation.
|
|
41
|
+
|
|
42
|
+
- Path: `.space/tasks.md` in the project you are planning for.
|
|
43
|
+
- Format: [`reference/task-template.md`](reference/task-template.md) — goal,
|
|
44
|
+
constraints, decisions, risks, and one task per bullet with its files, its
|
|
45
|
+
concrete change, and its verify command.
|
|
46
|
+
- `space-plan` creates it and keeps the Tasks section current. `space-build`
|
|
47
|
+
works from the task in the file. `space-review` diffs against it.
|
|
48
|
+
`space-finish` treats any task still `pending` or `blocked` as unfinished.
|
|
49
|
+
|
|
50
|
+
## When this applies
|
|
51
|
+
|
|
52
|
+
Use it when any of these is true:
|
|
53
|
+
|
|
54
|
+
- The request has no single obvious file to change.
|
|
55
|
+
- Nobody could tell from the diff whether the task was finished.
|
|
56
|
+
- More than one design would satisfy the wording.
|
|
57
|
+
- The task is a feature rather than a fix.
|
|
58
|
+
|
|
59
|
+
Skip it for a single obvious edit. Writing a plan for `fix the typo` is waste.
|
|
60
|
+
|
|
61
|
+
## Steps
|
|
62
|
+
|
|
63
|
+
1. **Read first, but narrowly.** Grep the file names that must be involved,
|
|
64
|
+
then read the functions you need with an offset. A plan built on assumed
|
|
65
|
+
paths is worse than no plan: it reads as confident and sends the build step
|
|
66
|
+
to the wrong place. Reading whole files to check whether a function exists is
|
|
67
|
+
not orientation, it is 20x the context.
|
|
68
|
+
2. **State the goal in one sentence, in the caller's words.** If you cannot,
|
|
69
|
+
you do not understand the request yet. Ask **one** specific question, and only
|
|
70
|
+
when a wrong guess would cost money, break the build irreversibly, publish
|
|
71
|
+
something, or be irreversible. Everything else you decide yourself.
|
|
72
|
+
3. **List the constraints.** What must not change. What is out of scope. What
|
|
73
|
+
already exists that must keep working.
|
|
74
|
+
4. **Break it into tasks that fit in a few minutes each.** For every task:
|
|
75
|
+
- exact file paths
|
|
76
|
+
- the concrete change, not "handle errors"
|
|
77
|
+
- the verify command, and what passing looks like
|
|
78
|
+
5. **Order so the repository stays working.** Every step should leave the tests
|
|
79
|
+
green. A plan whose middle step is red is a plan that gets abandoned.
|
|
80
|
+
6. **Name the risks** and the cheapest way to find out early.
|
|
81
|
+
7. **Write it to `.space/tasks.md`** using `reference/task-template.md`, then
|
|
82
|
+
hand that file over with the plan summary.
|
|
83
|
+
|
|
84
|
+
## What to hand off
|
|
85
|
+
|
|
86
|
+
The `.space/tasks.md` file, plus a summary a reviewer can read in one screen:
|
|
87
|
+
the goal sentence, the decisions you made rather than asked about, and the risks.
|
|
88
|
+
Then let the agent that runs the plan load the `space-build` skill for one task at
|
|
89
|
+
a time and `space-review` after the set. Use the `locate` agent to close gaps in
|
|
90
|
+
your evidence first: it is read-only, and a gap closed before the plan is written
|
|
91
|
+
costs a fraction of a gap found during the build.
|
|
92
|
+
|
|
93
|
+
Do not batch the builds. One task at a time is what keeps the verify command
|
|
94
|
+
attributable to a change.
|
|
95
|
+
|
|
96
|
+
## Failure modes
|
|
97
|
+
|
|
98
|
+
- Planning from the request alone, without reading code. This is the main one.
|
|
99
|
+
- Tasks sized "and then the rest". They cannot be verified, so they are not tasks.
|
|
100
|
+
- A plan that requires a decision you cannot make. That is a question, and it
|
|
101
|
+
belongs at the top, not buried in task 6.
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# Task contract: `.space/tasks.md`
|
|
2
|
+
|
|
3
|
+
`space-plan` writes this file into the project it is planning for. It is the
|
|
4
|
+
one artifact the whole loop reads, so it has to survive a session whose context
|
|
5
|
+
has been pruned. Never keep it in your head.
|
|
6
|
+
|
|
7
|
+
```markdown
|
|
8
|
+
# <goal, one sentence, in the caller's words>
|
|
9
|
+
|
|
10
|
+
## Constraints
|
|
11
|
+
- <what must not change, what must not be touched>
|
|
12
|
+
|
|
13
|
+
## Decisions
|
|
14
|
+
- <date> <decision, one line> — <why; links to plan/summary if there is one>
|
|
15
|
+
|
|
16
|
+
## Risks
|
|
17
|
+
- <the thing most likely to go wrong, and the cheapest way to find out early>
|
|
18
|
+
|
|
19
|
+
## Tasks
|
|
20
|
+
- [ ] <task, small enough to finish in a few minutes>
|
|
21
|
+
- files: <exact paths to change>
|
|
22
|
+
- change: <what the change is, concretely, not "handle errors">
|
|
23
|
+
- verify: <the exact command, and what passing looks like>
|
|
24
|
+
- status: pending | done | blocked
|
|
25
|
+
- [x] <finished task>
|
|
26
|
+
- files: <paths>
|
|
27
|
+
- change: <what was done, including anything the original task did not name>
|
|
28
|
+
- verify: <the command that was actually run, and its verbatim final lines>
|
|
29
|
+
- status: done
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
How the loop uses it:
|
|
33
|
+
|
|
34
|
+
| step | does |
|
|
35
|
+
|---|---|
|
|
36
|
+
| `space-plan` | creates and rewrites the Tasks section; records Decisions and Risks |
|
|
37
|
+
| `space-build` | reads the task, writes only what it names, marks it done with the real verify output |
|
|
38
|
+
| `space-review` | reads the Tasks section as the requirement, diffs against it in pass 1 |
|
|
39
|
+
| `space-finish` | treats any `pending` or `blocked` task as "not finished", and asks for the summary decision |
|
|
40
|
+
|
|
41
|
+
Rules:
|
|
42
|
+
|
|
43
|
+
- One file per body of work. Start a new one only when the goal changes.
|
|
44
|
+
- A task is `done` only with the verify command's real output pasted into its
|
|
45
|
+
`verify:` line. A task marked done without it has not been verified.
|
|
46
|
+
- Add a Decisions line the moment something was chosen rather than asked. That
|
|
47
|
+
is the line that stops the next session re-litigating it.
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: space-review
|
|
3
|
+
description: Review a change in two passes, spec compliance then code quality, without editing. Use before committing or deciding a task is done.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Review in two passes
|
|
7
|
+
|
|
8
|
+
The order matters. Quality opinions on a change that does the wrong thing are
|
|
9
|
+
noise, and they bury the finding that mattered.
|
|
10
|
+
|
|
11
|
+
## Pass 1 - does it do what was asked
|
|
12
|
+
|
|
13
|
+
Style is out of scope. Only the question of whether the task got done. The
|
|
14
|
+
requirement is the Tasks section of `.space/tasks.md`, in the format
|
|
15
|
+
`space-plan` wrote — not the conversation, and not your guess at the
|
|
16
|
+
intent. If there is no file, there is no requirement to review against: say
|
|
17
|
+
so and stop.
|
|
18
|
+
|
|
19
|
+
- Every requirement in the task met? Name the ones that are not, with
|
|
20
|
+
`file:line`.
|
|
21
|
+
- Anything in the diff the task did not ask for?
|
|
22
|
+
- Does the verify command the task named exist, and does it pass?
|
|
23
|
+
- Any file described as changed that is not actually in the diff?
|
|
24
|
+
|
|
25
|
+
Any failure here is a **blocker**. Report it and hand it back to `space-build`
|
|
26
|
+
with the location and the verify output that shows it. A review that stops
|
|
27
|
+
without naming where to go next is a half result: the task is still open and
|
|
28
|
+
now has an extra person who knows it is.
|
|
29
|
+
|
|
30
|
+
## Pass 2 - is it any good
|
|
31
|
+
|
|
32
|
+
Only if pass 1 was clean.
|
|
33
|
+
|
|
34
|
+
- Duplication that will cost something on the next change
|
|
35
|
+
- Error paths that swallow the reason a thing failed
|
|
36
|
+
- Edge cases the tests do not cover, named specifically rather than gestured at
|
|
37
|
+
- Names that do not match what they hold
|
|
38
|
+
- Dead code, debug leftovers, commented-out blocks
|
|
39
|
+
|
|
40
|
+
## Rules
|
|
41
|
+
|
|
42
|
+
- Every finding needs a location and a consequence. "This could be clearer" is
|
|
43
|
+
not a finding. "If the API returns a list, this takes the first element and
|
|
44
|
+
silently drops the rest" is.
|
|
45
|
+
- Severity is real: `blocker`, `should-fix`, `nit`. Do not inflate to look
|
|
46
|
+
thorough.
|
|
47
|
+
- Propose the smallest change that removes the defect. Not a rewrite.
|
|
48
|
+
- **Silence is a valid result.** Say the diff is clean. Inventing findings to
|
|
49
|
+
fill a review costs the author more than an empty review.
|
|
50
|
+
|
|
51
|
+
## Report
|
|
52
|
+
|
|
53
|
+
Two sections. If pass 1 blocked, one section plus the reason.
|
package/src/config.js
ADDED
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
// Compile a profile into an opencode.jsonc.
|
|
2
|
+
//
|
|
3
|
+
// Config that does not depend on the model - permission allowlist, pruning
|
|
4
|
+
// defaults, experimental flags - is fixed here. Everything model-specific is
|
|
5
|
+
// derived from the profile, so switching models changes only the profile file.
|
|
6
|
+
|
|
7
|
+
import { pruneThreshold, reservedTokens, inputCap, effectiveRetention, resolveRoleModel, resolveRoles, resolveDefaultVariant, roleSplitModels } from "./profile.js"
|
|
8
|
+
|
|
9
|
+
import { join } from "node:path"
|
|
10
|
+
import { homedir } from "node:os"
|
|
11
|
+
|
|
12
|
+
/** @typedef {import("./profile.js").Profile} Profile */
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Where the global rule set lives, as opencode should be told to look for it.
|
|
16
|
+
*
|
|
17
|
+
* Honours OPENCODE_CONFIG_DIR, because `~/.config/opencode` is only the default
|
|
18
|
+
* and emitting it unconditionally points at the wrong file for anyone using a
|
|
19
|
+
* custom config directory - the installer would write one place and the
|
|
20
|
+
* compiler would name another. Written with a leading `~` when it is the
|
|
21
|
+
* default, which is both correct and readable in the generated file.
|
|
22
|
+
*/
|
|
23
|
+
export function globalInstructionsPath() {
|
|
24
|
+
const dir = process.env.OPENCODE_CONFIG_DIR ?? join(homedir(), ".config", "opencode")
|
|
25
|
+
if (dir === join(homedir(), ".config", "opencode")) return "~/.config/opencode/GLOBAL.md"
|
|
26
|
+
return join(dir, "GLOBAL.md")
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The default location, for documentation and tests. Prefer
|
|
31
|
+
* `globalInstructionsPath()` when generating a config.
|
|
32
|
+
*
|
|
33
|
+
* It is written by `oc-harness install`, which symlinks this file rather than
|
|
34
|
+
* copying it, so the repo stays the source of truth. `oc-harness verify` fails if
|
|
35
|
+
* the path it names does not resolve - the failure mode where a config points at
|
|
36
|
+
* an instruction file that was never installed is silent and makes the whole
|
|
37
|
+
* harness a no-op.
|
|
38
|
+
*/
|
|
39
|
+
export const GLOBAL_INSTRUCTIONS = "~/.config/opencode/GLOBAL.md"
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Read-only and verification commands.
|
|
43
|
+
*
|
|
44
|
+
* Measured against 5,239 real bash calls from one install: 95.3% would prompt
|
|
45
|
+
* under a narrow allowlist, 23% under this one. The gated remainder is
|
|
46
|
+
* deliberate - ssh, curl, rm, pkill, osascript, cp, mv, kill, docker.
|
|
47
|
+
*
|
|
48
|
+
* `X*` with no space is used on purpose: opencode's `X *` pattern requires a
|
|
49
|
+
* trailing space and therefore does not match a bare `X`.
|
|
50
|
+
*
|
|
51
|
+
* The `gh` verbs below are the read half of the CLI and nothing else. `gh pr
|
|
52
|
+
* create`, `gh pr merge` and `gh api` are NOT here: they write, and `gh api` in
|
|
53
|
+
* particular can POST to any endpoint. Measured cost of gating the whole `gh`
|
|
54
|
+
* surface was 146 prompts; gating only the mutating verbs keeps those 146
|
|
55
|
+
* prompt-free without opening a publish path the guard plugin cannot police.
|
|
56
|
+
*/
|
|
57
|
+
export const BASH_ALLOWLIST = [
|
|
58
|
+
"ls*", "cat*", "head*", "tail*", "wc*", "file*", "stat*", "pwd*", "env*", "printenv*",
|
|
59
|
+
"date*", "whoami*", "which*", "find*", "tree*", "du*", "df*", "lsof*", "sed*", "awk*",
|
|
60
|
+
"sort*", "uniq*", "diff*", "grep*", "rg*", "echo*", "printf*", "sleep*",
|
|
61
|
+
"cd*", "realpath*", "basename*", "dirname*", "readlink*",
|
|
62
|
+
"for*", "set*", "export*", "unset*", "bash*", "sh*", "true*", "false*", "test*",
|
|
63
|
+
"read*", "#*", "xargs*", "seq*", "exit*", ":*",
|
|
64
|
+
"node*", "python3*", "python*", "npm*", "bun*", "uv*", "go*", "cargo*", "rustc*",
|
|
65
|
+
"opencode*", "sqlite3*", "jq*", "fd*",
|
|
66
|
+
"git status*", "git log*", "git diff*", "git show*", "git branch*",
|
|
67
|
+
"git rev-parse*", "git config --get*", "git remote*", "git blame*",
|
|
68
|
+
"git stash list*", "git ls-files*", "git describe*", "git shortlog*", "git --version*",
|
|
69
|
+
// `git -C <dir> <read-verb>` only. A bare `git -C *` would allow `git -C x push`.
|
|
70
|
+
"git -C * status*", "git -C * log*", "git -C * diff*", "git -C * rev-parse*",
|
|
71
|
+
"git -C * ls-files*", "git -C * show*",
|
|
72
|
+
// Staging is local and reversible; publishing is not and stays gated.
|
|
73
|
+
"git add*",
|
|
74
|
+
// `chmod 777` is denied below. `+x` is ordinary build work.
|
|
75
|
+
"chmod +x*", "chmod u+x*",
|
|
76
|
+
"mkdir*", "ps*",
|
|
77
|
+
"gh pr list*", "gh pr view*", "gh pr diff*", "gh pr checks*", "gh pr status*",
|
|
78
|
+
"gh issue list*", "gh issue view*", "gh repo view*", "gh auth status*",
|
|
79
|
+
"PYTEST_DISABLE_PLUGIN_AUTOLOAD=1*", "PYTHONPATH=*", "PYTHONUNBUFFERED=1*",
|
|
80
|
+
"python3 -m pytest*", "python3 -m venv*", "python3 -m compileall*",
|
|
81
|
+
"python3 -c*", "python3 agent/*", "python3 src/*", "python3 -",
|
|
82
|
+
".venv/bin/python*", "pytest*", "ruff*", "mypy*", "black*",
|
|
83
|
+
"npm test*", "npm run *", "npm ci*", "npm ls*", "npm view *",
|
|
84
|
+
"npx*", "uv run *", "uv pip *", "bun run *", "bunx*", "node -e*",
|
|
85
|
+
"opencode debug*", "opencode --version*", "opencode models*",
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
/** Hard blocks. The guard plugin enforces the same set in code. */
|
|
89
|
+
export const BASH_DENYLIST = [
|
|
90
|
+
// `git push` is intentionally absent: it falls through to the `*` ask rule
|
|
91
|
+
// so opencode prompts for human approval before publishing.
|
|
92
|
+
"rm -rf *",
|
|
93
|
+
"rm -fr *",
|
|
94
|
+
"curl * | sh",
|
|
95
|
+
"curl * | bash",
|
|
96
|
+
"chmod 777 *",
|
|
97
|
+
"chmod 777*",
|
|
98
|
+
"gh pr create*",
|
|
99
|
+
"gh pr merge*",
|
|
100
|
+
"gh pr close*",
|
|
101
|
+
"gh release*",
|
|
102
|
+
"gh repo delete*",
|
|
103
|
+
]
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Iteration cap per role, written as `agent.<role>.steps`.
|
|
107
|
+
*
|
|
108
|
+
* `steps` is a loop breaker rather than a quality knob. On exhaustion OpenCode
|
|
109
|
+
* forces a text-only response, so an agent that has stopped making progress
|
|
110
|
+
* reports what it has instead of silently looping until the context is gone.
|
|
111
|
+
* The caps are sized to the role's real work: build gets room because it runs
|
|
112
|
+
* the edit-verify-fix cycle, verify gets room because a full suite is many
|
|
113
|
+
* commands, and the read-only roles are capped tight because they have one job.
|
|
114
|
+
*/
|
|
115
|
+
export const AGENT_STEPS = /** @type {Record<string, number>} */ ({
|
|
116
|
+
build: 40,
|
|
117
|
+
plan: 25,
|
|
118
|
+
reviewer: 15,
|
|
119
|
+
locate: 12,
|
|
120
|
+
verify: 20,
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
/** Roles the kit writes, with the permissions that keep them honest. */
|
|
124
|
+
const AGENT_PERMISSIONS = /** @type {Record<string, Record<string, string>>} */ ({
|
|
125
|
+
plan: { edit: "deny" },
|
|
126
|
+
reviewer: { edit: "deny" },
|
|
127
|
+
locate: { edit: "deny", bash: "deny" },
|
|
128
|
+
verify: { edit: "deny" },
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* @param {Profile} p
|
|
133
|
+
* @param {{ base?: Record<string, any>, comments?: boolean }} [opts]
|
|
134
|
+
* @returns {string}
|
|
135
|
+
*/
|
|
136
|
+
export function generateConfig(p, opts = {}) {
|
|
137
|
+
const base = opts.base ?? {}
|
|
138
|
+
const model = `${p.provider}/${p.model}`
|
|
139
|
+
const roles = resolveRoles(p)
|
|
140
|
+
const instructionsPath = globalInstructionsPath()
|
|
141
|
+
|
|
142
|
+
const agent = { ...(base.agent ?? {}) }
|
|
143
|
+
for (const [role, level] of Object.entries(roles)) {
|
|
144
|
+
if (level === undefined) continue
|
|
145
|
+
const existing = { ...(agent[role] ?? {}) }
|
|
146
|
+
const rm = resolveRoleModel(p, role)
|
|
147
|
+
// The model is pinned, never inherited. Preserving a model already written
|
|
148
|
+
// on the agent is how `build` ended up on a `may-train` model while the rest
|
|
149
|
+
// of the file claimed the profile's zero-retention one: the compiler was
|
|
150
|
+
// asked to re-run over a hand-edited config and kept the hand-edit.
|
|
151
|
+
//
|
|
152
|
+
// `roleModels` is the one sanctioned way a role differs, because it is
|
|
153
|
+
// declared in the profile next to the model it points at and validated
|
|
154
|
+
// against retention.yml. Still an assignment, never a preserved value.
|
|
155
|
+
existing.model = rm ? `${p.provider}/${rm.model}` : model
|
|
156
|
+
if (level === null) delete existing.variant
|
|
157
|
+
else existing.variant = level
|
|
158
|
+
if (AGENT_PERMISSIONS[role]) {
|
|
159
|
+
existing.permission = {
|
|
160
|
+
...(existing.permission ?? {}),
|
|
161
|
+
...AGENT_PERMISSIONS[role],
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
if (AGENT_STEPS[role]) existing.steps = AGENT_STEPS[role]
|
|
165
|
+
agent[role] = existing
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const bashRules = {
|
|
169
|
+
"*": "ask",
|
|
170
|
+
...Object.fromEntries(BASH_ALLOWLIST.map((rule) => [rule, "allow"])),
|
|
171
|
+
...Object.fromEntries(BASH_DENYLIST.map((rule) => [rule, "deny"])),
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const compaction = { ...(base.compaction ?? {}) }
|
|
175
|
+
// `tail_turns` is dropped even if the inherited config had one. It only
|
|
176
|
+
// applies during compaction, and compaction is keyed off the full context
|
|
177
|
+
// ceiling, which a normal session never reaches. On the measured install it
|
|
178
|
+
// had never fired once, so it was inert config.
|
|
179
|
+
delete compaction.tail_turns
|
|
180
|
+
compaction.auto = true
|
|
181
|
+
compaction.prune = true
|
|
182
|
+
compaction.reserved = reservedTokens(p)
|
|
183
|
+
|
|
184
|
+
const base_ = { ...base }
|
|
185
|
+
// Two keys are dropped from the inherited config outright rather than merged.
|
|
186
|
+
// Both are cases where an existing value silently defeats the compiler:
|
|
187
|
+
// `variant` is rejected by the schema and stripped at load, so keeping it
|
|
188
|
+
// makes the file look tiered while doing nothing; and `tool_output` set tight
|
|
189
|
+
// by an earlier version would otherwise survive every regeneration.
|
|
190
|
+
delete base_.variant
|
|
191
|
+
delete base_.tool_output
|
|
192
|
+
|
|
193
|
+
const out = {
|
|
194
|
+
$schema: "https://opencode.ai/config.json",
|
|
195
|
+
...base_,
|
|
196
|
+
|
|
197
|
+
model,
|
|
198
|
+
// Same model, not a cheaper one: swapping models mid-session changes the
|
|
199
|
+
// voice and the tool habits. Lower the effort instead. An inherited
|
|
200
|
+
// small_model pointing at a different model is replaced, not preserved.
|
|
201
|
+
small_model: model,
|
|
202
|
+
// No top-level `variant`. The 1.18.35 Config schema sets
|
|
203
|
+
// `additionalProperties: false` and has no `variant` key, so OpenCode
|
|
204
|
+
// strips it from the resolved config - verified with `opencode debug
|
|
205
|
+
// config`. The per-role tiering is carried by `agent.<role>.variant`,
|
|
206
|
+
// which is a real schema key, and `effort.js` sets the session default at
|
|
207
|
+
// the `config` hook where the runtime does still see it.
|
|
208
|
+
default_agent: base.default_agent ?? "build",
|
|
209
|
+
|
|
210
|
+
// The default is 1, which blocks subagents from spawning subagents and so
|
|
211
|
+
// removes every nested review loop.
|
|
212
|
+
subagent_depth: base.subagent_depth ?? 2,
|
|
213
|
+
// Omitting `lsp` disables it entirely, so this must be set explicitly.
|
|
214
|
+
lsp: base.lsp ?? true,
|
|
215
|
+
instructions: base.instructions ?? [instructionsPath],
|
|
216
|
+
|
|
217
|
+
compaction,
|
|
218
|
+
// OpenCode's own default (2000 lines / 51200 bytes), reached back from an
|
|
219
|
+
// earlier 200 / 8192. The tight setting truncated 415 of 2,959 measured
|
|
220
|
+
// `read` calls, and 418 calls were issued with an `offset` to fetch the
|
|
221
|
+
// rest: the truncation causes the re-read, one for one. At the measured
|
|
222
|
+
// 17.3s median per request that round trip costs far more than the tokens
|
|
223
|
+
// it saves, and prune.ts already bounds old tool output deterministically.
|
|
224
|
+
// Only 14 of 2,959 reads exceed 51,200 bytes, so this ceiling is not the
|
|
225
|
+
// thing that limits context. `oc-harness baseline` re-measures all four
|
|
226
|
+
// numbers if this ever needs arguing again.
|
|
227
|
+
tool_output: { max_lines: 2000, max_bytes: 51200 },
|
|
228
|
+
|
|
229
|
+
skills: base.skills ?? {},
|
|
230
|
+
|
|
231
|
+
experimental: {
|
|
232
|
+
batch_tool: true,
|
|
233
|
+
continue_loop_on_deny: true,
|
|
234
|
+
...(base.experimental ?? {}),
|
|
235
|
+
},
|
|
236
|
+
|
|
237
|
+
permission: {
|
|
238
|
+
...(base.permission ?? {}),
|
|
239
|
+
read: "allow",
|
|
240
|
+
// `allow` here is load-bearing in a way that is easy to get backwards, and
|
|
241
|
+
// it is pinned rather than inherited for the same reason the model is: an
|
|
242
|
+
// inherited `ask` survives regeneration and re-breaks every read-only
|
|
243
|
+
// role.
|
|
244
|
+
//
|
|
245
|
+
// OpenCode appends the global rule AFTER the agent's own, and last match
|
|
246
|
+
// wins, so a global `edit: "ask"` silently overrides every agent's
|
|
247
|
+
// `edit: "deny"` and the read-only roles can edit files after a prompt.
|
|
248
|
+
// `allow` is the implicit default and emits no rule at all, which leaves
|
|
249
|
+
// the per-agent `deny` intact. Writes outside the project are still
|
|
250
|
+
// gated by `external_directory`, which defaults to `ask`.
|
|
251
|
+
edit: "allow",
|
|
252
|
+
// OpenCode ships this as `deny` on every built-in agent, so a genuinely
|
|
253
|
+
// blocked agent has no cheap way to say so and falls back to prose. One
|
|
254
|
+
// structured question is cheaper to answer and easier to act on.
|
|
255
|
+
question: "allow",
|
|
256
|
+
doom_loop: base.permission?.doom_loop ?? "ask",
|
|
257
|
+
bash: bashRules,
|
|
258
|
+
},
|
|
259
|
+
|
|
260
|
+
agent,
|
|
261
|
+
|
|
262
|
+
// The router plugin reads this at startup to know which fallback queue to
|
|
263
|
+
// walk, and it is also how a profile stays identifiable after the config
|
|
264
|
+
// has been hand-edited.
|
|
265
|
+
harness: {
|
|
266
|
+
profile: p.id,
|
|
267
|
+
...(p.fallback?.length ? { fallback: p.fallback[0] } : {}),
|
|
268
|
+
threshold: pruneThreshold(p),
|
|
269
|
+
},
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return render(out, p, { comments: opts.comments ?? true, instructionsPath })
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* @param {Record<string, any>} cfg
|
|
277
|
+
* @param {Profile} p
|
|
278
|
+
* @param {{ comments: boolean, instructionsPath?: string }} opts
|
|
279
|
+
* @returns {string}
|
|
280
|
+
*/
|
|
281
|
+
function render(cfg, p, opts) {
|
|
282
|
+
const c = opts.comments ? "// " : ""
|
|
283
|
+
const instructionsPath = opts.instructionsPath ?? globalInstructionsPath()
|
|
284
|
+
const input = p.limits.input ?? p.limits.context
|
|
285
|
+
const levels = p.effort?.levels ?? []
|
|
286
|
+
const roles = resolveRoles(p)
|
|
287
|
+
|
|
288
|
+
const effective = effectiveRetention(p)
|
|
289
|
+
// The input cap that actually governs context management, which on a split
|
|
290
|
+
// profile is the widest role model rather than the base. The header must name
|
|
291
|
+
// it, or `reserved` and `prune` read as arithmetic against a number that is
|
|
292
|
+
// not the one they used.
|
|
293
|
+
const effectiveInput = inputCap(p)
|
|
294
|
+
const splits = Object.keys(p.roleModels ?? {})
|
|
295
|
+
const head = [
|
|
296
|
+
`${c}Generated by opencode-harness-kit. Edit profiles/${p.id}.json instead`,
|
|
297
|
+
`${c}of this file, then re-run: oc-harness use ${p.id}`,
|
|
298
|
+
`${c}`,
|
|
299
|
+
`${c}profile ${p.id}`,
|
|
300
|
+
`${c}model ${p.provider}/${p.model}`,
|
|
301
|
+
...(splits.length
|
|
302
|
+
? [
|
|
303
|
+
`${c}split ${splits.map((r) => `${r}=${p.roleModels?.[r]?.model}`).join(" ")}`,
|
|
304
|
+
`${c} (roles not listed above run on the base model above)`,
|
|
305
|
+
]
|
|
306
|
+
: []),
|
|
307
|
+
`${c}context ${p.limits.context.toLocaleString("en-US")} tokens`,
|
|
308
|
+
`${c}input cap ${input.toLocaleString("en-US")} tokens${effectiveInput !== input ? ` (base model; ${effectiveInput.toLocaleString("en-US")} governs split roles)` : ""}`,
|
|
309
|
+
`${c}output cap ${p.limits.output.toLocaleString("en-US")} tokens`,
|
|
310
|
+
`${c}reasoning ${levels.length ? levels.join(" < ") : "(not tunable)"}`,
|
|
311
|
+
// The header must name the retention that actually applies, which with a
|
|
312
|
+
// split is the least private model in the file, not the base one. Printing
|
|
313
|
+
// `p.retention` here is the same class of bug as the inherited `build`
|
|
314
|
+
// model: the file claims a privacy property the agents do not have.
|
|
315
|
+
`${c}retention ${effective.retention}${effective.retention === "may-train" ? " <-- prompts may train a model" : ""}`,
|
|
316
|
+
...(effective.retention !== p.retention
|
|
317
|
+
? [
|
|
318
|
+
`${c} base ${p.provider}/${p.model} is ${p.retention}, but ${effective.worst} makes the whole file ${effective.retention}`,
|
|
319
|
+
]
|
|
320
|
+
: []),
|
|
321
|
+
`${c}`,
|
|
322
|
+
`${c}Derived numbers, do not hand-edit:`,
|
|
323
|
+
`${c} compaction.reserved ${reservedTokens(p).toLocaleString("en-US")} (${Math.round((p.effort?.reserveShare ?? 0.12) * 100)}% of the governing input cap${effectiveInput !== input ? `, ${effectiveInput.toLocaleString("en-US")} for the widest split role` : ""}, held back for reasoning growth)`,
|
|
324
|
+
`${c} prune threshold ${pruneThreshold(p).toLocaleString("en-US")} (governing input cap minus reserve)`,
|
|
325
|
+
`${c} role effort ${Object.entries(roles).map(([k, v]) => `${k}=${v ?? "unset"}`).join(" ")}`,
|
|
326
|
+
...(roleSplitModels(p).length
|
|
327
|
+
? [
|
|
328
|
+
`${c} role models ${roleSplitModels(p).map((r) => `${r.role}=${r.model}${r.limits ? ` (out ${r.limits.output.toLocaleString("en-US")})` : ""}`).join(" ")}`,
|
|
329
|
+
]
|
|
330
|
+
: []),
|
|
331
|
+
`${c} agent steps ${Object.entries(AGENT_STEPS).filter(([r]) => roles[r]).map(([r, n]) => `${r}=${n}`).join(" ")}`,
|
|
332
|
+
]
|
|
333
|
+
|
|
334
|
+
if (!levels.length) {
|
|
335
|
+
head.push(`${c}`, `${c}This model publishes no tunable reasoning effort, so no variant is written.`)
|
|
336
|
+
} else {
|
|
337
|
+
head.push(
|
|
338
|
+
`${c}`,
|
|
339
|
+
`${c}Effort is per-role above. There is no top-level "variant" key: the 1.18.35`,
|
|
340
|
+
`${c}Config schema rejects it, and the router sets the session default at runtime.`,
|
|
341
|
+
)
|
|
342
|
+
}
|
|
343
|
+
if (p.supports?.temperature === false) {
|
|
344
|
+
head.push(`${c}This model does not support temperature, so none is set.`)
|
|
345
|
+
}
|
|
346
|
+
if (p.prune?.enabled) {
|
|
347
|
+
head.push(
|
|
348
|
+
`${c} prune keeps ${p.prune.keepRecentToolCalls ?? 6} tool calls, ${p.prune.keepRecentTurns ?? 3} turns verbatim`,
|
|
349
|
+
)
|
|
350
|
+
}
|
|
351
|
+
head.push(
|
|
352
|
+
`${c}`,
|
|
353
|
+
`${c}Run "oc-harness verify" to check this file still matches the profile and`,
|
|
354
|
+
`${c}that the agents and ${instructionsPath} are actually installed.`,
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
return `${head.join("\n")}\n${JSON.stringify(cfg, null, 2)}\n`
|
|
358
|
+
}
|