@spacek33z/autoauto 0.0.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/README.md +197 -0
- package/package.json +51 -0
- package/src/App.tsx +224 -0
- package/src/cli.ts +772 -0
- package/src/components/AgentPanel.tsx +254 -0
- package/src/components/Chat.test.tsx +71 -0
- package/src/components/Chat.tsx +308 -0
- package/src/components/CycleField.tsx +23 -0
- package/src/components/ModelPicker.tsx +97 -0
- package/src/components/PostUpdatePrompt.tsx +46 -0
- package/src/components/ResultsTable.tsx +172 -0
- package/src/components/RunCompletePrompt.tsx +90 -0
- package/src/components/RunSettingsOverlay.tsx +49 -0
- package/src/components/RunsTable.tsx +219 -0
- package/src/components/StatsHeader.tsx +100 -0
- package/src/daemon.ts +264 -0
- package/src/index.tsx +8 -0
- package/src/lib/agent/agent-provider.test.ts +133 -0
- package/src/lib/agent/claude-provider.ts +277 -0
- package/src/lib/agent/codex-provider.ts +413 -0
- package/src/lib/agent/default-providers.ts +10 -0
- package/src/lib/agent/index.ts +32 -0
- package/src/lib/agent/mock-provider.ts +61 -0
- package/src/lib/agent/opencode-provider.ts +424 -0
- package/src/lib/agent/types.ts +73 -0
- package/src/lib/auth.ts +11 -0
- package/src/lib/config.ts +152 -0
- package/src/lib/daemon-callbacks.ts +59 -0
- package/src/lib/daemon-client.ts +16 -0
- package/src/lib/daemon-lifecycle.ts +368 -0
- package/src/lib/daemon-spawn.ts +122 -0
- package/src/lib/daemon-status.ts +189 -0
- package/src/lib/daemon-watcher.ts +192 -0
- package/src/lib/experiment-loop.ts +679 -0
- package/src/lib/experiment.ts +356 -0
- package/src/lib/finalize.test.ts +143 -0
- package/src/lib/finalize.ts +511 -0
- package/src/lib/format.test.ts +32 -0
- package/src/lib/format.ts +44 -0
- package/src/lib/git.ts +176 -0
- package/src/lib/ideas-backlog.test.ts +54 -0
- package/src/lib/ideas-backlog.ts +109 -0
- package/src/lib/measure.ts +472 -0
- package/src/lib/model-options.ts +24 -0
- package/src/lib/programs.ts +247 -0
- package/src/lib/push-stream.ts +48 -0
- package/src/lib/run-context.ts +112 -0
- package/src/lib/run-setup.ts +34 -0
- package/src/lib/run.ts +383 -0
- package/src/lib/syntax-theme.ts +39 -0
- package/src/lib/system-prompts/experiment.ts +77 -0
- package/src/lib/system-prompts/finalize.ts +90 -0
- package/src/lib/system-prompts/index.ts +7 -0
- package/src/lib/system-prompts/setup.ts +516 -0
- package/src/lib/system-prompts/update.ts +188 -0
- package/src/lib/tool-events.ts +99 -0
- package/src/lib/validate-measurement.ts +326 -0
- package/src/lib/worktree.ts +40 -0
- package/src/screens/AuthErrorScreen.tsx +31 -0
- package/src/screens/ExecutionScreen.tsx +851 -0
- package/src/screens/FirstSetupScreen.tsx +168 -0
- package/src/screens/HomeScreen.tsx +406 -0
- package/src/screens/PreRunScreen.tsx +206 -0
- package/src/screens/SettingsScreen.tsx +189 -0
- package/src/screens/SetupScreen.tsx +226 -0
- package/src/tui.tsx +17 -0
- package/tsconfig.json +17 -0
package/src/lib/git.ts
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { $ } from "bun"
|
|
2
|
+
|
|
3
|
+
/** Extract a meaningful message from a Bun ShellError (which has .stderr Buffer but a generic .message). */
|
|
4
|
+
export function formatShellError(err: unknown, context?: string): string {
|
|
5
|
+
if (err instanceof Error) {
|
|
6
|
+
// Bun ShellError has .stderr as a Buffer
|
|
7
|
+
const shellErr = err as Error & { stderr?: Buffer; exitCode?: number }
|
|
8
|
+
const stderr = shellErr.stderr ? shellErr.stderr.toString().trim() : ""
|
|
9
|
+
const prefix = context ? `${context}: ` : ""
|
|
10
|
+
if (stderr) return `${prefix}${stderr}`
|
|
11
|
+
if (shellErr.message && !shellErr.message.startsWith("Failed with exit code")) {
|
|
12
|
+
return `${prefix}${shellErr.message}`
|
|
13
|
+
}
|
|
14
|
+
return `${prefix}exit code ${shellErr.exitCode ?? "unknown"}`
|
|
15
|
+
}
|
|
16
|
+
return context ? `${context}: ${String(err)}` : String(err)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function getFullSha(cwd: string): Promise<string> {
|
|
20
|
+
return (await $`git rev-parse HEAD`.cwd(cwd).text()).trim()
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function getRecentLog(cwd: string, count?: number): Promise<string> {
|
|
24
|
+
return (await $`git log --oneline --decorate -n ${String(count ?? 10)}`.cwd(cwd).text()).trim()
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Resets HEAD to the given SHA, discarding all changes. Primary discard mechanism for failed experiments. */
|
|
28
|
+
export async function resetHard(cwd: string, sha: string): Promise<void> {
|
|
29
|
+
await $`git reset --hard ${sha}`.cwd(cwd).quiet()
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function getLatestCommitMessage(cwd: string): Promise<string> {
|
|
33
|
+
return (await $`git log -1 --format=%s`.cwd(cwd).text()).trim()
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function getCommitDiff(cwd: string, sha: string): Promise<string> {
|
|
37
|
+
return (await $`git show --stat ${sha}`.cwd(cwd).text()).trim()
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function branchExists(cwd: string, branchName: string): Promise<boolean> {
|
|
41
|
+
const result = await $`git show-ref --verify --quiet refs/heads/${branchName}`.cwd(cwd).nothrow().quiet()
|
|
42
|
+
return result.exitCode === 0
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function isWorkingTreeClean(cwd: string): Promise<boolean> {
|
|
46
|
+
return !(await $`git status --porcelain`.cwd(cwd).text()).trim()
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function getCurrentBranch(cwd: string): Promise<string> {
|
|
50
|
+
return (await $`git rev-parse --abbrev-ref HEAD`.cwd(cwd).text()).trim()
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function createExperimentBranch(
|
|
54
|
+
cwd: string,
|
|
55
|
+
programSlug: string,
|
|
56
|
+
runId: string,
|
|
57
|
+
): Promise<string> {
|
|
58
|
+
const branchName = `autoauto-${programSlug}-${runId}`
|
|
59
|
+
|
|
60
|
+
const result = await $`git checkout -b ${branchName}`.cwd(cwd).nothrow().quiet()
|
|
61
|
+
if (result.exitCode !== 0) {
|
|
62
|
+
throw new Error(
|
|
63
|
+
`Failed to create branch "${branchName}" — was a previous run interrupted? ` +
|
|
64
|
+
`Delete it with \`git branch -D ${branchName}\` to proceed.`,
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return branchName
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function checkoutBranch(cwd: string, branchName: string): Promise<void> {
|
|
72
|
+
await $`git checkout ${branchName}`.cwd(cwd).quiet()
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Returns files changed between two SHAs (relative paths). */
|
|
76
|
+
export async function getFilesChangedBetween(
|
|
77
|
+
cwd: string,
|
|
78
|
+
fromSha: string,
|
|
79
|
+
toSha: string,
|
|
80
|
+
): Promise<string[]> {
|
|
81
|
+
return (await $`git diff --name-only ${fromSha} ${toSha}`.cwd(cwd).text())
|
|
82
|
+
.trim()
|
|
83
|
+
.split("\n")
|
|
84
|
+
.filter(Boolean)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Returns the number of commits between two SHAs. */
|
|
88
|
+
export async function countCommitsBetween(
|
|
89
|
+
cwd: string,
|
|
90
|
+
fromSha: string,
|
|
91
|
+
toSha: string,
|
|
92
|
+
): Promise<number> {
|
|
93
|
+
return parseInt((await $`git rev-list --count ${fromSha}..${toSha}`.cwd(cwd).text()).trim(), 10)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Returns the full unified diff between two SHAs. */
|
|
97
|
+
export async function getDiffBetween(cwd: string, fromSha: string, toSha: string): Promise<string> {
|
|
98
|
+
return await $`git diff ${fromSha} ${toSha}`.cwd(cwd).text()
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Creates a group branch from baseline, applying only the specified files from headSha.
|
|
102
|
+
* Used by finalize to split kept experiments into independent, mergeable branches.
|
|
103
|
+
* Returns the new commit SHA. */
|
|
104
|
+
export async function createGroupBranch(
|
|
105
|
+
cwd: string,
|
|
106
|
+
branchName: string,
|
|
107
|
+
baselineSha: string,
|
|
108
|
+
headSha: string,
|
|
109
|
+
files: string[],
|
|
110
|
+
commitMessage: string,
|
|
111
|
+
): Promise<string> {
|
|
112
|
+
// Delete stale branch from a previous crashed attempt
|
|
113
|
+
if (await branchExists(cwd, branchName)) {
|
|
114
|
+
await $`git branch -D ${branchName}`.cwd(cwd).quiet()
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
await $`git checkout -b ${branchName} ${baselineSha}`.cwd(cwd).quiet()
|
|
119
|
+
} catch (err) {
|
|
120
|
+
throw new Error(formatShellError(err, `git checkout -b ${branchName}`), { cause: err })
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
try {
|
|
124
|
+
// Stage only this group's files from the final experiment state
|
|
125
|
+
await $`git checkout ${headSha} -- ${files}`.cwd(cwd).quiet()
|
|
126
|
+
} catch (err) {
|
|
127
|
+
throw new Error(formatShellError(err, `git checkout files from ${headSha.slice(0, 10)}`), { cause: err })
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
await $`git commit -m ${commitMessage}`.cwd(cwd).quiet()
|
|
132
|
+
} catch (err) {
|
|
133
|
+
throw new Error(formatShellError(err, `git commit for group "${branchName}"`), { cause: err })
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return getFullSha(cwd)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Diff statistics: lines added and removed between two SHAs. */
|
|
140
|
+
export interface DiffStats {
|
|
141
|
+
lines_added: number
|
|
142
|
+
lines_removed: number
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Returns lines added/removed between two SHAs using git diff --shortstat. */
|
|
146
|
+
export async function getDiffStats(cwd: string, fromSha: string, toSha: string): Promise<DiffStats> {
|
|
147
|
+
const output = (await $`git diff --shortstat ${fromSha} ${toSha}`.cwd(cwd).text()).trim()
|
|
148
|
+
if (!output) return { lines_added: 0, lines_removed: 0 }
|
|
149
|
+
|
|
150
|
+
const insertions = output.match(/(\d+) insertion/)
|
|
151
|
+
const deletions = output.match(/(\d+) deletion/)
|
|
152
|
+
return {
|
|
153
|
+
lines_added: insertions ? parseInt(insertions[1], 10) : 0,
|
|
154
|
+
lines_removed: deletions ? parseInt(deletions[1], 10) : 0,
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Returns formatted diff summaries for discarded commits, capped at maxLength chars. */
|
|
159
|
+
export async function getDiscardedDiffs(
|
|
160
|
+
cwd: string,
|
|
161
|
+
shas: string[],
|
|
162
|
+
maxLength = 2000,
|
|
163
|
+
): Promise<string> {
|
|
164
|
+
const parts: string[] = []
|
|
165
|
+
let totalLength = 0
|
|
166
|
+
|
|
167
|
+
for (const sha of shas) {
|
|
168
|
+
if (totalLength >= maxLength) break
|
|
169
|
+
const diff = await getCommitDiff(cwd, sha) // eslint-disable-line no-await-in-loop -- lazy fetch, stops at maxLength
|
|
170
|
+
const entry = `[${sha.slice(0, 7)}]\n${diff}\n`
|
|
171
|
+
parts.push(entry)
|
|
172
|
+
totalLength += entry.length
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return parts.join("\n")
|
|
176
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import { mkdtemp, readFile, rm } from "node:fs/promises"
|
|
3
|
+
import { join } from "node:path"
|
|
4
|
+
import { tmpdir } from "node:os"
|
|
5
|
+
import {
|
|
6
|
+
appendIdeasBacklog,
|
|
7
|
+
parseExperimentNotes,
|
|
8
|
+
readIdeasBacklogSummary,
|
|
9
|
+
} from "./ideas-backlog.ts"
|
|
10
|
+
|
|
11
|
+
describe("ideas backlog", () => {
|
|
12
|
+
test("parses orchestrator notes block", () => {
|
|
13
|
+
const notes = parseExperimentNotes(`
|
|
14
|
+
done
|
|
15
|
+
<autoauto_notes>
|
|
16
|
+
{"hypothesis":"cache hot path","why":"avoids repeated work","avoid":["global cache"],"next":["try local memo"]}
|
|
17
|
+
</autoauto_notes>
|
|
18
|
+
`)
|
|
19
|
+
|
|
20
|
+
expect(notes).toEqual({
|
|
21
|
+
hypothesis: "cache hot path",
|
|
22
|
+
why: "avoids repeated work",
|
|
23
|
+
avoid: ["global cache"],
|
|
24
|
+
next: ["try local memo"],
|
|
25
|
+
})
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
test("writes readable experiment entries", async () => {
|
|
29
|
+
const runDir = await mkdtemp(join(tmpdir(), "autoauto-ideas-"))
|
|
30
|
+
try {
|
|
31
|
+
await appendIdeasBacklog(runDir, {
|
|
32
|
+
experiment_number: 1,
|
|
33
|
+
commit: "abc1234",
|
|
34
|
+
metric_value: 42,
|
|
35
|
+
secondary_values: "",
|
|
36
|
+
status: "discard",
|
|
37
|
+
description: "noise: cache hot path",
|
|
38
|
+
measurement_duration_ms: 100,
|
|
39
|
+
}, {
|
|
40
|
+
hypothesis: "cache hot path",
|
|
41
|
+
why: "within noise",
|
|
42
|
+
avoid: ["global cache"],
|
|
43
|
+
next: ["try local memo"],
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
const raw = await readFile(join(runDir, "ideas.md"), "utf-8")
|
|
47
|
+
expect(raw).toContain("# Ideas Backlog")
|
|
48
|
+
expect(raw).toContain("## Experiment #1 - discard")
|
|
49
|
+
expect(await readIdeasBacklogSummary(runDir)).toContain("try local memo")
|
|
50
|
+
} finally {
|
|
51
|
+
await rm(runDir, { recursive: true, force: true })
|
|
52
|
+
}
|
|
53
|
+
})
|
|
54
|
+
})
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { appendFile } from "node:fs/promises"
|
|
2
|
+
import { join } from "node:path"
|
|
3
|
+
import type { ExperimentResult } from "./run.ts"
|
|
4
|
+
|
|
5
|
+
export interface ExperimentNotes {
|
|
6
|
+
hypothesis?: string
|
|
7
|
+
why?: string
|
|
8
|
+
next?: string[]
|
|
9
|
+
avoid?: string[]
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const BACKLOG_FILE = "ideas.md"
|
|
13
|
+
const MAX_FIELD_LENGTH = 500
|
|
14
|
+
const MAX_ITEMS = 5
|
|
15
|
+
|
|
16
|
+
function cleanText(value: unknown): string | undefined {
|
|
17
|
+
if (typeof value !== "string") return undefined
|
|
18
|
+
const normalized = value.replace(/\s+/g, " ").trim()
|
|
19
|
+
if (!normalized) return undefined
|
|
20
|
+
return normalized.slice(0, MAX_FIELD_LENGTH)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function cleanList(value: unknown): string[] | undefined {
|
|
24
|
+
if (!Array.isArray(value)) return undefined
|
|
25
|
+
const items = value
|
|
26
|
+
.map(cleanText)
|
|
27
|
+
.filter((item): item is string => item != null)
|
|
28
|
+
.slice(0, MAX_ITEMS)
|
|
29
|
+
return items.length > 0 ? items : undefined
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function parseExperimentNotes(text: string): ExperimentNotes | undefined {
|
|
33
|
+
const match = text.match(/<autoauto_notes>\s*([\s\S]*?)\s*<\/autoauto_notes>/)
|
|
34
|
+
if (!match) return undefined
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
const raw = JSON.parse(match[1]) as Record<string, unknown>
|
|
38
|
+
const notes: ExperimentNotes = {
|
|
39
|
+
hypothesis: cleanText(raw.hypothesis),
|
|
40
|
+
why: cleanText(raw.why),
|
|
41
|
+
next: cleanList(raw.next),
|
|
42
|
+
avoid: cleanList(raw.avoid),
|
|
43
|
+
}
|
|
44
|
+
return Object.values(notes).some((value) => value != null) ? notes : undefined
|
|
45
|
+
} catch {
|
|
46
|
+
return undefined
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function listLines(items: string[] | undefined, fallback: string): string[] {
|
|
51
|
+
if (!items || items.length === 0) return [` - ${fallback}`]
|
|
52
|
+
return items.map((item) => ` - ${item}`)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function formatEntry(result: ExperimentResult, notes?: ExperimentNotes): string {
|
|
56
|
+
const tried = notes?.hypothesis ?? result.description
|
|
57
|
+
const agentNote = notes?.why ?? "No agent note captured."
|
|
58
|
+
|
|
59
|
+
return [
|
|
60
|
+
`## Experiment #${result.experiment_number} - ${result.status}`,
|
|
61
|
+
`- Commit: ${result.commit}`,
|
|
62
|
+
`- Metric: ${result.metric_value}`,
|
|
63
|
+
`- Result: ${result.description}`,
|
|
64
|
+
`- Tried: ${tried}`,
|
|
65
|
+
`- Agent note: ${agentNote}`,
|
|
66
|
+
"- Avoid:",
|
|
67
|
+
...listLines(notes?.avoid, "No specific avoid note captured."),
|
|
68
|
+
"- Try next:",
|
|
69
|
+
...listLines(notes?.next, "No specific next idea captured."),
|
|
70
|
+
"",
|
|
71
|
+
].join("\n")
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function ensureHeader(runDir: string): Promise<void> {
|
|
75
|
+
const path = join(runDir, BACKLOG_FILE)
|
|
76
|
+
if (await Bun.file(path).exists()) return
|
|
77
|
+
await appendFile(
|
|
78
|
+
path,
|
|
79
|
+
[
|
|
80
|
+
"# Ideas Backlog",
|
|
81
|
+
"",
|
|
82
|
+
"Append-only experiment memory. Captures what was tried, why it worked or failed, and what to try next.",
|
|
83
|
+
"",
|
|
84
|
+
].join("\n"),
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function appendIdeasBacklog(
|
|
89
|
+
runDir: string,
|
|
90
|
+
result: ExperimentResult,
|
|
91
|
+
notes?: ExperimentNotes,
|
|
92
|
+
): Promise<void> {
|
|
93
|
+
if (result.experiment_number === 0) return
|
|
94
|
+
await ensureHeader(runDir)
|
|
95
|
+
await appendFile(join(runDir, BACKLOG_FILE), formatEntry(result, notes))
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function readIdeasBacklogSummary(runDir: string, maxChars = 4000): Promise<string> {
|
|
99
|
+
try {
|
|
100
|
+
const raw = await Bun.file(join(runDir, BACKLOG_FILE)).text()
|
|
101
|
+
if (raw.length <= maxChars) return raw.trim()
|
|
102
|
+
|
|
103
|
+
const tail = raw.slice(-maxChars)
|
|
104
|
+
const firstEntry = tail.indexOf("\n## ")
|
|
105
|
+
return (firstEntry >= 0 ? tail.slice(firstEntry + 1) : tail).trim()
|
|
106
|
+
} catch {
|
|
107
|
+
return ""
|
|
108
|
+
}
|
|
109
|
+
}
|