@onyx-robotics/agent 0.1.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/src/lib/git.ts ADDED
@@ -0,0 +1,57 @@
1
+ import { commandOutput, runProcess } from "./process"
2
+
3
+ export async function git(args: string[], cwd?: string) {
4
+ return commandOutput("git", args, cwd)
5
+ }
6
+
7
+ export async function gitResult(args: string[], cwd?: string) {
8
+ return runProcess("git", args, { cwd })
9
+ }
10
+
11
+ export async function repoRoot(cwd = process.cwd()) {
12
+ return git(["rev-parse", "--show-toplevel"], cwd)
13
+ }
14
+
15
+ /** Absolute path to the repo's git directory (handles worktrees). */
16
+ export async function gitDir(root: string) {
17
+ return git(["rev-parse", "--absolute-git-dir"], root)
18
+ }
19
+
20
+ export async function currentBranch(cwd: string) {
21
+ return git(["branch", "--show-current"], cwd)
22
+ }
23
+
24
+ export async function currentCommit(cwd: string) {
25
+ return git(["rev-parse", "HEAD"], cwd)
26
+ }
27
+
28
+ export function gitBranchForName(name: string) {
29
+ return `onyx/${name}`
30
+ }
31
+
32
+ export function nameFromGitBranch(gitBranchName: string) {
33
+ return gitBranchName.startsWith("onyx/")
34
+ ? gitBranchName.slice("onyx/".length)
35
+ : null
36
+ }
37
+
38
+ /** Pushes a branch to origin, setting upstream. Throws on failure. */
39
+ export async function pushBranch(root: string, branchName: string) {
40
+ await git(["push", "-u", "origin", branchName], root)
41
+ }
42
+
43
+ export function normalizeRepositoryUrl(value: string) {
44
+ const ssh = value.match(/^git@github\.com:(.+?)\/(.+?)(?:\.git)?$/)
45
+ if (ssh)
46
+ return `https://github.com/${ssh[1]}/${ssh[2]!.replace(/\.git$/, "")}`
47
+ return value.replace(/\.git$/, "")
48
+ }
49
+
50
+ export async function repositoryUrl(
51
+ root: string,
52
+ explicitUrl?: string
53
+ ): Promise<string> {
54
+ if (explicitUrl) return normalizeRepositoryUrl(explicitUrl)
55
+ const origin = await git(["remote", "get-url", "origin"], root)
56
+ return normalizeRepositoryUrl(origin)
57
+ }
@@ -0,0 +1,272 @@
1
+ import { readFile, rename, writeFile } from "node:fs/promises"
2
+ import { join } from "node:path"
3
+
4
+ import {
5
+ localResearchHistoryRecordSchema,
6
+ type LocalResearchExperimentLoggedRecord,
7
+ type LocalResearchHistoryRecord,
8
+ } from "../protocol"
9
+
10
+ import {
11
+ getProjectTree,
12
+ resolveProject,
13
+ type ApiTreeBranch,
14
+ type ApiTreeExperiment,
15
+ } from "./api"
16
+ import type { Args } from "./args"
17
+ import { onyxStateDir, readOutbox, readState } from "./outbox"
18
+
19
+ export async function historyPath(root: string) {
20
+ return join(await onyxStateDir(root), "history.jsonl")
21
+ }
22
+
23
+ export async function appendHistory(
24
+ root: string,
25
+ record: LocalResearchHistoryRecord
26
+ ) {
27
+ const validated = localResearchHistoryRecordSchema.parse(record)
28
+ await writeFile(await historyPath(root), `${JSON.stringify(validated)}\n`, {
29
+ encoding: "utf8",
30
+ flag: "a",
31
+ })
32
+ }
33
+
34
+ /**
35
+ * Reads the local history cache. Corrupt or partially-written lines are
36
+ * skipped and counted rather than thrown, mirroring the outbox.
37
+ */
38
+ export async function readHistory(
39
+ root: string
40
+ ): Promise<{ records: LocalResearchHistoryRecord[]; corrupt: number }> {
41
+ let text = ""
42
+ try {
43
+ text = await readFile(await historyPath(root), "utf8")
44
+ } catch {
45
+ return { records: [], corrupt: 0 }
46
+ }
47
+
48
+ const records: LocalResearchHistoryRecord[] = []
49
+ let corrupt = 0
50
+
51
+ for (const line of text.split("\n")) {
52
+ const trimmed = line.trim()
53
+ if (!trimmed) continue
54
+ let parsed: unknown
55
+ try {
56
+ parsed = JSON.parse(trimmed)
57
+ } catch {
58
+ corrupt += 1
59
+ continue
60
+ }
61
+ const result = localResearchHistoryRecordSchema.safeParse(parsed)
62
+ if (result.success) {
63
+ records.push(result.data)
64
+ } else {
65
+ corrupt += 1
66
+ }
67
+ }
68
+
69
+ return { records, corrupt }
70
+ }
71
+
72
+ /** Atomically replaces the history cache. */
73
+ export async function rewriteHistory(
74
+ root: string,
75
+ records: LocalResearchHistoryRecord[]
76
+ ) {
77
+ const path = await historyPath(root)
78
+ const body = records.map((record) => JSON.stringify(record)).join("\n")
79
+ const tmp = `${path}.tmp`
80
+ await writeFile(tmp, body ? `${body}\n` : "", "utf8")
81
+ await rename(tmp, path)
82
+ }
83
+
84
+ /** Provisional history record for a just-logged experiment. */
85
+ export function experimentRecordToHistory(
86
+ record: LocalResearchExperimentLoggedRecord
87
+ ): LocalResearchHistoryRecord {
88
+ return {
89
+ schemaVersion: 1,
90
+ source: "local",
91
+ branchName: record.branchName,
92
+ gitBranchName: record.gitBranchName,
93
+ runRef: record.runRef,
94
+ commitSha: record.commitSha,
95
+ status: record.status,
96
+ name: record.name,
97
+ description: record.description ?? null,
98
+ primaryMetricName: record.primaryMetricName,
99
+ primaryMetricValue: record.primaryMetricValue,
100
+ metrics: record.metrics,
101
+ agentNotes: record.agentNotes,
102
+ checks: record.checks ?? null,
103
+ durationMs: record.durationMs ?? null,
104
+ outputSummary: record.outputSummary ?? null,
105
+ startedAt: record.startedAt ?? null,
106
+ completedAt: record.completedAt ?? null,
107
+ createdAt: record.createdAt,
108
+ }
109
+ }
110
+
111
+ /** Canonical history record from a tree-endpoint experiment DTO. */
112
+ export function apiExperimentToHistory(
113
+ branch: ApiTreeBranch,
114
+ experiment: ApiTreeExperiment
115
+ ): LocalResearchHistoryRecord | null {
116
+ const metrics: Record<string, number> = {}
117
+ for (const [key, value] of Object.entries(experiment.secondaryMetrics)) {
118
+ if (typeof value === "number" && Number.isFinite(value)) {
119
+ metrics[key] = value
120
+ }
121
+ }
122
+ if (experiment.primaryMetricValue !== null) {
123
+ metrics[experiment.primaryMetricName] = experiment.primaryMetricValue
124
+ }
125
+
126
+ const result = localResearchHistoryRecordSchema.safeParse({
127
+ schemaVersion: 1,
128
+ source: "api",
129
+ branchName: branch.name,
130
+ gitBranchName: branch.gitBranchName ?? undefined,
131
+ runRef: experiment.runRef,
132
+ commitSha: experiment.commitSha,
133
+ status: experiment.status,
134
+ name: experiment.name.slice(0, 160),
135
+ description: experiment.description?.slice(0, 2000) ?? null,
136
+ primaryMetricName: experiment.primaryMetricName,
137
+ primaryMetricValue: experiment.primaryMetricValue,
138
+ metrics,
139
+ agentNotes: experiment.agentNotes,
140
+ checks: experiment.checks
141
+ ? {
142
+ status: experiment.checks.status,
143
+ durationMs: experiment.checks.durationMs,
144
+ outputSummary: experiment.checks.outputSummary?.slice(0, 4000),
145
+ }
146
+ : null,
147
+ durationMs: experiment.durationMs,
148
+ outputSummary: experiment.outputSummary?.slice(0, 4000) ?? null,
149
+ startedAt: experiment.startedAt,
150
+ completedAt: experiment.completedAt,
151
+ createdAt: experiment.createdAt,
152
+ experimentId: experiment.id,
153
+ branchId: experiment.branchId,
154
+ sequenceNumber: experiment.sequenceNumber,
155
+ })
156
+ return result.success ? result.data : null
157
+ }
158
+
159
+ /**
160
+ * Merges canonical API records with still-pending local records. Keyed by
161
+ * runRef (globally unique: `local/{branch}/{uuid}`); canonical wins so
162
+ * server-updated statuses (accepted/rejected) replace provisional rows.
163
+ */
164
+ export function mergeHistory(
165
+ canonical: LocalResearchHistoryRecord[],
166
+ localCandidates: LocalResearchHistoryRecord[]
167
+ ): LocalResearchHistoryRecord[] {
168
+ const byRunRef = new Map<string, LocalResearchHistoryRecord>()
169
+ for (const record of localCandidates) byRunRef.set(record.runRef, record)
170
+ for (const record of canonical) byRunRef.set(record.runRef, record)
171
+
172
+ return [...byRunRef.values()].sort((a, b) => {
173
+ if (a.branchName !== b.branchName) {
174
+ return a.branchName < b.branchName ? -1 : 1
175
+ }
176
+ // Server-sequenced records first in order; pending local records after.
177
+ if (a.sequenceNumber !== undefined && b.sequenceNumber !== undefined) {
178
+ return a.sequenceNumber - b.sequenceNumber
179
+ }
180
+ if (a.sequenceNumber !== undefined) return -1
181
+ if (b.sequenceNumber !== undefined) return 1
182
+ return a.createdAt < b.createdAt ? -1 : a.createdAt > b.createdAt ? 1 : 0
183
+ })
184
+ }
185
+
186
+ export type HistorySyncUpdate = {
187
+ sequenceNumber: number
188
+ experimentId: string
189
+ branchId: string
190
+ }
191
+
192
+ /**
193
+ * Stamps server-assigned fields onto matching history records right after a
194
+ * flush, so `onyx listen` and `exp list` show sequence numbers immediately
195
+ * instead of waiting for the next full hydrate. Matched records become
196
+ * canonical (`source: "api"`).
197
+ */
198
+ export async function applyHistorySyncUpdates(
199
+ root: string,
200
+ updates: Map<string, HistorySyncUpdate>
201
+ ) {
202
+ if (updates.size === 0) return
203
+ const { records } = await readHistory(root)
204
+ let changed = false
205
+ const next = records.map((record) => {
206
+ const update = updates.get(record.runRef)
207
+ if (!update) return record
208
+ changed = true
209
+ return {
210
+ ...record,
211
+ source: "api" as const,
212
+ sequenceNumber: update.sequenceNumber,
213
+ experimentId: update.experimentId,
214
+ branchId: update.branchId,
215
+ }
216
+ })
217
+ if (changed) await rewriteHistory(root, next)
218
+ }
219
+
220
+ export type HydrateResult = {
221
+ experiments: number
222
+ branches: number
223
+ pendingLocal: number
224
+ }
225
+
226
+ /**
227
+ * Rewrites `.git/onyx/history.jsonl` to the canonical API state, keeping
228
+ * local records the server doesn't know yet (offline-logged, unflushed).
229
+ * Throws on network/auth failure; callers treat hydration as best-effort.
230
+ */
231
+ export async function hydrateHistoryFromApi(
232
+ root: string,
233
+ args: Args
234
+ ): Promise<HydrateResult> {
235
+ const state = await readState(root)
236
+ const projectId = state.projectId ?? (await resolveProject(root, args)).id
237
+ const tree = await getProjectTree(projectId, args)
238
+
239
+ const canonical: LocalResearchHistoryRecord[] = []
240
+ for (const branch of tree.branches) {
241
+ for (const experiment of branch.experiments) {
242
+ const record = apiExperimentToHistory(branch, experiment)
243
+ if (record) canonical.push(record)
244
+ }
245
+ }
246
+ const canonicalRunRefs = new Set(canonical.map((record) => record.runRef))
247
+
248
+ // Local records the server doesn't have yet: provisional history rows plus
249
+ // anything still queued in the outbox (covers a failed earlier append).
250
+ const { records: existing } = await readHistory(root)
251
+ const localCandidates = existing.filter(
252
+ (record) =>
253
+ record.source === "local" && !canonicalRunRefs.has(record.runRef)
254
+ )
255
+ const seen = new Set(localCandidates.map((record) => record.runRef))
256
+ const { records: outbox } = await readOutbox(root)
257
+ for (const record of outbox) {
258
+ if (record.type !== "experiment_logged") continue
259
+ if (canonicalRunRefs.has(record.runRef) || seen.has(record.runRef)) continue
260
+ localCandidates.push(experimentRecordToHistory(record))
261
+ seen.add(record.runRef)
262
+ }
263
+
264
+ const merged = mergeHistory(canonical, localCandidates)
265
+ await rewriteHistory(root, merged)
266
+
267
+ return {
268
+ experiments: merged.length,
269
+ branches: new Set(merged.map((record) => record.branchName)).size,
270
+ pendingLocal: localCandidates.length,
271
+ }
272
+ }
@@ -0,0 +1,233 @@
1
+ import { createServer, type Server } from "node:http"
2
+
3
+ import { runProcess } from "./process"
4
+
5
+ export async function openBrowser(url: string) {
6
+ const command =
7
+ process.platform === "darwin"
8
+ ? "open"
9
+ : process.platform === "win32"
10
+ ? "cmd"
11
+ : "xdg-open"
12
+ const args = process.platform === "win32" ? ["/c", "start", "", url] : [url]
13
+ try {
14
+ const result = await runProcess(command, args)
15
+ if (result.code === 0) return
16
+ } catch {
17
+ // Fall through to printing the URL for headless or minimal systems.
18
+ }
19
+ console.log(`Open this URL to log in:\n${url}`)
20
+ }
21
+
22
+ export type CliLoginResult = {
23
+ apiKey?: string
24
+ apiKeyId?: string
25
+ apiUrl?: string
26
+ teamId: string
27
+ teamName: string
28
+ profileName?: string
29
+ alreadyConfigured: boolean
30
+ }
31
+
32
+ const LOGIN_COMPLETE_MESSAGE =
33
+ "Onyx CLI login complete. You can close this tab."
34
+
35
+ const ONYX_MARK_SVG = `<svg width="381" height="509" viewBox="0 0 381 509" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><path d="M381 382.14L233 508.018V424.447L298.75 368.525L190.501 151.379L82.25 368.526L149 425.297V508.867L0 382.14L190.501 0L381 382.14Z" fill="currentColor"/></svg>`
36
+
37
+ export function cliLoginCompleteHtml() {
38
+ return `<!doctype html>
39
+ <html lang="en">
40
+ <head>
41
+ <meta charset="utf-8">
42
+ <meta name="viewport" content="width=device-width, initial-scale=1">
43
+ <title>Onyx CLI login complete</title>
44
+ <style>
45
+ :root {
46
+ color-scheme: light dark;
47
+ --background: oklch(0.96 0 0);
48
+ --foreground: oklch(0.145 0 0);
49
+ --card: oklch(0.96 0 0);
50
+ --card-foreground: oklch(0.145 0 0);
51
+ --muted-foreground: oklch(0.556 0 0);
52
+ --border: oklch(0.922 0 0);
53
+ }
54
+
55
+ @media (prefers-color-scheme: dark) {
56
+ :root {
57
+ --background: oklch(0.145 0 0);
58
+ --foreground: oklch(0.985 0 0);
59
+ --card: oklch(0.205 0 0);
60
+ --card-foreground: oklch(0.985 0 0);
61
+ --muted-foreground: oklch(0.708 0 0);
62
+ --border: oklch(1 0 0 / 10%);
63
+ }
64
+ }
65
+
66
+ * {
67
+ box-sizing: border-box;
68
+ }
69
+
70
+ body {
71
+ margin: 0;
72
+ min-height: 100vh;
73
+ display: grid;
74
+ place-items: center;
75
+ padding: 24px;
76
+ background: var(--background);
77
+ color: var(--foreground);
78
+ font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
79
+ }
80
+
81
+ main {
82
+ width: min(100%, 448px);
83
+ }
84
+
85
+ .brand {
86
+ display: flex;
87
+ align-items: center;
88
+ justify-content: center;
89
+ gap: 32px;
90
+ margin-bottom: 32px;
91
+ font-size: 48px;
92
+ line-height: 1;
93
+ font-weight: 600;
94
+ letter-spacing: 0;
95
+ }
96
+
97
+ .brand svg {
98
+ width: 60px;
99
+ height: 80px;
100
+ flex: none;
101
+ }
102
+
103
+ .card {
104
+ border: 1px solid var(--border);
105
+ border-radius: 8px;
106
+ background: var(--card);
107
+ color: var(--card-foreground);
108
+ padding: 24px;
109
+ text-align: center;
110
+ box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
111
+ }
112
+
113
+ h1 {
114
+ margin: 0;
115
+ font-size: 16px;
116
+ line-height: 24px;
117
+ font-weight: 600;
118
+ letter-spacing: 0;
119
+ }
120
+
121
+ p {
122
+ margin: 8px 0 0;
123
+ color: var(--muted-foreground);
124
+ font-size: 14px;
125
+ line-height: 22px;
126
+ }
127
+ </style>
128
+ </head>
129
+ <body>
130
+ <main>
131
+ <div class="brand">${ONYX_MARK_SVG}<span>Onyx</span></div>
132
+ <section class="card" aria-labelledby="title">
133
+ <h1 id="title">${LOGIN_COMPLETE_MESSAGE}</h1>
134
+ <p>Your CLI profile is ready to use.</p>
135
+ </section>
136
+ </main>
137
+ </body>
138
+ </html>`
139
+ }
140
+
141
+ function closeServer(server: Server) {
142
+ return new Promise<void>((resolveClose) => {
143
+ const forceClose = setTimeout(() => {
144
+ server.closeAllConnections()
145
+ resolveClose()
146
+ }, 1_000)
147
+ forceClose.unref()
148
+
149
+ server.close(() => {
150
+ clearTimeout(forceClose)
151
+ resolveClose()
152
+ })
153
+ server.closeIdleConnections()
154
+ })
155
+ }
156
+
157
+ export async function waitForCliLogin({
158
+ port,
159
+ state,
160
+ timeoutMs,
161
+ }: {
162
+ port: number
163
+ state: string
164
+ timeoutMs: number
165
+ }): Promise<CliLoginResult> {
166
+ let server: Server | null = null
167
+ let timeout: ReturnType<typeof setTimeout> | null = null
168
+ const login = new Promise<CliLoginResult>((resolveLogin, reject) => {
169
+ server = createServer((request, response) => {
170
+ response.setHeader("connection", "close")
171
+ response.on("finish", () => request.socket.destroy())
172
+
173
+ const url = new URL(request.url ?? "/", `http://127.0.0.1:${port}`)
174
+ if (url.pathname !== "/callback") {
175
+ response.writeHead(404).end("Not found")
176
+ return
177
+ }
178
+
179
+ if (url.searchParams.get("state") !== state) {
180
+ response.writeHead(400).end("Invalid state")
181
+ reject(new Error("Login callback returned an invalid state."))
182
+ return
183
+ }
184
+
185
+ const teamId = url.searchParams.get("team_id")
186
+ const teamName = url.searchParams.get("team_name")
187
+ if (!teamId || !teamName) {
188
+ response.writeHead(400).end("Missing team")
189
+ reject(new Error("Login callback did not include a team."))
190
+ return
191
+ }
192
+
193
+ const alreadyConfigured =
194
+ url.searchParams.get("already_configured") === "true"
195
+ const key = url.searchParams.get("api_key")
196
+ if (!alreadyConfigured && !key) {
197
+ response.writeHead(400).end("Missing API key")
198
+ reject(new Error("Login callback did not include an API key."))
199
+ return
200
+ }
201
+
202
+ response
203
+ .writeHead(200, { "content-type": "text/html; charset=utf-8" })
204
+ .end(cliLoginCompleteHtml())
205
+ resolveLogin({
206
+ apiKey: key ?? undefined,
207
+ apiKeyId: url.searchParams.get("api_key_id") ?? undefined,
208
+ apiUrl: url.searchParams.get("api_url") ?? undefined,
209
+ teamId,
210
+ teamName,
211
+ profileName: url.searchParams.get("profile_name") ?? undefined,
212
+ alreadyConfigured,
213
+ })
214
+ })
215
+ server.listen(port, "127.0.0.1")
216
+ })
217
+
218
+ try {
219
+ return await Promise.race([
220
+ login,
221
+ new Promise<never>((_, reject) => {
222
+ timeout = setTimeout(
223
+ () => reject(new Error("Timed out waiting for browser login.")),
224
+ timeoutMs
225
+ )
226
+ timeout.unref()
227
+ }),
228
+ ])
229
+ } finally {
230
+ if (timeout) clearTimeout(timeout)
231
+ if (server) await closeServer(server)
232
+ }
233
+ }
@@ -0,0 +1,148 @@
1
+ import { readFile, writeFile } from "node:fs/promises"
2
+
3
+ import { currentBranch, nameFromGitBranch } from "./git"
4
+ import { readState } from "./outbox"
5
+ import { branchStateKey, onyxPath } from "./project"
6
+
7
+ export type MetricDirection = "maximize" | "minimize"
8
+
9
+ export type BranchMetadata = {
10
+ name: string
11
+ description: string | null
12
+ gitBranchName: string
13
+ baseCommitSha: string | null
14
+ metricName: string
15
+ metricUnit: string | null
16
+ metricDirection: MetricDirection
17
+ }
18
+
19
+ export async function resolveBranchName(root: string, branchOption?: string) {
20
+ if (branchOption) return branchOption
21
+
22
+ const gitBranchName = await currentBranch(root)
23
+ const name = nameFromGitBranch(gitBranchName)
24
+ if (name) return name
25
+
26
+ throw new Error("Missing --branch and no local onyx/* branch is checked out.")
27
+ }
28
+
29
+ function markdownField(section: string, name: string) {
30
+ return section.match(new RegExp(`^${name}:\\s*(.*)$`, "m"))?.[1]?.trim()
31
+ }
32
+
33
+ async function branchFromMarkdown({
34
+ root,
35
+ projectPath,
36
+ name,
37
+ gitBranchName,
38
+ }: {
39
+ root: string
40
+ projectPath: string
41
+ name: string
42
+ gitBranchName: string
43
+ }): Promise<BranchMetadata | null> {
44
+ let text = ""
45
+ try {
46
+ text = await readFile(onyxPath(root, projectPath, "onyx.md"), "utf8")
47
+ } catch {
48
+ return null
49
+ }
50
+
51
+ const start = text.indexOf(`### ${name}`)
52
+ if (start === -1) return null
53
+ const rest = text.slice(start)
54
+ const next = rest.slice(1).search(/\n###\s+/)
55
+ const section = next === -1 ? rest : rest.slice(0, next + 1)
56
+ const metricName = markdownField(section, "Metric") || "score"
57
+ const unit = markdownField(section, "Unit")
58
+ const direction = markdownField(section, "Direction")
59
+ const baseCommitSha = markdownField(section, "Base commit")
60
+
61
+ return {
62
+ name,
63
+ description: markdownField(section, "Description") || null,
64
+ gitBranchName,
65
+ baseCommitSha: baseCommitSha || null,
66
+ metricName,
67
+ metricUnit: unit || null,
68
+ metricDirection: direction === "minimize" ? "minimize" : "maximize",
69
+ }
70
+ }
71
+
72
+ export async function branchMetadata({
73
+ root,
74
+ projectPath,
75
+ branchName,
76
+ gitBranchName,
77
+ }: {
78
+ root: string
79
+ projectPath: string
80
+ branchName: string
81
+ gitBranchName: string
82
+ }): Promise<BranchMetadata> {
83
+ const state = await readState(root)
84
+ const stored = state.branches[branchStateKey(projectPath, branchName)]
85
+ if (stored?.metricName) {
86
+ return {
87
+ name: branchName,
88
+ description: stored.description ?? null,
89
+ gitBranchName: stored.gitBranchName ?? gitBranchName,
90
+ baseCommitSha: stored.baseCommitSha ?? null,
91
+ metricName: stored.metricName,
92
+ metricUnit: stored.metricUnit ?? null,
93
+ metricDirection:
94
+ stored.metricDirection === "minimize" ? "minimize" : "maximize",
95
+ }
96
+ }
97
+
98
+ return (
99
+ (await branchFromMarkdown({
100
+ root,
101
+ projectPath,
102
+ name: branchName,
103
+ gitBranchName,
104
+ })) ?? {
105
+ name: branchName,
106
+ description: null,
107
+ gitBranchName,
108
+ baseCommitSha: null,
109
+ metricName: "score",
110
+ metricUnit: null,
111
+ metricDirection: "maximize",
112
+ }
113
+ )
114
+ }
115
+
116
+ export async function appendBranchToMarkdown({
117
+ root,
118
+ projectPath,
119
+ name,
120
+ description,
121
+ baseCommitSha,
122
+ metricName,
123
+ metricUnit,
124
+ metricDirection,
125
+ }: {
126
+ root: string
127
+ projectPath: string
128
+ name: string
129
+ description?: string | null
130
+ baseCommitSha?: string | null
131
+ metricName: string
132
+ metricUnit?: string | null
133
+ metricDirection: MetricDirection
134
+ }) {
135
+ const file = onyxPath(root, projectPath, "onyx.md")
136
+ const marker = `### ${name}`
137
+ let text = ""
138
+ try {
139
+ text = await readFile(file, "utf8")
140
+ } catch {
141
+ return
142
+ }
143
+
144
+ if (text.includes(marker)) return
145
+
146
+ const addition = `\n### ${name}\n\nDescription: ${description ?? ""}\n\nMetric: ${metricName}\nUnit: ${metricUnit ?? ""}\nDirection: ${metricDirection}\nBase commit: ${baseCommitSha ?? ""}\n\nPlan:\n\nSuccess criteria:\n\nNext to try:\n\n- Add hypotheses here as a queue. Each iteration pops one and appends a row to Attempts.\n\nAttempts:\n\n| commit | hypothesis | metric | status | learning |\n| ------ | ---------- | ------ | ------ | -------- |\n`
147
+ await writeFile(file, `${text.trimEnd()}\n${addition}`, "utf8")
148
+ }