@tduyng/pi-autoresearch 0.0.1 → 0.0.2

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 CHANGED
@@ -100,8 +100,9 @@ worktree is refused before it can run anything. Files that were already dirty wh
100
100
  recorded in the lock and never committed or reverted.
101
101
 
102
102
  For a second loop on the same repository, use a separate git worktree. Each worktree has its own `.auto/`,
103
- its own branch, and its own log. The first run creates `autoresearch/<name>-<timestamp>` when you are on
104
- trunk and leaves an existing feature branch alone.
103
+ its own branch, and its own log. The first run on trunk creates `autoresearch/<name>-<timestamp>` in a git
104
+ worktree at `~/worktrees/<repo>/autoresearch-<name>-<timestamp>`, and the shared checkout stays on trunk. Start
105
+ pi in that directory to run the loop. An existing feature branch is used as it is.
105
106
 
106
107
  ## Compaction and restarts
107
108
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tduyng/pi-autoresearch",
3
- "version": "0.0.1",
3
+ "version": "0.0.2",
4
4
  "description": "Autonomous experiment loop with zero cold-start cost: measure, decide, commit or revert, with scope and benchmark guards.",
5
5
  "keywords": [
6
6
  "pi",
package/src/experiment.ts CHANGED
@@ -1,10 +1,7 @@
1
1
  import { randomUUID } from 'node:crypto'
2
+ import { readFileSync } from 'node:fs'
2
3
 
3
- import type {
4
- AgentToolResult,
5
- AgentToolUpdateCallback,
6
- ExtensionContext
7
- } from '@earendil-works/pi-coding-agent'
4
+ import type { AgentToolResult, AgentToolUpdateCallback, ExtensionContext } from '@earendil-works/pi-coding-agent'
8
5
  import { t } from 'ata-validator/t'
9
6
 
10
7
  import { runCommand } from './command.js'
@@ -61,13 +58,10 @@ const CHECKS_TAIL_LINES = 8
61
58
  const PENDING_TTL_MS = 24 * 60 * 60 * 1000
62
59
  const FILE_LIST_LIMIT = 10
63
60
 
64
- const AsiParam = t.record(t.union([t.string(), t.number(), t.boolean(), t.null()])
65
- )
61
+ const AsiParam = t.record(t.union([t.string(), t.number(), t.boolean(), t.null()]))
66
62
 
67
63
  const RunParams = t.object({
68
- command: t.optional(
69
- t.string({ description: 'Benchmark command to run instead of the one in .auto/prompt.md' })
70
- ),
64
+ command: t.optional(t.string({ description: 'Benchmark command to run instead of the one in .auto/prompt.md' })),
71
65
  timeout_seconds: t.optional(t.number({ description: 'Kill the benchmark after this many seconds' }))
72
66
  })
73
67
 
@@ -80,9 +74,7 @@ const LogParams = t.object({
80
74
  description: t.string({
81
75
  description: 'One imperative line naming the change, in the words of a commit subject ("use forks pool").'
82
76
  }),
83
- asi: t.optional(
84
- AsiParam
85
- ),
77
+ asi: t.optional(AsiParam),
86
78
  note: t.optional(t.string({ description: 'One line of what this run taught you. Shorthand for asi.note.' }))
87
79
  })
88
80
 
@@ -187,11 +179,7 @@ export function outOfScope(files: readonly string[], scope: readonly string[]):
187
179
  return files.filter((file) => !patterns.some((pattern) => pattern.test(file)))
188
180
  }
189
181
 
