opencode-harness-kit 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/GLOBAL.md +1 -1
- package/README.md +90 -10
- package/agents/build.md +2 -2
- package/bin/oc-harness.mjs +148 -42
- package/dist/index.js +197 -64
- package/package.json +8 -6
- package/profiles/index.json +18 -0
- package/profiles/muse-split-free.json +64 -0
- package/profiles/schema.json +29 -0
- package/profiles/space-bunny-free.json +1 -1
- package/skills/space-finish/SKILL.md +4 -14
- package/src/config.js +40 -8
- package/src/driver/run.mjs +237 -0
- package/src/profile.js +269 -14
- package/src/rules.js +3 -18
- package/src/tasks.js +248 -0
package/src/tasks.js
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
// Parse and rewrite `.space/tasks.md`, the one artifact the whole loop reads.
|
|
2
|
+
//
|
|
3
|
+
// The driver reads it to decide what to do next, so a mistake here is a mistake
|
|
4
|
+
// in the loop itself rather than in a report about the loop. It is kept pure and
|
|
5
|
+
// line-based on purpose: every byte of the file that the loop does not own --
|
|
6
|
+
// the goal, the constraints, the decisions, any prose a human added -- survives
|
|
7
|
+
// a rewrite untouched.
|
|
8
|
+
|
|
9
|
+
export const TASK_FIELDS = ["files", "change", "verify", "status"]
|
|
10
|
+
|
|
11
|
+
const SECTIONS = ["Constraints", "Decisions", "Risks", "Tasks"]
|
|
12
|
+
|
|
13
|
+
const headingRe = /^##\s+(.+?)\s*$/
|
|
14
|
+
const taskRe = /^\s*-\s*\[([ xX])\]\s*(.*)$/
|
|
15
|
+
const fieldRe = new RegExp(`^\\s+-\\s+(${TASK_FIELDS.join("|")})\\s*:\\s*(.*)$`)
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @typedef {object} Task
|
|
19
|
+
* @property {number} index position in the file, zero based
|
|
20
|
+
* @property {boolean} checked the checkbox was ticked
|
|
21
|
+
* @property {string} title the task on one line
|
|
22
|
+
* @property {string} files which files it is allowed to touch
|
|
23
|
+
* @property {string} change what it is supposed to change
|
|
24
|
+
* @property {string} verify the command that proves it
|
|
25
|
+
* @property {string} status pending | done | blocked
|
|
26
|
+
* @property {number} start first line of the task block
|
|
27
|
+
* @property {number} end last line of the task block
|
|
28
|
+
* @property {string} [key] indexed by field name when a block is written
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @typedef {object} TaskFile
|
|
33
|
+
* @property {string} goal
|
|
34
|
+
* @property {string} constraints
|
|
35
|
+
* @property {string} decisions
|
|
36
|
+
* @property {string} risks
|
|
37
|
+
* @property {Task[]} tasks
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Read a task file into its sections and tasks.
|
|
42
|
+
*
|
|
43
|
+
* Only the Tasks section is parsed structurally. Everything else is kept as raw
|
|
44
|
+
* lines so that rendering a document back never reformats a decision someone
|
|
45
|
+
* wrote in prose.
|
|
46
|
+
*
|
|
47
|
+
* @param {string} text
|
|
48
|
+
* @returns {TaskFile}
|
|
49
|
+
*/
|
|
50
|
+
export function parseTasks(text) {
|
|
51
|
+
const lines = String(text ?? "").split("\n")
|
|
52
|
+
|
|
53
|
+
const goal = (() => {
|
|
54
|
+
for (const line of lines) {
|
|
55
|
+
if (!line.trim()) continue
|
|
56
|
+
if (line.startsWith("# ")) return line.slice(2).trim()
|
|
57
|
+
return line.trim() // a file with no heading still has a goal on line 1
|
|
58
|
+
}
|
|
59
|
+
return ""
|
|
60
|
+
})()
|
|
61
|
+
|
|
62
|
+
/** @type {Record<string, string[]>} */
|
|
63
|
+
const sections = {}
|
|
64
|
+
let current = ""
|
|
65
|
+
for (const line of lines) {
|
|
66
|
+
const h = line.match(headingRe)
|
|
67
|
+
if (h) {
|
|
68
|
+
current = h[1]
|
|
69
|
+
if (!sections[current]) sections[current] = []
|
|
70
|
+
continue
|
|
71
|
+
}
|
|
72
|
+
if (current) sections[current].push(line)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** @type {Task[]} */
|
|
76
|
+
const tasks = []
|
|
77
|
+
for (let i = 0; i < lines.length; i++) {
|
|
78
|
+
const m = lines[i].match(taskRe)
|
|
79
|
+
if (!m) continue
|
|
80
|
+
// A checkbox outside the Tasks section is prose, not a task.
|
|
81
|
+
if (!inSection(lines, i, "Tasks")) continue
|
|
82
|
+
|
|
83
|
+
/** @type {Record<string, any> & Task} */
|
|
84
|
+
const t = {
|
|
85
|
+
index: tasks.length,
|
|
86
|
+
checked: m[1].toLowerCase() === "x",
|
|
87
|
+
title: m[2].trim(),
|
|
88
|
+
files: "",
|
|
89
|
+
change: "",
|
|
90
|
+
verify: "",
|
|
91
|
+
status: "",
|
|
92
|
+
start: i,
|
|
93
|
+
end: i,
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
let last = null
|
|
97
|
+
let j = i + 1
|
|
98
|
+
for (; j < lines.length; j++) {
|
|
99
|
+
if (taskRe.test(lines[j]) || headingRe.test(lines[j])) break
|
|
100
|
+
const f = lines[j].match(fieldRe)
|
|
101
|
+
if (f) {
|
|
102
|
+
t[f[1]] = f[2].trim()
|
|
103
|
+
last = f[1]
|
|
104
|
+
continue
|
|
105
|
+
}
|
|
106
|
+
// A wrapped value continues the field above it, indented deeper.
|
|
107
|
+
if (last && lines[j].trim() && /^\s{4,}\S/.test(lines[j])) {
|
|
108
|
+
t[last] = `${t[last]} ${lines[j].trim()}`.trim()
|
|
109
|
+
continue
|
|
110
|
+
}
|
|
111
|
+
if (!lines[j].trim()) continue
|
|
112
|
+
break
|
|
113
|
+
}
|
|
114
|
+
t.end = j - 1
|
|
115
|
+
if (!t.status) t.status = t.checked ? "done" : "pending"
|
|
116
|
+
tasks.push(t)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
goal,
|
|
121
|
+
constraints: raw(sections, "Constraints"),
|
|
122
|
+
decisions: raw(sections, "Decisions"),
|
|
123
|
+
risks: raw(sections, "Risks"),
|
|
124
|
+
tasks,
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* @param {string[]} lines
|
|
130
|
+
* @param {number} idx
|
|
131
|
+
* @param {string} name
|
|
132
|
+
* @returns {boolean}
|
|
133
|
+
*/
|
|
134
|
+
function inSection(lines, idx, name) {
|
|
135
|
+
for (let i = idx; i >= 0; i--) {
|
|
136
|
+
const h = lines[i].match(headingRe)
|
|
137
|
+
if (h) return h[1] === name
|
|
138
|
+
}
|
|
139
|
+
return false
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* @param {Record<string, string[]>} sections
|
|
144
|
+
* @param {string} name
|
|
145
|
+
* @returns {string}
|
|
146
|
+
*/
|
|
147
|
+
function raw(sections, name) {
|
|
148
|
+
return (sections[name] ?? []).join("\n").trim()
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The next task the loop is allowed to touch. Blocked work is never retried.
|
|
153
|
+
*
|
|
154
|
+
* @param {TaskFile} parsed
|
|
155
|
+
* @returns {Task | null}
|
|
156
|
+
*/
|
|
157
|
+
export function nextPending(parsed) {
|
|
158
|
+
return parsed.tasks.find((t) => !isDone(t) && !isBlocked(t)) ?? null
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** @param {Task} t */
|
|
162
|
+
export const isDone = (t) => t.checked || t.status === "done"
|
|
163
|
+
|
|
164
|
+
/** @param {Task} t */
|
|
165
|
+
export const isBlocked = (t) => t.status === "blocked"
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Record the outcome of a task: flip the checkbox, set the status, and paste the
|
|
169
|
+
* verify output that was actually observed.
|
|
170
|
+
*
|
|
171
|
+
* The verify text is written under the `verify:` line rather than replacing it,
|
|
172
|
+
* because the contract asks for the command and the real output together.
|
|
173
|
+
*
|
|
174
|
+
* @param {string} text
|
|
175
|
+
* @param {Task} task
|
|
176
|
+
* @param {{ status: string, verifyOutput?: string }} outcome
|
|
177
|
+
* @returns {string}
|
|
178
|
+
*/
|
|
179
|
+
export function markTask(text, task, { status, verifyOutput }) {
|
|
180
|
+
const lines = String(text).split("\n")
|
|
181
|
+
const boxLine = lines[task.start] ?? ""
|
|
182
|
+
lines[task.start] = boxLine.replace(
|
|
183
|
+
/^\s*-\s*\[[ xX]\]/,
|
|
184
|
+
status === "done" ? "- [x]" : "- [ ]",
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
let statusLine = -1
|
|
188
|
+
for (let i = task.start; i <= task.end + 1 && i < lines.length; i++) {
|
|
189
|
+
const f = lines[i]?.match(fieldRe)
|
|
190
|
+
if (f && f[1] === "status") {
|
|
191
|
+
statusLine = i
|
|
192
|
+
break
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
if (statusLine >= 0) lines[statusLine] = lines[statusLine].replace(
|
|
196
|
+
/(\s-\s+status\s*:).*$/,
|
|
197
|
+
`$1 ${status}`,
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
if (verifyOutput) {
|
|
201
|
+
let verifyLine = -1
|
|
202
|
+
for (let i = task.start; i <= task.end + 1 && i < lines.length; i++) {
|
|
203
|
+
const f = lines[i]?.match(fieldRe)
|
|
204
|
+
if (f && f[1] === "verify") {
|
|
205
|
+
verifyLine = i
|
|
206
|
+
break
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
const verifySource = verifyLine >= 0 ? lines[verifyLine] : undefined
|
|
210
|
+
if (verifySource) {
|
|
211
|
+
const indent = (verifySource.match(/^\s*/) ?? [""])[0] + " "
|
|
212
|
+
const quoted = String(verifyOutput)
|
|
213
|
+
.trimEnd()
|
|
214
|
+
.split("\n")
|
|
215
|
+
.map((l) => `${indent}${l}`)
|
|
216
|
+
lines.splice(verifyLine + 1, 0, ...quoted)
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return lines.join("\n")
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* One task rendered as the prompt handed to the agent that will do it.
|
|
225
|
+
*
|
|
226
|
+
* @param {Task} task
|
|
227
|
+
* @returns {string}
|
|
228
|
+
*/
|
|
229
|
+
export function taskPrompt(task) {
|
|
230
|
+
const out = [task.title]
|
|
231
|
+
if (task.files) out.push("", `Files: ${task.files}`)
|
|
232
|
+
if (task.change) out.push("", `Change: ${task.change}`)
|
|
233
|
+
if (task.verify) out.push("", `Verify: ${task.verify}`)
|
|
234
|
+
return out.join("\n")
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* The summary the driver prints when it stops, whatever stopped it.
|
|
239
|
+
*
|
|
240
|
+
* @param {TaskFile} parsed
|
|
241
|
+
* @returns {{ done: number, blocked: number, pending: number, total: number }}
|
|
242
|
+
*/
|
|
243
|
+
export function summarize(parsed) {
|
|
244
|
+
const done = parsed.tasks.filter(isDone).length
|
|
245
|
+
const blocked = parsed.tasks.filter(isBlocked).length
|
|
246
|
+
const pending = parsed.tasks.filter((t) => !isDone(t) && !isBlocked(t)).length
|
|
247
|
+
return { done, blocked, pending, total: parsed.tasks.length }
|
|
248
|
+
}
|