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.
Files changed (73) hide show
  1. package/CHANGELOG.md +527 -18
  2. package/CONTRIBUTING.md +242 -0
  3. package/GLOBAL.md +34 -5
  4. package/MIGRATION.md +151 -0
  5. package/README.md +307 -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,50 @@
1
+ {
2
+ "$comment": [
3
+ "TypeScript subject. The kit is TypeScript and the Python suite in tasks.json",
4
+ "never tested that, which is the gap this file exists to close.",
5
+ "The subject is a PRIVATE repository, so this suite is a pilot that",
6
+ "nobody can repeat. The repeatable one is tasks-self.json.",
7
+ "",
8
+ "Subject is vendor/gibwork-sentinel from gibwork-agent-harness: 692 lines of",
9
+ "TypeScript across 7 modules, shipped alongside compiled JavaScript in dist/.",
10
+ "Every answer below occurs in src/ and again in dist/. Each task says so",
11
+ "explicitly, because picking the build output instead of the source is the",
12
+ "trap and the reason this subject is worth having.",
13
+ "",
14
+ "All three wants were checked to occur in exactly one file under src/.",
15
+ "That check is what stops an ambiguous task - two of the earlier ones were",
16
+ "wrong for exactly that reason."
17
+ ],
18
+ "snapshot": "gibwork-agent-harness/vendor/gibwork-sentinel (not a git repo, copied as-is)",
19
+ "tasks": [
20
+ {
21
+ "id": "ts-rpc-default",
22
+ "task": "This package is shipped as both TypeScript sources and compiled JavaScript. In the TypeScript source only, not the build output, which module declares the default Solana RPC endpoint, and what is that URL? Report the file and line. Do not modify any files.",
23
+ "want": "https://api.mainnet-beta.solana.com"
24
+ },
25
+ {
26
+ "id": "ts-rest-client",
27
+ "task": "This package is shipped as both TypeScript sources and compiled JavaScript. In the TypeScript source only, not the build output, which module exports the class that talks to the Gibwork REST API? Report the file and line. Do not modify any files.",
28
+ "want": "export class GibworkClient"
29
+ },
30
+ {
31
+ "id": "ts-pr-template",
32
+ "task": "This package is shipped as both TypeScript sources and compiled JavaScript. In the TypeScript source only, not the build output, one module writes a pull request template file into the generated workspace. Which module writes it, and what is the filename? Report the file and line. Do not modify any files.",
33
+ "want": "PULL_REQUEST_TEMPLATE.md"
34
+ },
35
+ {
36
+ "id": "ts-edit-rpc",
37
+ "$comment": "The edit task, which the TypeScript suite was missing. mustNotContain is as important as mustContain: the same string is in dist/solana.js too, so an agent that edits the build output instead of the source gets mustNotContain for free and is caught.",
38
+ "task": "In the TypeScript source of this package, the Solana verifier defaults to the mainnet RPC endpoint. Change that default to the devnet endpoint at https://api.devnet.solana.com. Make no other changes and do not reformat anything.",
39
+ "mutate": {
40
+ "file": "src/solana.ts",
41
+ "mustContain": [
42
+ "api.devnet.solana.com"
43
+ ],
44
+ "mustNotContain": [
45
+ "api.mainnet-beta.solana.com"
46
+ ]
47
+ }
48
+ }
49
+ ]
50
+ }
@@ -0,0 +1,83 @@
1
+ {
2
+ "$comment": [
3
+ "Verification tasks for eval/ab.mjs --suite.",
4
+ "Written against a `git archive` snapshot of lablab-harness at 8a674f5.",
5
+ "",
6
+ "Grading is by citation, not by prose: each read task names `want`, a",
7
+ "substring of one real source line, and eval/verify.mjs resolves the path:line",
8
+ "the agent cited and reads that line out of the snapshot. An answer can only",
9
+ "pass by pointing at the code.",
10
+ "",
11
+ "The single-file lookups came first and are kept, because they are the",
12
+ "control. The multi-step ones are the point: this repository defines OUT in",
13
+ "6 modules and BASE in 19, so answering them means enumerating candidates and",
14
+ "then choosing, not hitting the right file first try.",
15
+ "",
16
+ "Every want below was read out of the snapshot with sed before it was written",
17
+ "here. Re-check with: git archive | tar -x && grep -n"
18
+ ],
19
+ "snapshot": "lablab-harness@8a674f5",
20
+ "tasks": [
21
+ {
22
+ "id": "read-control",
23
+ "$comment": "Control. One module, one constant.",
24
+ "task": "In this repository one module builds a result object containing a boolean field named pass. Report the exact file path and line number where that field is assigned, and quote the line. Do not modify any files.",
25
+ "want": "\"pass\": total >= 55"
26
+ },
27
+ {
28
+ "id": "read-multi-out",
29
+ "$comment": "Six modules define OUT at module level. The agent has to see all six to pick the one whose value is the weekly plan.",
30
+ "task": "Six modules in agent/ each define a module-level constant OUT pointing at a different output file. Which one of them points at work/plan-week.md? Report the file and the line. Do not modify any files.",
31
+ "want": "plan-week.md"
32
+ },
33
+ {
34
+ "id": "read-multi-gate",
35
+ "$comment": "judge.py has two real submit gates: line 345 rejects total < 5 with a hard failure, line 348 rejects total < 7 asking for a FIX. An earlier wording of this task said 'the comparison that rejects a too-low total' and a kit-off run cited line 345, which is a correct answer, and was scored wrong. Naming the threshold removes the ambiguity without naming the file.",
36
+ "task": "One module refuses to submit an entry and prints a message asking for a FIX before doing so. It has two such guards; report the one whose threshold is 7. Report the file, the line number and quote the comparison. Do not modify any files.",
37
+ "want": "total < 7"
38
+ },
39
+ {
40
+ "id": "read-multi-skills",
41
+ "$comment": "Needs two different files: research.py:20 for the output, research.py:21 for the skills path. want matches the second line.",
42
+ "task": "One module writes its findings to work/winners.md and reads its playbooks from the user's OpenCode skills directory rather than from the repository. Report both line numbers in that module, and quote the line that builds the skills path. Do not modify any files.",
43
+ "want": "\"opencode\" / \"skills\""
44
+ },
45
+ {
46
+ "id": "read-multi-skills-collision",
47
+ "$comment": "Two modules define SKILLS as a list of skill names and one defines it as a Path. A grep for SKILLS returns three hits, and only one of them is the path the task asks about. Premise checked with premises.mjs: the two list definitions are byte-identical, so the agent cannot tell them apart by content and has to read which variable each module goes on to use.",
48
+ "task": "Three modules define a module-level name SKILLS. In two of them it is a list of skill names; in one it is a Path built from the user's home directory. Report the file and line of the one that is a Path, and quote that line. Do not modify any files.",
49
+ "want": "Path.home() / \".config\" / \"opencode\" / \"skills\""
50
+ },
51
+ {
52
+ "id": "read-multi-weights-outliers",
53
+ "$comment": "Six profiles, and four of them share the same weights dict. An agent that reads the first profile and assumes the rest match is wrong twice over. The task cannot be answered by grepping one line: the answer requires comparing six dicts and finding which two differ. Premise verified by hand: 20/20/40/20 appears four times, and the two outliers are 20/15/40/25 and 25/25/30/20, so 'Business Value: 15' and 'Technology: 30' each occur exactly once in the file.",
54
+ "task": "One module defines six stack profiles. Four of the six have identical weights dictionaries. The other two differ from the majority. Report both outliers: the profile name and the exact weights line for each, quoting them. Do not modify any files.",
55
+ "want": "\"Presentation\": 25, \"Business Value\": 25, \"Technology\": 30"
56
+ },
57
+ {
58
+ "id": "edit-rename-output",
59
+ "$comment": "The first task that needs the kit to do work rather than to shortcut it. Graded on the file after the run, not on what the agent says it did. mustNotContain is as important as mustContain: appending the new path without removing the old one is not the change that was asked for.",
60
+ "task": "In agent/portfolio.py the weekly plan is written to work/plan-week.md. Change it so it is written to work/plan-week-v2.md instead. Make no other changes and do not reformat anything.",
61
+ "mutate": {
62
+ "file": "agent/portfolio.py",
63
+ "mustContain": [
64
+ "plan-week-v2.md"
65
+ ],
66
+ "mustNotContain": [
67
+ "plan-week.md\""
68
+ ]
69
+ }
70
+ },
71
+ {
72
+ "id": "edit-multi-profile",
73
+ "$comment": "The first task that is real work rather than a shortcut: six profiles, six edits, and an agent that stops after the first two is the obvious failure. mustContainMin counts occurrences, because a plain substring check passes that. An earlier version of this task said the weights sum to 100 and asked for a compensating cut. That premise was false, the sums were 85/80/75 and not 100, so all four agents correctly preserved sums that were never 100 and the grader passed them anyway. A grader that only counts 'Robustness' occurrences cannot tell that difference, which is the whole reason eval/premises.mjs and the prose premise in the task text both have to be right. Premise for the current wording re-verified against lablab-harness@8a674f5: six profiles in STACK_OVERRIDE, each with a four-dimension weights dict, so adding a fifth is correct, and the fallback dict at the bottom of the file has four dimensions too and must be left alone.",
74
+ "task": "In agent/rubric.py the STACK_OVERRIDE dict defines six stack profiles, and each profile's weights dictionary has four dimensions. Add a fifth dimension named Robustness with weight 10 to every one of those six profile dictionaries, so each profile has five dimensions. Leave the fallback dictionary at the bottom of the file alone. Make no other changes and do not reformat anything.",
75
+ "mutate": {
76
+ "file": "agent/rubric.py",
77
+ "mustContainMin": {
78
+ "Robustness": 6
79
+ }
80
+ }
81
+ }
82
+ ]
83
+ }
@@ -0,0 +1,134 @@
1
+ #!/usr/bin/env node
2
+ // thresholds.mjs - propose the floor for measure:check instead of guessing it.
3
+ //
4
+ // The gate had 50% because that looked about right. The right floor comes from
5
+ // what the install has actually done: the lowest day since the kit landed, less
6
+ // one observed spread, so ordinary day-to-day movement cannot trip it.
7
+ //
8
+ // node eval/thresholds.mjs human-readable
9
+ // node eval/thresholds.mjs --json
10
+ //
11
+ // It reports a floor. It does not apply one. A floor that rewrites itself is a
12
+ // floor that cannot catch a regression, because the day the kit breaks is also
13
+ // the day the floor moves down. Changing measure:check stays a human decision.
14
+
15
+ import { existsSync, statSync } from "node:fs"
16
+ import { homedir } from "node:os"
17
+ import { join } from "node:path"
18
+
19
+ import { median } from "./metrics.js"
20
+ import { countFlag } from "./flags.mjs"
21
+ import { canQuerySqlite, sqliteMissingMessage, query as queryDb } from "../src/sqlite.js"
22
+
23
+ /**
24
+ * The message of a caught value, whatever it turned out to be.
25
+ *
26
+ * `catch` gives `unknown` under strict mode, and the file is not strict -
27
+ * but a message is what these scripts exist to print.
28
+ *
29
+ * @param {unknown} err
30
+ * @returns {string}
31
+ */
32
+ function errorMessage(err) {
33
+ return err instanceof Error ? err.message : String(err)
34
+ }
35
+
36
+ const args = process.argv.slice(2)
37
+ const flag = (...n) => {
38
+ const i = args.findIndex((a) => n.includes(a))
39
+ return i >= 0 ? args[i + 1] : undefined
40
+ }
41
+ const wantJson = args.includes("--json")
42
+ const DB = flag("--db") ?? join(homedir(), ".local/share/opencode/opencode.db")
43
+
44
+ const installDir = join(homedir(), ".config/opencode/agents")
45
+ const split = existsSync(installDir) ? statSync(installDir).mtimeMs : Date.now() - 7 * 864e5
46
+
47
+ if (!existsSync(DB)) {
48
+ console.error(`opencode database not found: ${DB}`)
49
+ process.exit(1)
50
+ }
51
+
52
+ // Per day, since the install: how many reads, and how many were bounded. A day
53
+ // with very few reads says nothing, so it is excluded rather than allowed to
54
+ // drag the distribution.
55
+ // A malformed number used to become NaN and land in the SQL as
56
+ // `having reads >= NaN`, which sqlite rejects with a raw error.
57
+ let minReads
58
+ try {
59
+ minReads = countFlag("--min-reads") ?? 10
60
+ } catch (err) {
61
+ console.error(String(errorMessage(err)))
62
+ process.exit(1)
63
+ }
64
+ const sql = `
65
+ -- The day boundary is LOCAL, like every other day bucket in this repo. It
66
+ -- was hard-coded to UTC+7, so the threshold this script proposes was
67
+ -- bucketed on a different calendar than the window measure-install.mjs
68
+ -- compares it against.
69
+ select date(time_created/1000,'unixepoch','localtime') as day,
70
+ sum(json_extract(data,'$.tool')='read') as reads,
71
+ sum(case when json_extract(data,'$.tool')='read'
72
+ and (json_extract(data,'$.state.input.offset') is not null
73
+ or json_extract(data,'$.state.input.limit') is not null)
74
+ then 1 else 0 end) as narrow
75
+ from part
76
+ where json_extract(data,'$.type')='tool' and time_created >= ${split}
77
+ group by day having reads >= ${minReads} order by day`
78
+
79
+ const rows = queryDb(DB, sql)
80
+
81
+ const days = rows.map((r) => ({
82
+ day: r.day,
83
+ reads: Number(r.reads),
84
+ pct: Math.round((Number(r.narrow) / Number(r.reads)) * 1000) / 10,
85
+ }))
86
+
87
+ if (days.length < 2) {
88
+ console.error(`only ${days.length} day(s) with at least ${minReads} reads since the install.`)
89
+ console.error("A floor needs a spread to sit below. Nothing to propose.")
90
+ process.exit(1)
91
+ }
92
+
93
+ const pcts = days.map((d) => d.pct)
94
+ const low = Math.min(...pcts)
95
+ // Rounded before the subtraction: 75.2 - 60.9 is 14.299999999999997 otherwise,
96
+ // and a threshold report should not print that.
97
+ const spread = Math.round((Math.max(...pcts) - low) * 10) / 10
98
+ // One whole observed spread below the worst day.
99
+ const floor = Math.max(0, Math.round((low - spread) / 5) * 5)
100
+
101
+ const report = {
102
+ db: DB,
103
+ split: new Date(split).toISOString(),
104
+ min_reads_per_day: minReads,
105
+ days,
106
+ lowest_day: low,
107
+ median_day: median(pcts),
108
+ spread,
109
+ proposed_floor: floor,
110
+ }
111
+
112
+ if (wantJson) {
113
+ console.log(JSON.stringify(report, null, 2))
114
+ process.exit(0)
115
+ }
116
+
117
+ console.log("THRESHOLD PROPOSAL")
118
+ console.log(` db ${DB}`)
119
+ console.log(` split ${report.split}`)
120
+ console.log("")
121
+ console.log(` ${"day".padEnd(14)} ${"reads".padStart(8)} ${"narrow".padStart(8)}`)
122
+ for (const d of days) console.log(` ${d.day.padEnd(14)} ${String(d.reads).padStart(8)} ${`${d.pct}%`.padStart(8)}`)
123
+ console.log("")
124
+ console.log(` lowest day ${low}%`)
125
+ console.log(` median day ${report.median_day}%`)
126
+ console.log(` spread ${spread} points`)
127
+ console.log(` floor ${floor}% (lowest day, one spread below)`)
128
+ console.log("")
129
+ console.log(` to adopt: npm run measure -- --min-narrow ${floor} --min-ctx-drop <floor>`)
130
+ console.log("")
131
+ if (days.length < 7) {
132
+ console.log(` NOTE ${days.length} days is a small sample. One spread is not a confidence`)
133
+ console.log(" interval and this floor should be revisited as days accumulate.")
134
+ }
@@ -0,0 +1,287 @@
1
+ #!/usr/bin/env node
2
+ // verify.mjs - grade an answer against the snapshot, not against my guess.
3
+ //
4
+ // Three numbers in eval/RESULTS.md were wrong because the grader matched
5
+ // strings I had typed by hand:
6
+ //
7
+ // `--expect "judge.py|7"` passed answers containing the digit 7, which is
8
+ // every line number, and reported 3/3
9
+ // `expect: ["score.py"]` scored a correct answer wrong because the agent
10
+ // wrote "the rubric" instead of the filename
11
+ // `--expect judge.py` pointed at judge.py:348, which is `if total < 7`,
12
+ // a submit gate, not the field the task asked for
13
+ //
14
+ // None of those are wrong answers. They are a grader that trusts the agent's
15
+ // prose. This does not.
16
+ //
17
+ // The agent's answer is treated as a *claim about the repository*. Every
18
+ // file:line reference in it is extracted, resolved against the real snapshot,
19
+ // and the line at that position is read. The answer is correct when one of the
20
+ // references actually points at a line containing what was asked for. An agent
21
+ // that says "the rubric file" without a path names no reference and is scored
22
+ // wrong - which is the right default for a question that demanded a path.
23
+ //
24
+ // Nothing here is hand-written per task except the thing being looked for, and
25
+ // that is a substring of one line of real source that is verified to exist.
26
+
27
+ import { readFileSync, existsSync } from "node:fs"
28
+ import { join, isAbsolute, relative, resolve } from "node:path"
29
+
30
+ /**
31
+ * The message of a caught value, whatever it turned out to be.
32
+ *
33
+ * @param {unknown} err
34
+ * @returns {string}
35
+ */
36
+ function errorMessage(err) {
37
+ return err instanceof Error ? err.message : String(err)
38
+ }
39
+
40
+ /**
41
+ * Pull file:line references out of free text.
42
+ *
43
+ * Handles the shapes an agent actually writes:
44
+ * agent/score.py:214 path:line
45
+ * `agent/score.py` line 214 path ... line N
46
+ * agent/score.py (214) path (line)
47
+ * /abs/path/to/file.py:12 absolute
48
+ *
49
+ * Returns {file, line, text} where text is the span the match came from, so a
50
+ * caller can report what the agent claimed rather than only whether it held.
51
+ */
52
+ export function extractRefs(text) {
53
+ if (typeof text !== "string" || !text) return []
54
+ const refs = []
55
+ const push = (file, line) => {
56
+ const f = String(file).trim().replace(/^["'`]|["'`;,.)]+$/g, "")
57
+ if (!f) return
58
+ // Number(null) is 0, not NaN, so a bare path would be recorded as line 0
59
+ // and then window-checked against the top of the file.
60
+ const n = line === null || line === undefined ? NaN : Number(line)
61
+ refs.push({
62
+ file: f,
63
+ line: Number.isFinite(n) ? n : null,
64
+ text: f + (Number.isFinite(n) ? `:${n}` : ""),
65
+ })
66
+ }
67
+
68
+ // path:line
69
+ for (const m of text.matchAll(/([\w./@~-]+\.[A-Za-z0-9]+):(\d+)/g)) push(m[1], m[2])
70
+
71
+ // path ... line 214, on ONE line.
72
+ //
73
+ // The gap may not contain a full stop. A full stop is a sentence end, and
74
+ // an answer that says "the answer is in src/a.js. Unrelated note: see line
75
+ // 2 of the report" would otherwise pair the path with a line number that is
76
+ // about something else - inventing a claim the agent never made, and then
77
+ // scoring the answer against it. Under-claiming a line is better than
78
+ // claiming a wrong one.
79
+ //
80
+ // An ellipsis is not a sentence end. "see src/a.js ... line 5" is the
81
+ // shape the pattern is named after, and a full stop inside the gap lost
82
+ // it.
83
+ //
84
+ // The gap may not contain a full stop, EXCEPT the ones that are part of
85
+ // an ellipsis. "see src/a.js ... line 5" is the shape this pattern is
86
+ // named after, and a full stop inside the gap lost it.
87
+ for (const m of text.matchAll(
88
+ /([\w./@~-]+\.[A-Za-z0-9]+)(?:[^\n.]|\.(?=\.)|(?<=\.)\.){0,40}?\blines?\s*:?\s*[*:_`\s]*(\d+)/gi,
89
+ ))
90
+ push(m[1], m[2])
91
+
92
+ // path, then a line number on the NEXT line.
93
+ //
94
+ // The number has to be introduced by a label at the start of that line.
95
+ // An agent that answers in a markdown field writes the file on one line and
96
+ // the number on the next:
97
+ //
98
+ // **File:** `src/profile.js`
99
+ // **Line:** 105
100
+ //
101
+ // and a gap that stops at the newline never pairs them, so the citation is
102
+ // lost and a correct answer is scored wrong. Requiring the label is what
103
+ // keeps this from pairing a path with a line number that belongs to the
104
+ // next sentence.
105
+ //
106
+ // One blank line is allowed between the two fields. Markdown puts a blank
107
+ // line between a label and its value all the time, and a pattern that
108
+ // only allows adjacent lines loses the citation on a shape that is
109
+ // ordinary.
110
+ for (const m of text.matchAll(
111
+ /([\w./@~-]+\.[A-Za-z0-9]+)[^\n]{0,40}?\n(?:[ \t]*\n)?[ \t]*[*_`]{0,2}[Ll]ines?[*_`]{0,2}\s*:?\s*[*_`\s]*(\d+)/g,
112
+ ))
113
+ push(m[1], m[2])
114
+
115
+ // The markdown field form, paired explicitly: the file and the number are
116
+ // named as fields, so the pairing does not depend on how far apart they are.
117
+ for (const m of text.matchAll(
118
+ /[*_`]{0,2}[Ff]ile[*_`]{0,2}\s*:?\s*[*_`]*([\w@~-]+(?:\/[\w.@~-]+)*\.[A-Za-z0-9]+)[*_`]*[\s\S]{0,80}?[*_`]{0,2}[Ll]ine[*_`]{0,2}\s*:?\s*[*_`\s]*(\d+)/g,
119
+ ))
120
+ push(m[1], m[2])
121
+
122
+ // Markdown emphasis between the words, which is what a model writes when it
123
+ // answers: **File:** `agent/score.py` **Line:** 214
124
+ for (const m of text.matchAll(/\*{0,2}[Ll]ines?\*{0,2}\s*:?\s*[*`_\s]*(\d+)[\s\S]{0,40}?([\w@~-]+(?:\/[\w.@~-]+)*\.[A-Za-z0-9]+)/g))
125
+ push(m[2], m[1])
126
+
127
+ // path (214)
128
+ for (const m of text.matchAll(/([\w./@~-]+\.[A-Za-z0-9]+)\s*\((\d+)\)/g)) push(m[1], m[2])
129
+
130
+ // Bare paths, no line. Still worth resolving: a task may only ask which file.
131
+ for (const m of text.matchAll(/(?<![\w/])([\w@~-]+(?:\/[\w.@~-]+)+\.[A-Za-z0-9]+)/g)) push(m[1], null)
132
+
133
+ // De-duplicate on the claimed position, keeping order. A bare path is dropped
134
+ // when the same file was also cited with a line, since the line version is the
135
+ // stronger claim and grading both double-counts one answer.
136
+ //
137
+ // The pairing that produces the line claim is what has to be careful, then -
138
+ // and it is: a line that belongs to another sentence must not be paired with
139
+ // the file at all. That is fixed where the pairs are made, not here.
140
+ const seen = new Set()
141
+ const unique = refs.filter((r) => {
142
+ const key = `${r.file}:${r.line}`
143
+ if (seen.has(key)) return false
144
+ seen.add(key)
145
+ return true
146
+ })
147
+ const withLines = new Set(unique.filter((r) => r.line !== null).map((r) => r.file))
148
+ return unique.filter((r) => r.line !== null || !withLines.has(r.file))
149
+ }
150
+
151
+ /**
152
+ * Resolve a claimed path against the snapshot root, tolerating the absolute
153
+ * form an agent prints when it saw the real path. Returns null when the file is
154
+ * not in the snapshot, which is how a hallucinated citation fails.
155
+ */
156
+ export function resolveInTarget(target, ref) {
157
+ const candidates = []
158
+ if (isAbsolute(ref)) {
159
+ candidates.push(ref)
160
+ // The agent may print the temp path with /private stripped or added.
161
+ const rel = target.replace(/^\/private/, "")
162
+ if (ref.startsWith(rel)) candidates.push(`/private${ref}`)
163
+ if (ref.startsWith(`/private${rel}`)) candidates.push(ref.replace(/^\/private/, ""))
164
+ } else {
165
+ candidates.push(join(target, ref))
166
+ }
167
+ for (const c of candidates) {
168
+ const norm = resolve(c)
169
+ if (norm.startsWith(target) && existsSync(norm)) return norm
170
+ }
171
+ return null
172
+ }
173
+
174
+ /**
175
+ * Whether a cited path is one of the excluded files or inside an excluded
176
+ * directory.
177
+ *
178
+ * @param {string} file
179
+ * @param {string[]} exclude
180
+ * @returns {boolean}
181
+ */
182
+ export function isExcluded(file, exclude = []) {
183
+ const base = file.split("/").pop()
184
+ return exclude.some((x) => {
185
+ if (!x) return false
186
+ if (x.endsWith("/")) return file.startsWith(x)
187
+ // A file NAME excludes that file wherever it is: the suite is excluded as
188
+ // "tasks-self.json", and the citation is "eval/tasks-self.json".
189
+ if (file === x || base === x) return true
190
+ return file.startsWith(`${x}/`)
191
+ })
192
+ }
193
+
194
+ /**
195
+ * Grade one answer.
196
+ *
197
+ * `want` is a substring of the line that answers the task. A window of
198
+ * `window` lines is read around the claim, because an agent that names
199
+ * line 214 for a definition on 213 has still located it, and a task should not
200
+ * fail over an off-by-one in a natural-language citation.
201
+ *
202
+ * Returns every reference with its verdict rather than a single boolean, so a
203
+ * wrong answer can be read instead of guessed at.
204
+ */
205
+ export function gradeAnswer(target, text, want, { window = 1, exclude = [] } = {}) {
206
+ const refs = extractRefs(text)
207
+ const checked = refs.map((ref) => {
208
+ const abs = resolveInTarget(target, ref.file)
209
+ if (!abs) return { ...ref, status: "not-in-snapshot" }
210
+ // A citation to the SUITE is not evidence. The subject of the self
211
+ // suite is this repository, and the suite's answer key is inside it, so
212
+ // an agent that greps for the symbol can cite the answer key and pass
213
+ // without reading the source.
214
+ //
215
+ // The test is on the RESOLVED path, relative to the target, not on the
216
+ // string the agent typed. "./dist/index.js", "src/../dist/index.js" and
217
+ // "/tmp/scratch/self/dist/index.js" are three spellings of the same
218
+ // file, and the build output is the build output however it is written.
219
+ const rel = relative(target, abs)
220
+ if (isExcluded(rel, exclude) || isExcluded(ref.file, exclude)) {
221
+ return { ...ref, status: "excluded", resolved: rel }
222
+ }
223
+ let lines
224
+ try {
225
+ lines = readFileSync(abs, "utf8").split("\n")
226
+ } catch {
227
+ return { ...ref, status: "unreadable" }
228
+ }
229
+ const needle = String(want)
230
+ const at = ref.line
231
+ if (at !== null && at >= 1) {
232
+ const lo = Math.max(0, at - 1 - window)
233
+ const hi = Math.min(lines.length, at + window)
234
+ const slice = lines.slice(lo, hi).join("\n")
235
+ if (slice.includes(needle)) return { ...ref, status: "match", matched: slice.trim().slice(0, 160) }
236
+ return { ...ref, status: "line-mismatch", matched: slice.trim().slice(0, 160) }
237
+ }
238
+ // No line claimed: accept if the whole file contains the needle.
239
+ const body = lines.join("\n")
240
+ if (body.includes(needle)) return { ...ref, status: "match-file", matched: "" }
241
+ return { ...ref, status: "file-mismatch" }
242
+ })
243
+
244
+ const ok = checked.some((c) => c.status === "match" || c.status === "match-file")
245
+ // A citation is a file the agent named. One file cited twice - once with a
246
+ // line and once without - is one citation, not two.
247
+ const cited = new Set(
248
+ checked.filter((c) => c.status !== "not-in-snapshot").map((c) => c.file),
249
+ ).size
250
+ return { ok, refs: checked, cited }
251
+ }
252
+
253
+ /**
254
+ * Grade an edit, by looking at the file rather than at what the agent said it
255
+ * did. This is the only shape that tests whether the kit helps with work, as
256
+ * opposed to work it can shortcut.
257
+ *
258
+ * `mustContain` and `mustNotContain` are substrings of the final file. The
259
+ * second list matters as much as the first: an agent that appends the new value
260
+ * without removing the old one has not made the change it was asked for.
261
+ *
262
+ * `mustContainMin` counts occurrences instead of testing presence. A task that
263
+ * says "in every one of the six profiles" is not done by editing the first two,
264
+ * and a plain substring check would pass it happily.
265
+ */
266
+ export function gradeMutation(runDir, spec) {
267
+ const abs = resolveInTarget(runDir, spec.file)
268
+ if (!abs) return { ok: false, reason: `${spec.file} missing from the run copy` }
269
+ let body
270
+ try {
271
+ body = readFileSync(abs, "utf8")
272
+ } catch (e) {
273
+ return { ok: false, reason: `${spec.file} unreadable: ${errorMessage(e)}` }
274
+ }
275
+ const count = (s) => body.split(s).length - 1
276
+ for (const s of spec.mustContain ?? []) {
277
+ if (!body.includes(s)) return { ok: false, reason: `${spec.file} does not contain ${JSON.stringify(s)}` }
278
+ }
279
+ for (const [s, n] of Object.entries(spec.mustContainMin ?? {})) {
280
+ const got = count(s)
281
+ if (got < n) return { ok: false, reason: `${spec.file} contains ${JSON.stringify(s)} ${got} time(s), needs at least ${n}` }
282
+ }
283
+ for (const s of spec.mustNotContain ?? []) {
284
+ if (body.includes(s)) return { ok: false, reason: `${spec.file} still contains ${JSON.stringify(s)}` }
285
+ }
286
+ return { ok: true }
287
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-harness-kit",
3
- "version": "0.4.3",
3
+ "version": "0.4.4",
4
4
  "description": "Harness kit for OpenCode: capability profiles, a fleet of job lanes with one free model each, role-based reasoning effort, context pruning, and measured baselines.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/pham-canh/space-harness#readme",
@@ -16,13 +16,28 @@
16
16
  "module": "./dist/index.js",
17
17
  "exports": {
18
18
  ".": {
19
- "types": "./src/index.ts",
19
+ "types": "./dist/index.d.ts",
20
20
  "import": "./dist/index.js"
21
21
  },
22
- "./profile": "./src/profile.js",
23
- "./config": "./src/config.js",
24
- "./rules": "./src/rules.js",
25
- "./package.json": "./package.json"
22
+ "./config": {
23
+ "types": "./dist/config.d.ts",
24
+ "import": "./src/config.js"
25
+ },
26
+ "./denylist": {
27
+ "types": "./dist/denylist.d.ts",
28
+ "import": "./src/denylist.js"
29
+ },
30
+ "./lanes.schema.json": "./lanes.schema.json",
31
+ "./package.json": "./package.json",
32
+ "./profile": {
33
+ "types": "./dist/profile.d.ts",
34
+ "import": "./src/profile.js"
35
+ },
36
+ "./profiles/schema.json": "./profiles/schema.json",
37
+ "./rules": {
38
+ "types": "./dist/rules.d.ts",
39
+ "import": "./src/rules.js"
40
+ }
26
41
  },
27
42
  "bin": {
28
43
  "oc-harness": "bin/oc-harness.mjs"
@@ -42,25 +57,35 @@
42
57
  "CHANGELOG.md",
43
58
  "MIGRATION.md",
44
59
  "README.md",
45
- "LICENSE"
60
+ "CONTRIBUTING.md",
61
+ "SECURITY.md",
62
+ "LICENSE",
63
+ "eval/"
46
64
  ],
47
65
  "scripts": {
48
- "build": "esbuild src/index.ts --bundle --platform=node --format=esm --outfile=dist/index.js",
49
- "typecheck": "tsc --noEmit",
66
+ "build": "npm run build:types && esbuild src/index.ts --bundle --platform=node --format=esm --outfile=dist/index.js",
67
+ "typecheck": "tsc --noEmit && npm run typecheck:scripts",
68
+ "typecheck:scripts": "tsc -p tsconfig.scripts.json",
69
+ "pretest": "node -e \"const fs=require('fs');if(!fs.existsSync('test')){console.error('no test directory here - this is an installed package, not a checkout');process.exit(1)}\"",
50
70
  "test": "node --test \"test/*.test.js\"",
51
71
  "test:all": "npm run build && npm run typecheck && npm test",
72
+ "test:dist": "git diff --exit-code -- dist/ || (echo \"dist/ is out of sync with src/. Run: npm run build && git add dist/\" && exit 1)",
73
+ "check": "npm run build && npm run test:dist && npm run typecheck && npm test",
52
74
  "test:install": "npm run measure:check && npm run attribute",
53
- "check": "npm run test:all && npm run test:install && node bin/oc-harness.mjs verify",
54
- "check:online": "npm run check && node bin/oc-harness.mjs doctor",
75
+ "check:install": "npm run test:all && npm run test:install && node bin/oc-harness.mjs verify",
76
+ "check:online": "npm run check:install && node bin/oc-harness.mjs doctor",
77
+ "doctor": "node bin/oc-harness.mjs doctor",
55
78
  "baseline": "node bin/baseline.mjs",
56
79
  "release:trust": "node bin/oc-harness.mjs trust",
57
80
  "eval": "node eval/run.mjs",
58
81
  "measure": "node eval/measure-install.mjs",
59
- "measure:check": "node eval/measure-install.mjs --min-narrow 45 --min-ctx-drop 20",
82
+ "measure:prune": "node eval/measure-prune.mjs --sessions 60 --days 30 --min-saved 20",
83
+ "measure:check": "npm run measure:prune && node eval/measure-install.mjs --min-narrow 45 --min-ctx-drop 20",
60
84
  "attribute": "node eval/attribute.mjs",
61
85
  "attribute:json": "node eval/attribute.mjs --json",
62
86
  "thresholds": "node eval/thresholds.mjs",
63
- "prepublishOnly": "npm run build"
87
+ "prepublishOnly": "npm run build",
88
+ "build:types": "tsc -p tsconfig.types.json"
64
89
  },
65
90
  "keywords": [
66
91
  "opencode",
@@ -73,13 +98,15 @@
73
98
  "engines": {
74
99
  "node": ">=22"
75
100
  },
76
- "peerDependencies": {
77
- "@opencode-ai/plugin": ">=1.18.0"
78
- },
79
101
  "devDependencies": {
80
102
  "@opencode-ai/plugin": "^1.18.31",
81
103
  "@types/node": "^26.6.5",
104
+ "ajv": "^8.20.0",
82
105
  "esbuild": "^0.28.2",
83
106
  "typescript": "^7.0.2"
84
- }
107
+ },
108
+ "publishConfig": {
109
+ "access": "public"
110
+ },
111
+ "author": "pham-canh"
85
112
  }