190
- function primaryMetric(
191
- samples: readonly PendingSample[],
192
- config: ResearchConfig,
193
- seconds: number
194
- ): number | null {
182
+ function primaryMetric(samples: readonly PendingSample[], config: ResearchConfig, seconds: number): number | null {
195
183
  const parsed = metricValue(samples, config.metric)
196
184
  if (parsed !== null) return parsed
197
185
  if (config.metric === 'duration' || config.metric === 'seconds') return seconds
@@ -274,42 +262,35 @@ async function executeRun(
274
262
  output: result.output,
275
263
  tail: result.tail,
276
264
  fullOutputPath: result.fullOutputPath,
277
- metrics: parseMetricLines(result.output)
265
+ metrics: parseMetricLines(
266
+ result.fullOutputPath === null ? result.output : readFileSync(result.fullOutputPath, 'utf8')
267
+ )
278
268
  }
279
269
  }
280
270
 
281
- async function runChecks(
282
- pi: GitApi,
283
- cwd: string,
284
- signal: AbortSignal | undefined
285
- ): Promise<ChecksOutcome | null> {
271
+ async function runChecks(pi: GitApi, cwd: string, signal: AbortSignal | undefined): Promise<ChecksOutcome | null> {
286
272
  const path = checksPath(cwd)
287
273
  if (!fileExists(path)) return null
288
- const result = await pi.exec('bash', [path], {
289
- cwd,
290
- timeout: CHECKS_TIMEOUT_SECONDS * 1000,
291
- signal
292
- }).catch(() => ({ stdout: '', stderr: 'checks could not run', code: 1, killed: false }))
293
- const tail = `${result.stdout}\n${result.stderr}`
294
- .trim()
295
- .split('\n')
296
- .slice(-CHECKS_TAIL_LINES)
297
- .join('\n')
274
+ const result = await pi
275
+ .exec('bash', [path], {
276
+ cwd,
277
+ timeout: CHECKS_TIMEOUT_SECONDS * 1000,
278
+ signal
279
+ })
280
+ .catch(() => ({ stdout: '', stderr: 'checks could not run', code: 1, killed: false }))
281
+ const tail = `${result.stdout}\n${result.stderr}`.trim().split('\n').slice(-CHECKS_TAIL_LINES).join('\n')
298
282
  return { passed: result.code === 0 && !result.killed, tail }
299
283
  }
300
284
 
301
- function secondarySummary(
302
- state: LogState,
303
- samples: readonly PendingSample[],
304
- config: ResearchConfig
305
- ): string | null {
285
+ function secondarySummary(state: LogState, samples: readonly PendingSample[], config: ResearchConfig): string | null {
306
286
  const extras = samples.filter((sample) => sample.name !== config.metric).slice(0, 4)
307
287
  if (extras.length === 0) return null
308
288
  const baseline = runsInSegment(state)[0]?.metrics ?? {}
309
289
  return extras
310
290
  .map((sample) => {
311
291
  const reference = baseline[sample.name]
312
- const delta = reference === undefined || reference === 0 ? '' : ` (${formatPercent(sample.value, reference)})`
292
+ const delta =
293
+ reference === undefined || reference === 0 ? '' : ` (${formatPercent(sample.value, reference)})`
313
294
  return `${sample.name} ${formatMetric(sample.value, inferUnit(sample.name))}${delta}`
314
295
  })
315
296
  .join(' | ')
@@ -365,7 +346,7 @@ export function createRunTool(pi: GitApi, hooks: ExperimentHooks) {
365
346
  promptSnippet: 'Run the benchmark once and show the measurement',
366
347
  promptGuidelines: [
367
348
  'Run this after editing files and before log_experiment. It never changes git history.',
368
- 'Read the benchmark output, the secondary metrics and the noise advice before deciding. The decision is yours, not the tool\'s.',
349
+ "Read the benchmark output, the secondary metrics and the noise advice before deciding. The decision is yours, not the tool's.",
369
350
  'If a run is already pending, call log_experiment first. One pending run at a time.'
370
351
  ],
371
352
  parameters: RunParams,
@@ -531,6 +512,20 @@ export function createLogTool(pi: GitApi, hooks: ExperimentHooks) {
531
512
  const cwd = ctx.cwd
532
513
  const setup = sessionSetup(cwd)
533
514
  if (setup.kind === 'error') return errorResult(setup.message)
515
+ const lockProblem = ensureLock(ctx)
516
+ if (lockProblem !== null) return errorResult(lockProblem)
517
+ const activeLock = readLock(cwd)
518
+ const current = await git.currentBranch(pi, cwd)
519
+ if (
520
+ activeLock !== null &&
521
+ activeLock.branch.length > 0 &&
522
+ current.length > 0 &&
523
+ current !== activeLock.branch
524
+ ) {
525
+ return errorResult(
526
+ `The worktree moved from ${activeLock.branch} to ${current}. Check out ${activeLock.branch} before logging a keep.`
527
+ )
528
+ }
534
529
  const config = setup.config
535
530
  const pending = readPending(cwd)
536
531
  if (pending === null) {
package/src/git.ts CHANGED
@@ -85,24 +85,14 @@ export async function stagePaths(pi: GitApi, cwd: string, files: readonly string
85
85
  return git(pi, cwd, ['add', '--', ...files])
86
86
  }
87
87
 
88
- export async function commitStaged(
89
- pi: GitApi,
90
- cwd: string,
91
- subject: string,
92
- body: string
93
- ): Promise<CommitResult> {
88
+ export async function commitStaged(pi: GitApi, cwd: string, subject: string, body: string): Promise<CommitResult> {
94
89
  const result = await git(pi, cwd, ['commit', '-m', subject, '-m', body])
95
90
  if (result.code !== 0) return { sha: '', output: result.output }
96
91
  const sha = await git(pi, cwd, ['rev-parse', '--short=7', 'HEAD'])
97
92
  return sha.code === 0 ? { sha: sha.output, output: '' } : { sha: '', output: sha.output }
98
93
  }
99
94
 
100
- export async function commitAllowEmpty(
101
- pi: GitApi,
102
- cwd: string,
103
- subject: string,
104
- body: string
105
- ): Promise<GitResult> {
95
+ export async function commitAllowEmpty(pi: GitApi, cwd: string, subject: string, body: string): Promise<GitResult> {
106
96
  return git(pi, cwd, ['commit', '--allow-empty', '-m', subject, '-m', body])
107
97
  }
108
98
 
@@ -130,8 +120,15 @@ export async function restoreFiles(pi: GitApi, cwd: string, files: readonly Pend
130
120
  return failure
131
121
  }
132
122
 
133
- export async function createBranch(pi: GitApi, cwd: string, name: string): Promise<GitResult> {
134
- return git(pi, cwd, ['checkout', '-b', name])
123
+ export async function repoName(pi: GitApi, cwd: string): Promise<string> {
124
+ const origin = await git(pi, cwd, ['remote', 'get-url', 'origin'])
125
+ if (origin.code === 0 && origin.output.length > 0) return path.basename(origin.output).replace(/\.git$/, '')
126
+ const top = await git(pi, cwd, ['rev-parse', '--show-toplevel'])
127
+ return path.basename(top.output)
128
+ }
129
+
130
+ export function loopWorktreePath(repo: string, branch: string): string {
131
+ return path.join(os.homedir(), 'worktrees', repo, branch.replace(/\//g, '-'))
135
132
  }
136
133
 
137
134
  export async function branchExists(pi: GitApi, cwd: string, name: string): Promise<boolean> {
@@ -154,12 +151,8 @@ export async function mergeBase(pi: GitApi, cwd: string, trunk: string): Promise
154
151
  }
155
152
 
156
153
  export async function filesInCommit(pi: GitApi, cwd: string, commit: string): Promise<readonly string[]> {
157
- const result = await git(pi, cwd, ['show', '--name-only', '--format=', commit])
158
- if (result.code !== 0) return []
159
- return result.output
160
- .split('\n')
161
- .map((line) => line.trim())
162
- .filter((line) => line.length > 0 && !line.startsWith(`${RESEARCH_DIRECTORY}/`))
154
+ const files = await zeroList(pi, cwd, ['show', '--name-only', '--format=', commit])
155
+ return files.filter((file) => !file.startsWith(`${RESEARCH_DIRECTORY}/`))
163
156
  }
164
157
 
165
158
  export async function fileInCommit(pi: GitApi, cwd: string, commit: string, file: string): Promise<boolean> {
package/src/index.ts CHANGED
@@ -229,8 +229,10 @@ export default function autoresearchExtension(pi: ExtensionAPI): void {
229
229
  }
230
230
  if (branch.length > 0 && branch !== trunk) return null
231
231
  const name = `autoresearch/${slugify(config?.name ?? goal, 30)}-${branchStamp()}`
232
- const created = await git.createBranch(pi, ctx.cwd, name)
233
- return created.code === 0 ? null : `Could not create branch ${name}: ${created.output}`
232
+ const directory = git.loopWorktreePath(await git.repoName(pi, ctx.cwd), name)
233
+ const created = await git.worktreeAddBranch(pi, ctx.cwd, directory, name, 'HEAD')
234
+ if (created.code !== 0) return `Could not create branch ${name}: ${created.output}`
235
+ return `Autoresearch runs in its own worktree: ${directory}. This checkout stays on ${branch}. Start pi in that directory and run /autoresearch there.`
234
236
  }
235
237
 
236
238
  const activate = async (ctx: ExtensionCommandContext, text: string): Promise<void> => {
package/src/session.ts CHANGED
@@ -29,9 +29,7 @@ const isLockSchema = new Validator(LockSchema).isValidObject
29
29
 
30
30
  export type SessionLock = Infer<typeof LockSchema>
31
31
 
32
- export type LockResult =
33
- | { kind: 'ok'; lock: SessionLock }
34
- | { kind: 'other'; lock: SessionLock }
32
+ export type LockResult = { kind: 'ok'; lock: SessionLock } | { kind: 'other'; lock: SessionLock }
35
33
 
36
34
  export function researchPath(cwd: string, file: string): string {
37
35
  return path.join(cwd, RESEARCH_DIRECTORY, file)
@@ -143,12 +141,18 @@ function writeLock(cwd: string, lock: SessionLock): void {
143
141
  fs.renameSync(temporary, target)
144
142
  }
145
143
 
146
- export function acquireLock(
147
- cwd: string,
148
- sessionId: string,
149
- branch: string,
150
- dirty: readonly string[]
151
- ): LockResult {
144
+ function createLock(cwd: string, lock: SessionLock): boolean {
145
+ const directory = path.join(cwd, RESEARCH_DIRECTORY)
146
+ fs.mkdirSync(directory, { recursive: true })
147
+ try {
148
+ fs.writeFileSync(lockPath(cwd), `${JSON.stringify(lock)}\n`, { flag: 'wx' })
149
+ return true
150
+ } catch {
151
+ return false
152
+ }
153
+ }
154
+
155
+ export function acquireLock(cwd: string, sessionId: string, branch: string, dirty: readonly string[]): LockResult {
152
156
  const existing = readLock(cwd)
153
157
  if (existing !== null && existing.sessionId !== sessionId && pidAlive(existing.pid)) {
154
158
  return { kind: 'other', lock: existing }
@@ -161,8 +165,18 @@ export function acquireLock(
161
165
  startedAt: sameSession ? existing.startedAt : Date.now(),
162
166
  dirty: sameSession ? existing.dirty : [...dirty]
163
167
  }
164
- writeLock(cwd, lock)
165
- return { kind: 'ok', lock }
168
+ if (sameSession) {
169
+ writeLock(cwd, lock)
170
+ return { kind: 'ok', lock }
171
+ }
172
+ if (createLock(cwd, lock)) return { kind: 'ok', lock }
173
+ const latest = readLock(cwd)
174
+ if (latest !== null && latest.sessionId !== sessionId && pidAlive(latest.pid)) {
175
+ return { kind: 'other', lock: latest }
176
+ }
177
+ fs.rmSync(lockPath(cwd), { force: true })
178
+ if (createLock(cwd, lock)) return { kind: 'ok', lock }
179
+ return { kind: 'other', lock: readLock(cwd) ?? lock }
166
180
  }
167
181
 
168
182
  export function releaseLock(cwd: string, sessionId: string): void {