lalph 0.3.44 → 0.3.46

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.
@@ -15,6 +15,7 @@ import { selectCliAgentPreset } from "../Presets.ts"
15
15
  import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
16
16
  import { parseBranch } from "../shared/git.ts"
17
17
  import type { CliAgentPreset } from "../domain/CliAgentPreset.ts"
18
+ import { ClankaModels } from "../ClankaModels.ts"
18
19
 
19
20
  const dangerous = Flag.boolean("dangerous").pipe(
20
21
  Flag.withAlias("d"),
@@ -74,7 +75,13 @@ export const commandPlan = Command.make("plan", {
74
75
  dangerous,
75
76
  preset,
76
77
  }).pipe(Effect.provideService(CurrentProjectId, project.id))
77
- }).pipe(Effect.provide([Settings.layer, CurrentIssueSource.layer]))
78
+ }).pipe(
79
+ Effect.provide([
80
+ Settings.layer,
81
+ CurrentIssueSource.layer,
82
+ ClankaModels.layer,
83
+ ]),
84
+ )
78
85
  }, Effect.provide(Editor.layer)),
79
86
  ),
80
87
  Command.withSubcommands([commandPlanTasks]),
@@ -5,7 +5,6 @@ import {
5
5
  Effect,
6
6
  FiberSet,
7
7
  FileSystem,
8
- identity,
9
8
  Iterable,
10
9
  Option,
11
10
  Path,
@@ -44,13 +43,11 @@ import { GitFlow, GitFlowCommit, GitFlowError, GitFlowPR } from "../GitFlow.ts"
44
43
  import { getAllProjects, welcomeWizard } from "../Projects.ts"
45
44
  import type { Project } from "../domain/Project.ts"
46
45
  import { getDefaultCliAgentPreset } from "../Presets.ts"
47
- import { ChosenTaskDeferred } from "../TaskTools.ts"
48
- import { ClankaModels, type ClankaModel } from "../ClankaModels.ts"
49
- import { runClanka } from "../Clanka.ts"
50
46
  import type { QuitError } from "effect/Terminal"
51
47
  import type { TimeoutError } from "effect/Cause"
52
48
  import type { ChildProcessSpawner } from "effect/unstable/process"
53
- import type { AiError } from "effect/unstable/ai"
49
+ import { ClankaModels } from "../ClankaModels.ts"
50
+ import type { AiError } from "effect/unstable/ai/AiError"
54
51
 
55
52
  // Main iteration run logic
56
53
 
@@ -71,7 +68,8 @@ const run = Effect.fnUntraced(
71
68
  | GitFlowError
72
69
  | ChosenTaskNotFound
73
70
  | RunnerStalled
74
- | TimeoutError,
71
+ | TimeoutError
72
+ | AiError,
75
73
  | CurrentProjectId
76
74
  | ChildProcessSpawner.ChildProcessSpawner
77
75
  | Settings
@@ -85,6 +83,7 @@ const run = Effect.fnUntraced(
85
83
  | PromptGen
86
84
  | Prd
87
85
  | Worktree
86
+ | ClankaModels
88
87
  | Scope.Scope
89
88
  > {
90
89
  const projectId = yield* CurrentProjectId
@@ -202,13 +201,21 @@ const run = Effect.fnUntraced(
202
201
  )
203
202
 
204
203
  const promptGen = yield* PromptGen
205
- const instructions = promptGen.prompt({
206
- specsDirectory: options.specsDirectory,
207
- targetBranch: Option.getOrUndefined(options.targetBranch),
208
- task: chosenTask.prd,
209
- githubPrNumber: chosenTask.githubPrNumber ?? undefined,
210
- gitFlow,
211
- })
204
+ const instructions = taskPreset.cliAgent.command
205
+ ? promptGen.prompt({
206
+ specsDirectory: options.specsDirectory,
207
+ targetBranch: Option.getOrUndefined(options.targetBranch),
208
+ task: chosenTask.prd,
209
+ githubPrNumber: chosenTask.githubPrNumber ?? undefined,
210
+ gitFlow,
211
+ })
212
+ : promptGen.promptClanka({
213
+ specsDirectory: options.specsDirectory,
214
+ targetBranch: Option.getOrUndefined(options.targetBranch),
215
+ task: chosenTask.prd,
216
+ githubPrNumber: chosenTask.githubPrNumber ?? undefined,
217
+ gitFlow,
218
+ })
212
219
 
213
220
  const exitCode = yield* agentWorker({
214
221
  stallTimeout: options.stallTimeout,
@@ -274,252 +281,6 @@ const run = Effect.fnUntraced(
274
281
  Effect.provide(Prd.layer, { local: true }),
275
282
  )
276
283
 
277
- const runWithClanka = Effect.fnUntraced(
278
- function* (options: {
279
- readonly startedDeferred: Deferred.Deferred<void>
280
- readonly targetBranch: Option.Option<string>
281
- readonly specsDirectory: string
282
- readonly stallTimeout: Duration.Duration
283
- readonly runTimeout: Duration.Duration
284
- readonly review: boolean
285
- readonly clankaModel: ClankaModel
286
- }): Effect.fn.Return<
287
- void,
288
- | PlatformError.PlatformError
289
- | Schema.SchemaError
290
- | IssueSourceError
291
- | GitFlowError
292
- | ChosenTaskNotFound
293
- | RunnerStalled
294
- | TimeoutError
295
- | AiError.AiError,
296
- | CurrentProjectId
297
- | FileSystem.FileSystem
298
- | Path.Path
299
- | Worktree
300
- | ChildProcessSpawner.ChildProcessSpawner
301
- | GithubCli
302
- | IssueSource
303
- | AtomRegistry.AtomRegistry
304
- | GitFlow
305
- | CurrentWorkerState
306
- | PromptGen
307
- | ClankaModels
308
- | Scope.Scope
309
- | Prd
310
- > {
311
- const projectId = yield* CurrentProjectId
312
- const fs = yield* FileSystem.FileSystem
313
- const pathService = yield* Path.Path
314
- const worktree = yield* Worktree
315
- const gh = yield* GithubCli
316
- const source = yield* IssueSource
317
- const gitFlow = yield* GitFlow
318
- const currentWorker = yield* CurrentWorkerState
319
- const registry = yield* AtomRegistry.AtomRegistry
320
- const promptGen = yield* PromptGen
321
- const models = yield* ClankaModels
322
- const model = models.get(options.clankaModel)
323
-
324
- // ensure cleanup of branch after run
325
- yield* Effect.addFinalizer(
326
- Effect.fnUntraced(function* () {
327
- const currentBranchName = yield* worktree
328
- .currentBranch(worktree.directory)
329
- .pipe(Effect.option, Effect.map(Option.getOrUndefined))
330
- if (!currentBranchName) return
331
-
332
- // enter detached state
333
- yield* worktree.exec`git checkout --detach ${currentBranchName}`
334
- // delete the branch
335
- yield* worktree.exec`git branch -D ${currentBranchName}`
336
- }, Effect.ignore()),
337
- )
338
-
339
- let taskId: string | undefined = undefined
340
-
341
- // setup finalizer to revert issue if we fail
342
- yield* Effect.addFinalizer(
343
- Effect.fnUntraced(function* (exit) {
344
- if (exit._tag === "Success") return
345
- if (taskId) {
346
- yield* source.updateIssue({
347
- projectId,
348
- issueId: taskId,
349
- state: "todo",
350
- })
351
- }
352
- }, Effect.ignore()),
353
- )
354
-
355
- const taskById = (taskId: string) =>
356
- AtomRegistry.getResult(registry, currentIssuesAtom(projectId)).pipe(
357
- Effect.map((issues) => issues.find((entry) => entry.id === taskId)),
358
- )
359
-
360
- // 1. Choose task
361
- // --------------
362
-
363
- registry.update(currentWorker.state, (s) =>
364
- s.transitionTo(WorkerStatus.ChoosingTask()),
365
- )
366
-
367
- const deferred = ChosenTaskDeferred.of(Deferred.makeUnsafe())
368
- const chooseResult = yield* runClanka({
369
- directory: worktree.directory,
370
- prompt: promptGen.promptChooseClanka({ gitFlow }),
371
- stallTimeout: options.stallTimeout,
372
- withChoose: true,
373
- }).pipe(
374
- Effect.andThen(Effect.fail(new ChosenTaskNotFound())),
375
- Effect.provideService(ChosenTaskDeferred, deferred),
376
- Effect.provide(model),
377
- Effect.raceFirst(Deferred.await(deferred)),
378
- Effect.withSpan("Main.choose"),
379
- )
380
-
381
- const chosenTask = yield* taskById(chooseResult.taskId)
382
- if (!chosenTask) {
383
- return yield* new ChosenTaskNotFound()
384
- }
385
- taskId = chooseResult.taskId
386
- yield* source.updateIssue({
387
- projectId,
388
- issueId: taskId,
389
- state: "in-progress",
390
- })
391
-
392
- yield* source.ensureInProgress(projectId, taskId).pipe(
393
- Effect.timeoutOrElse({
394
- duration: "1 minute",
395
- onTimeout: () => Effect.fail(new RunnerStalled()),
396
- }),
397
- )
398
-
399
- yield* Deferred.completeWith(options.startedDeferred, Effect.void)
400
-
401
- if (gitFlow.requiresGithubPr && chooseResult.githubPrNumber) {
402
- yield* worktree.exec`gh pr checkout ${chooseResult.githubPrNumber}`
403
- const feedback = yield* gh.prFeedbackMd(chooseResult.githubPrNumber)
404
- yield* fs.writeFileString(
405
- pathService.join(worktree.directory, ".lalph", "feedback.md"),
406
- feedback,
407
- )
408
- }
409
-
410
- const catchStallInReview = <A, E, R>(
411
- effect: Effect.Effect<A, E | RunnerStalled, R>,
412
- ) =>
413
- Effect.catchIf(
414
- effect,
415
- (u): u is RunnerStalled => u instanceof RunnerStalled,
416
- Effect.fnUntraced(function* (e) {
417
- const issues = yield* AtomRegistry.getResult(
418
- registry,
419
- currentIssuesAtom(projectId),
420
- )
421
- const task = issues.find((entry) => entry.id === taskId)
422
- const inReview = task?.state === "in-review"
423
- if (inReview) return
424
- return yield* e
425
- }),
426
- )
427
-
428
- const cancelled = yield* Effect.gen(function* () {
429
- //
430
- // 2. Work on task
431
- // -----------------------
432
-
433
- registry.update(currentWorker.state, (s) =>
434
- s.transitionTo(WorkerStatus.Working({ issueId: taskId })),
435
- )
436
-
437
- const instructions = promptGen.promptClanka({
438
- specsDirectory: options.specsDirectory,
439
- targetBranch: Option.getOrUndefined(options.targetBranch),
440
- task: chosenTask,
441
- githubPrNumber: chooseResult.githubPrNumber ?? undefined,
442
- gitFlow,
443
- })
444
-
445
- yield* runClanka({
446
- directory: worktree.directory,
447
- system: promptGen.systemClanka(options),
448
- prompt: instructions,
449
- stallTimeout: options.stallTimeout,
450
- }).pipe(Effect.provide(model), Effect.withSpan("Main.worker"))
451
-
452
- // 3. Review task
453
- // -----------------------
454
-
455
- if (options.review) {
456
- registry.update(currentWorker.state, (s) =>
457
- s.transitionTo(WorkerStatus.Reviewing({ issueId: taskId })),
458
- )
459
-
460
- yield* runClanka({
461
- directory: worktree.directory,
462
- system: promptGen.systemClanka(options),
463
- prompt: promptGen.promptReview({
464
- prompt: instructions,
465
- gitFlow,
466
- }),
467
- }).pipe(
468
- Effect.provide(model),
469
- catchStallInReview,
470
- Effect.withSpan("Main.review"),
471
- )
472
- }
473
- }).pipe(
474
- Effect.timeout(options.runTimeout),
475
- Effect.tapErrorTag("TimeoutError", () =>
476
- runClanka({
477
- directory: worktree.directory,
478
- system: promptGen.systemClanka(options),
479
- prompt: promptGen.promptTimeoutClanka({
480
- taskId,
481
- specsDirectory: options.specsDirectory,
482
- }),
483
- stallTimeout: options.stallTimeout,
484
- }).pipe(Effect.provide(model), Effect.withSpan("Main.timeout")),
485
- ),
486
- Effect.raceFirst(watchTaskState({ issueId: taskId })),
487
- Effect.as(false),
488
- Effect.catchTag("TaskStateChanged", (error) =>
489
- Effect.log(
490
- `Task ${error.issueId} moved to ${error.state}; cancelling run.`,
491
- ).pipe(Effect.as(true)),
492
- ),
493
- )
494
-
495
- if (cancelled) return
496
-
497
- yield* gitFlow.postWork({
498
- worktree,
499
- targetBranch: Option.getOrUndefined(options.targetBranch),
500
- issueId: taskId,
501
- })
502
-
503
- const task = yield* taskById(taskId)
504
- if (task?.autoMerge) {
505
- yield* gitFlow.autoMerge({
506
- targetBranch: Option.getOrUndefined(options.targetBranch),
507
- issueId: taskId,
508
- worktree,
509
- })
510
- }
511
- },
512
- Effect.scoped,
513
- Effect.provide(Prd.layer, { local: true }),
514
- )
515
-
516
- type RunEffects = ReturnType<typeof run> | ReturnType<typeof runWithClanka>
517
- type RunEffect = Effect.Effect<
518
- void,
519
- Effect.Error<RunEffects>,
520
- Effect.Services<RunEffects>
521
- >
522
-
523
284
  const runProject = Effect.fnUntraced(
524
285
  function* (options: {
525
286
  readonly iterations: number
@@ -527,7 +288,6 @@ const runProject = Effect.fnUntraced(
527
288
  readonly specsDirectory: string
528
289
  readonly stallTimeout: Duration.Duration
529
290
  readonly runTimeout: Duration.Duration
530
- readonly clankaModel: ClankaModel | undefined
531
291
  }) {
532
292
  const isFinite = Number.isFinite(options.iterations)
533
293
  const iterationsDisplay = isFinite ? options.iterations : "unlimited"
@@ -558,26 +318,14 @@ const runProject = Effect.fnUntraced(
558
318
 
559
319
  yield* checkForWork.pipe(
560
320
  Effect.andThen(
561
- identity<RunEffect>(
562
- options.clankaModel
563
- ? runWithClanka({
564
- startedDeferred,
565
- targetBranch: options.project.targetBranch,
566
- specsDirectory: options.specsDirectory,
567
- stallTimeout: options.stallTimeout,
568
- runTimeout: options.runTimeout,
569
- review: options.project.reviewAgent,
570
- clankaModel: options.clankaModel,
571
- })
572
- : run({
573
- startedDeferred,
574
- targetBranch: options.project.targetBranch,
575
- specsDirectory: options.specsDirectory,
576
- stallTimeout: options.stallTimeout,
577
- runTimeout: options.runTimeout,
578
- review: options.project.reviewAgent,
579
- }),
580
- ).pipe(
321
+ run({
322
+ startedDeferred,
323
+ targetBranch: options.project.targetBranch,
324
+ specsDirectory: options.specsDirectory,
325
+ stallTimeout: options.stallTimeout,
326
+ runTimeout: options.runTimeout,
327
+ review: options.project.reviewAgent,
328
+ }).pipe(
581
329
  Effect.provide(
582
330
  options.project.gitFlow === "commit" ? GitFlowCommit : GitFlowPR,
583
331
  { local: true },
@@ -585,7 +333,6 @@ const runProject = Effect.fnUntraced(
585
333
  withWorkerState(options.project.id),
586
334
  ),
587
335
  ),
588
- (_) => _,
589
336
  Effect.catchTags({
590
337
  NoMoreWork(_error) {
591
338
  if (isFinite) {
@@ -692,7 +439,7 @@ export const commandRoot = Command.make("lalph", {
692
439
  stallMinutes,
693
440
  specsDirectory,
694
441
  }) {
695
- const preset = yield* getDefaultCliAgentPreset
442
+ yield* getDefaultCliAgentPreset
696
443
 
697
444
  let allProjects = yield* getAllProjects
698
445
  if (allProjects.length === 0) {
@@ -715,7 +462,6 @@ export const commandRoot = Command.make("lalph", {
715
462
  specsDirectory,
716
463
  stallTimeout: Duration.minutes(stallMinutes),
717
464
  runTimeout: Duration.minutes(maxIterationMinutes),
718
- clankaModel: preset.clankaModel,
719
465
  }).pipe(Effect.provideService(CurrentProjectId, project.id)),
720
466
  { concurrency: "unbounded", discard: true },
721
467
  )
@@ -12,7 +12,7 @@ export class CliAgent<const Id extends string> extends Data.Class<{
12
12
  id: Id
13
13
  name: string
14
14
  outputTransformer?: OutputTransformer | undefined
15
- command: (options: {
15
+ command?: (options: {
16
16
  readonly prompt: string
17
17
  readonly prdFilePath: string
18
18
  readonly extraArgs: ReadonlyArray<string>
@@ -28,6 +28,34 @@ export type OutputTransformer = (
28
28
  stream: Stream.Stream<string, PlatformError.PlatformError>,
29
29
  ) => Stream.Stream<string, PlatformError.PlatformError>
30
30
 
31
+ const clanka = new CliAgent({
32
+ id: "clanka",
33
+ name: "clanka",
34
+ commandPlan: ({ prompt, prdFilePath, dangerous }) =>
35
+ ChildProcess.make(
36
+ "opencode",
37
+ [
38
+ "--prompt",
39
+ `@${prdFilePath}
40
+
41
+ ${prompt}`,
42
+ ],
43
+ {
44
+ extendEnv: true,
45
+ ...(dangerous
46
+ ? {
47
+ env: {
48
+ OPENCODE_PERMISSION: '{"*":"allow"}',
49
+ },
50
+ }
51
+ : {}),
52
+ stdout: "inherit",
53
+ stderr: "inherit",
54
+ stdin: "inherit",
55
+ },
56
+ ),
57
+ })
58
+
31
59
  const opencode = new CliAgent({
32
60
  id: "opencode",
33
61
  name: "opencode",
@@ -178,7 +206,7 @@ ${prompt}`,
178
206
  })`echo ${"Plan mode is not supported for amp."}`,
179
207
  })
180
208
 
181
- export const allCliAgents = [opencode, claude, codex, amp] as const
209
+ export const allCliAgents = [clanka, opencode, claude, codex, amp] as const
182
210
  export type AnyCliAgent = (typeof allCliAgents)[number]
183
211
 
184
212
  export const CliAgentFromId = Schema.Literals(
@@ -1,7 +1,6 @@
1
1
  import { Array, Effect, identity, Option, Schema } from "effect"
2
2
  import { CliAgentFromId } from "./CliAgent.ts"
3
3
  import { ChildProcess } from "effect/unstable/process"
4
- import { ClankaModel } from "../ClankaModels.ts"
5
4
 
6
5
  export const CliAgentPresetId = Schema.NonEmptyString.pipe(
7
6
  Schema.brand("lalph/CliAgentPresetId"),
@@ -16,7 +15,6 @@ export class CliAgentPreset extends Schema.Class<CliAgentPreset>(
16
15
  commandPrefix: Schema.Array(Schema.String),
17
16
  extraArgs: Schema.Array(Schema.String),
18
17
  sourceMetadata: Schema.Record(Schema.String, Schema.Any),
19
- clankaModel: Schema.optionalKey(ClankaModel),
20
18
  }) {
21
19
  static defaultId = CliAgentPresetId.makeUnsafe("default")
22
20