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/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
+ }