lalph 0.3.45 → 0.3.47

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "lalph",
3
3
  "type": "module",
4
- "version": "0.3.45",
4
+ "version": "0.3.47",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
@@ -22,13 +22,15 @@
22
22
  "devDependencies": {
23
23
  "@changesets/changelog-github": "^0.6.0",
24
24
  "@changesets/cli": "^2.30.0",
25
+ "@effect/ai-openai": "4.0.0-beta.30",
26
+ "@effect/ai-openai-compat": "4.0.0-beta.30",
25
27
  "@effect/language-service": "^0.79.0",
26
28
  "@effect/platform-node": "4.0.0-beta.30",
27
29
  "@linear/sdk": "^77.0.0",
28
30
  "@octokit/plugin-rest-endpoint-methods": "^17.0.0",
29
31
  "@octokit/types": "^16.0.0",
30
32
  "@typescript/native-preview": "7.0.0-dev.20260310.1",
31
- "clanka": "^0.0.15",
33
+ "clanka": "^0.0.17",
32
34
  "concurrently": "^9.2.1",
33
35
  "effect": "4.0.0-beta.30",
34
36
  "husky": "^9.1.7",
@@ -40,6 +40,7 @@ export const agentReviewer = Effect.fnUntraced(function* (options: {
40
40
  promptGen.promptReviewCustom({
41
41
  prompt,
42
42
  specsDirectory: options.specsDirectory,
43
+ removePrdNotes: true,
43
44
  }),
44
45
  }),
45
46
  stallTimeout: options.stallTimeout,
@@ -59,6 +60,7 @@ export const agentReviewer = Effect.fnUntraced(function* (options: {
59
60
  promptGen.promptReviewCustom({
60
61
  prompt,
61
62
  specsDirectory: options.specsDirectory,
63
+ removePrdNotes: false,
62
64
  }),
63
65
  }),
64
66
  prdFilePath: pathService.join(".lalph", "prd.yml"),
@@ -1,15 +1,18 @@
1
- import { Duration, Effect, Path, pipe } from "effect"
1
+ import { Duration, Effect, identity, Path, pipe, Stream } from "effect"
2
2
  import { ChildProcess } from "effect/unstable/process"
3
3
  import { Worktree } from "../Worktree.ts"
4
4
  import type { CliAgentPreset } from "../domain/CliAgentPreset.ts"
5
5
  import { runClanka } from "../Clanka.ts"
6
6
  import { ExitCode } from "effect/unstable/process/ChildProcessSpawner"
7
+ import { CurrentTaskRef } from "../TaskTools.ts"
7
8
 
8
9
  export const agentWorker = Effect.fnUntraced(function* (options: {
9
10
  readonly stallTimeout: Duration.Duration
10
11
  readonly preset: CliAgentPreset
11
12
  readonly system?: string
12
13
  readonly prompt: string
14
+ readonly steer?: Stream.Stream<string>
15
+ readonly taskRef?: CurrentTaskRef["Service"]
13
16
  }) {
14
17
  const pathService = yield* Path.Path
15
18
  const worktree = yield* Worktree
@@ -22,7 +25,12 @@ export const agentWorker = Effect.fnUntraced(function* (options: {
22
25
  system: options.system,
23
26
  prompt: options.prompt,
24
27
  stallTimeout: options.stallTimeout,
25
- })
28
+ steer: options.steer,
29
+ }).pipe(
30
+ options.taskRef
31
+ ? Effect.provideService(CurrentTaskRef, options.taskRef)
32
+ : identity,
33
+ )
26
34
  return ExitCode(0)
27
35
  }
28
36
 
package/src/Clanka.ts CHANGED
@@ -7,8 +7,6 @@ import {
7
7
  } from "./TaskTools.ts"
8
8
  import { ClankaModels, clankaSubagent } from "./ClankaModels.ts"
9
9
  import { withStallTimeout } from "./shared/stream.ts"
10
- import type { AiError } from "effect/unstable/ai"
11
- import type { RunnerStalled } from "./domain/Errors.ts"
12
10
 
13
11
  export const runClanka = Effect.fnUntraced(
14
12
  /** The working directory to run the agent in */
@@ -18,6 +16,7 @@ export const runClanka = Effect.fnUntraced(
18
16
  readonly prompt: string
19
17
  readonly system?: string | undefined
20
18
  readonly stallTimeout?: Duration.Input | undefined
19
+ readonly steer?: Stream.Stream<string> | undefined
21
20
  readonly withChoose?: boolean | undefined
22
21
  }) {
23
22
  const models = yield* ClankaModels
@@ -33,6 +32,19 @@ export const runClanka = Effect.fnUntraced(
33
32
  ? withStallTimeout(options.stallTimeout)(agent.output)
34
33
  : agent.output
35
34
 
35
+ if (options.steer) {
36
+ yield* options.steer.pipe(
37
+ Stream.switchMap(
38
+ Effect.fnUntraced(function* (message) {
39
+ yield* Effect.log(`Received steer message: ${message}`)
40
+ yield* agent.steer(message)
41
+ }, Stream.fromEffectDrain),
42
+ ),
43
+ Stream.runDrain,
44
+ Effect.forkScoped,
45
+ )
46
+ }
47
+
36
48
  return yield* stream.pipe(
37
49
  OutputFormatter.pretty,
38
50
  Stream.runForEachArray((out) => {
@@ -41,7 +53,6 @@ export const runClanka = Effect.fnUntraced(
41
53
  }
42
54
  return Effect.void
43
55
  }),
44
- (_) => _ as Effect.Effect<void, AiError.AiError | RunnerStalled>,
45
56
  )
46
57
  },
47
58
  Effect.scoped,
package/src/Editor.ts CHANGED
@@ -57,7 +57,18 @@ export class Editor extends ServiceMap.Service<Editor>()("lalph/Editor", {
57
57
  Effect.option,
58
58
  )
59
59
 
60
- return { edit, editTemp } as const
60
+ const saveTemp = Effect.fnUntraced(function* (
61
+ content: string,
62
+ options: { suffix?: string },
63
+ ) {
64
+ const file = yield* fs.makeTempFile({
65
+ suffix: options.suffix ?? ".txt",
66
+ })
67
+ yield* fs.writeFileString(file, content)
68
+ return file
69
+ })
70
+
71
+ return { edit, editTemp, saveTemp } as const
61
72
  }),
62
73
  }) {
63
74
  static layer = Layer.effect(this, this.make).pipe(
package/src/PromptGen.ts CHANGED
@@ -285,7 +285,11 @@ ${options.prompt}`
285
285
  const promptReviewCustom = (options: {
286
286
  readonly prompt: string
287
287
  readonly specsDirectory: string
288
- }) => `${options.prompt}
288
+ readonly removePrdNotes: boolean
289
+ }) =>
290
+ options.removePrdNotes
291
+ ? options.prompt
292
+ : `${options.prompt}
289
293
 
290
294
  ${prdNotes(options)}`
291
295
 
package/src/TaskTools.ts CHANGED
@@ -1,4 +1,12 @@
1
- import { Deferred, Effect, Schema, ServiceMap, Struct } from "effect"
1
+ import {
2
+ Deferred,
3
+ Effect,
4
+ MutableRef,
5
+ Option,
6
+ Schema,
7
+ ServiceMap,
8
+ Struct,
9
+ } from "effect"
2
10
  import { Tool, Toolkit } from "effect/unstable/ai"
3
11
  import { PrdIssue } from "./domain/PrdIssue.ts"
4
12
  import { IssueSource } from "./IssueSource.ts"
@@ -14,24 +22,36 @@ export class ChosenTaskDeferred extends ServiceMap.Reference(
14
22
  },
15
23
  ) {}
16
24
 
25
+ export class CurrentTaskRef extends ServiceMap.Service<
26
+ CurrentTaskRef,
27
+ MutableRef.MutableRef<PrdIssue>
28
+ >()("lalph/TaskTools/CurrentTaskRef") {
29
+ static update(f: (prev: PrdIssue) => PrdIssue) {
30
+ return Effect.serviceOption(CurrentTaskRef).pipe(
31
+ Effect.map(Option.map((ref) => MutableRef.updateAndGet(ref, f))),
32
+ )
33
+ }
34
+ }
35
+
36
+ const TaskList = Schema.Array(
37
+ Schema.Struct({
38
+ id: Schema.String.annotate({
39
+ documentation: "The unique identifier of the task.",
40
+ }),
41
+ ...Struct.pick(PrdIssue.fields, [
42
+ "title",
43
+ "description",
44
+ "state",
45
+ "priority",
46
+ "blockedBy",
47
+ ]),
48
+ }),
49
+ )
50
+
17
51
  export class TaskTools extends Toolkit.make(
18
52
  Tool.make("listTasks", {
19
53
  description: "Returns the current list of tasks.",
20
- success: Schema.Array(
21
- Schema.Struct({
22
- id: Schema.String.annotate({
23
- documentation: "The unique identifier of the task.",
24
- }),
25
- ...Struct.pick(PrdIssue.fields, [
26
- "title",
27
- "description",
28
- "state",
29
- "priority",
30
- "estimate",
31
- "blockedBy",
32
- ]),
33
- }),
34
- ),
54
+ success: TaskList,
35
55
  dependencies: [CurrentProjectId],
36
56
  }),
37
57
  Tool.make("createTask", {
@@ -41,7 +61,6 @@ export class TaskTools extends Toolkit.make(
41
61
  description: PrdIssue.fields.description,
42
62
  state: PrdIssue.fields.state,
43
63
  priority: PrdIssue.fields.priority,
44
- estimate: PrdIssue.fields.estimate,
45
64
  blockedBy: PrdIssue.fields.blockedBy,
46
65
  }),
47
66
  success: Schema.String,
@@ -58,13 +77,13 @@ export class TaskTools extends Toolkit.make(
58
77
  }),
59
78
  dependencies: [CurrentProjectId],
60
79
  }),
61
- // Tool.make("removeTask", {
62
- // description: "Remove a task by it's id.",
63
- // parameters: Schema.String.annotate({
64
- // identifier: "taskId",
65
- // }),
66
- // dependencies: [CurrentProjectId],
67
- // }),
80
+ Tool.make("removeTask", {
81
+ description: "Remove a task by it's id.",
82
+ parameters: Schema.String.annotate({
83
+ identifier: "taskId",
84
+ }),
85
+ dependencies: [CurrentProjectId],
86
+ }),
68
87
  ) {}
69
88
 
70
89
  export class TaskToolsWithChoose extends Toolkit.merge(
@@ -77,6 +96,11 @@ export class TaskToolsWithChoose extends Toolkit.merge(
77
96
  githubPrNumber: Schema.optional(Schema.Number),
78
97
  }),
79
98
  }),
99
+ Tool.make("listEligibleTasks", {
100
+ description: "List tasks eligible for being chosen with chooseTask.",
101
+ success: TaskList,
102
+ dependencies: [CurrentProjectId],
103
+ }),
80
104
  ),
81
105
  ) {}
82
106
 
@@ -95,10 +119,24 @@ export const TaskToolsHandlers = TaskToolsWithChoose.toLayer(
95
119
  description: issue.description,
96
120
  state: issue.state,
97
121
  priority: issue.priority,
98
- estimate: issue.estimate,
99
122
  blockedBy: issue.blockedBy,
100
123
  }))
101
124
  }, Effect.orDie),
125
+ listEligibleTasks: Effect.fn("TaskTools.listEligibleTasks")(function* () {
126
+ yield* Effect.log(`Calling "listEligibleTasks"`)
127
+ const projectId = yield* CurrentProjectId
128
+ const tasks = yield* source.issues(projectId)
129
+ return tasks
130
+ .filter((t) => t.blockedBy.length === 0 && t.state === "todo")
131
+ .map((issue) => ({
132
+ id: issue.id ?? "",
133
+ title: issue.title,
134
+ description: issue.description,
135
+ state: issue.state,
136
+ priority: issue.priority,
137
+ blockedBy: issue.blockedBy,
138
+ }))
139
+ }, Effect.orDie),
102
140
  chooseTask: Effect.fn("TaskTools.chooseTask")(function* (options) {
103
141
  yield* Effect.log(`Calling "chooseTask"`).pipe(
104
142
  Effect.annotateLogs(options),
@@ -114,6 +152,7 @@ export const TaskToolsHandlers = TaskToolsWithChoose.toLayer(
114
152
  new PrdIssue({
115
153
  ...options,
116
154
  id: null,
155
+ estimate: null,
117
156
  autoMerge: false,
118
157
  }),
119
158
  )
@@ -124,19 +163,20 @@ export const TaskToolsHandlers = TaskToolsWithChoose.toLayer(
124
163
  Effect.annotateLogs({ taskId: options.taskId }),
125
164
  )
126
165
  const projectId = yield* CurrentProjectId
166
+ yield* CurrentTaskRef.update((prev) => prev.update(options))
127
167
  yield* source.updateIssue({
128
168
  projectId,
129
169
  issueId: options.taskId,
130
170
  ...options,
131
171
  })
132
172
  }, Effect.orDie),
133
- // removeTask: Effect.fn("TaskTools.removeTask")(function* (taskId) {
134
- // yield* Effect.log(`Calling "removeTask"`).pipe(
135
- // Effect.annotateLogs({ taskId }),
136
- // )
137
- // const projectId = yield* CurrentProjectId
138
- // yield* source.cancelIssue(projectId, taskId)
139
- // }, Effect.orDie),
173
+ removeTask: Effect.fn("TaskTools.removeTask")(function* (taskId) {
174
+ yield* Effect.log(`Calling "removeTask"`).pipe(
175
+ Effect.annotateLogs({ taskId }),
176
+ )
177
+ const projectId = yield* CurrentProjectId
178
+ yield* source.cancelIssue(projectId, taskId)
179
+ }, Effect.orDie),
140
180
  })
141
181
  }),
142
182
  )
@@ -1,6 +1,6 @@
1
1
  import { Command } from "effect/unstable/cli"
2
2
  import { CurrentIssueSource } from "../CurrentIssueSource.ts"
3
- import { Effect, flow, Option, Schema } from "effect"
3
+ import { Effect, Exit, flow, Option, pipe, Schema } from "effect"
4
4
  import { IssueSource } from "../IssueSource.ts"
5
5
  import { PrdIssue } from "../domain/PrdIssue.ts"
6
6
  import * as Yaml from "yaml"
@@ -40,8 +40,18 @@ const handler = flow(
40
40
  if (Option.isNone(content)) {
41
41
  return
42
42
  }
43
+ const contentValue = content.value.trim()
43
44
 
44
- const lines = content.value.split("\n")
45
+ yield* Effect.addFinalizer((exit) => {
46
+ if (Exit.isSuccess(exit)) return Effect.void
47
+ return pipe(
48
+ editor.saveTemp(contentValue, { suffix: ".md" }),
49
+ Effect.flatMap((file) => Effect.log(`Saved your issue to: ${file}`)),
50
+ Effect.ignore,
51
+ )
52
+ })
53
+
54
+ const lines = contentValue.split("\n")
45
55
  const yamlLines: string[] = []
46
56
  let descriptionStartIndex = 0
47
57
  for (let i = 0; i < lines.length; i++) {
@@ -81,7 +91,7 @@ const handler = flow(
81
91
  console.log(`Created issue with ID: ${created.id}`)
82
92
  console.log(`URL: ${created.url}`)
83
93
  }).pipe(Effect.provide([layerProjectIdPrompt, CurrentIssueSource.layer]))
84
- }),
94
+ }, Effect.scoped),
85
95
  ),
86
96
  Command.provide(Editor.layer),
87
97
  )
@@ -1,4 +1,13 @@
1
- import { Data, Effect, FileSystem, Option, Path, pipe, Schema } from "effect"
1
+ import {
2
+ Data,
3
+ Effect,
4
+ Exit,
5
+ FileSystem,
6
+ Option,
7
+ Path,
8
+ pipe,
9
+ Schema,
10
+ } from "effect"
2
11
  import { PromptGen } from "../PromptGen.ts"
3
12
  import { Prd } from "../Prd.ts"
4
13
  import { Worktree } from "../Worktree.ts"
@@ -48,41 +57,54 @@ export const commandPlan = Command.make("plan", {
48
57
  "Draft a plan in your editor (or use --file); then generate a specification under --specs and create PRD tasks from it. Use --new to create a project first, and --dangerous to skip permission prompts during spec generation.",
49
58
  ),
50
59
  Command.withHandler(
51
- Effect.fnUntraced(function* ({ dangerous, withNewProject, file }) {
52
- const editor = yield* Editor
53
- const fs = yield* FileSystem.FileSystem
54
-
55
- const thePlan = yield* Effect.matchEffect(file.asEffect(), {
56
- onFailure: () => editor.editTemp({ suffix: ".md" }),
57
- onSuccess: (path) => fs.readFileString(path).pipe(Effect.asSome),
58
- })
59
-
60
- if (Option.isNone(thePlan)) return
61
-
62
- // We nest this effect, so we can launch the editor first as fast as
63
- // possible
64
- yield* Effect.gen(function* () {
65
- const project = withNewProject
66
- ? yield* addOrUpdateProject()
67
- : yield* selectProject
68
- const { specsDirectory } = yield* commandRoot
69
- const preset = yield* selectCliAgentPreset
70
-
71
- yield* plan({
72
- plan: thePlan.value,
73
- specsDirectory,
74
- targetBranch: project.targetBranch,
75
- dangerous,
76
- preset,
77
- }).pipe(Effect.provideService(CurrentProjectId, project.id))
78
- }).pipe(
79
- Effect.provide([
80
- Settings.layer,
81
- CurrentIssueSource.layer,
82
- ClankaModels.layer,
83
- ]),
84
- )
85
- }, Effect.provide(Editor.layer)),
60
+ Effect.fnUntraced(
61
+ function* ({ dangerous, withNewProject, file }) {
62
+ const editor = yield* Editor
63
+ const fs = yield* FileSystem.FileSystem
64
+
65
+ const thePlan = yield* Effect.matchEffect(file.asEffect(), {
66
+ onFailure: () => editor.editTemp({ suffix: ".md" }),
67
+ onSuccess: (path) => fs.readFileString(path).pipe(Effect.asSome),
68
+ })
69
+
70
+ if (Option.isNone(thePlan)) return
71
+
72
+ yield* Effect.addFinalizer((exit) => {
73
+ if (Exit.isSuccess(exit)) return Effect.void
74
+ return pipe(
75
+ editor.saveTemp(thePlan.value, { suffix: ".md" }),
76
+ Effect.flatMap((file) => Effect.log(`Saved your plan to: ${file}`)),
77
+ Effect.ignore,
78
+ )
79
+ })
80
+
81
+ // We nest this effect, so we can launch the editor first as fast as
82
+ // possible
83
+ yield* Effect.gen(function* () {
84
+ const project = withNewProject
85
+ ? yield* addOrUpdateProject()
86
+ : yield* selectProject
87
+ const { specsDirectory } = yield* commandRoot
88
+ const preset = yield* selectCliAgentPreset
89
+
90
+ yield* plan({
91
+ plan: thePlan.value,
92
+ specsDirectory,
93
+ targetBranch: project.targetBranch,
94
+ dangerous,
95
+ preset,
96
+ }).pipe(Effect.provideService(CurrentProjectId, project.id))
97
+ }).pipe(
98
+ Effect.provide([
99
+ Settings.layer,
100
+ CurrentIssueSource.layer,
101
+ ClankaModels.layer,
102
+ ]),
103
+ )
104
+ },
105
+ Effect.scoped,
106
+ Effect.provide(Editor.layer),
107
+ ),
86
108
  ),
87
109
  Command.withSubcommands([commandPlanTasks]),
88
110
  )
@@ -6,9 +6,12 @@ import {
6
6
  FiberSet,
7
7
  FileSystem,
8
8
  Iterable,
9
+ MutableRef,
9
10
  Option,
10
11
  Path,
11
12
  PlatformError,
13
+ Result,
14
+ Schedule,
12
15
  Schema,
13
16
  Scope,
14
17
  Semaphore,
@@ -48,6 +51,7 @@ import type { TimeoutError } from "effect/Cause"
48
51
  import type { ChildProcessSpawner } from "effect/unstable/process"
49
52
  import { ClankaModels } from "../ClankaModels.ts"
50
53
  import type { AiError } from "effect/unstable/ai/AiError"
54
+ import type { PrdIssue } from "../domain/PrdIssue.ts"
51
55
 
52
56
  // Main iteration run logic
53
57
 
@@ -201,18 +205,37 @@ const run = Effect.fnUntraced(
201
205
  )
202
206
 
203
207
  const promptGen = yield* PromptGen
204
- const instructions = promptGen.prompt({
205
- specsDirectory: options.specsDirectory,
206
- targetBranch: Option.getOrUndefined(options.targetBranch),
207
- task: chosenTask.prd,
208
- githubPrNumber: chosenTask.githubPrNumber ?? undefined,
209
- gitFlow,
208
+ const instructions = taskPreset.cliAgent.command
209
+ ? promptGen.prompt({
210
+ specsDirectory: options.specsDirectory,
211
+ targetBranch: Option.getOrUndefined(options.targetBranch),
212
+ task: chosenTask.prd,
213
+ githubPrNumber: chosenTask.githubPrNumber ?? undefined,
214
+ gitFlow,
215
+ })
216
+ : promptGen.promptClanka({
217
+ specsDirectory: options.specsDirectory,
218
+ targetBranch: Option.getOrUndefined(options.targetBranch),
219
+ task: chosenTask.prd,
220
+ githubPrNumber: chosenTask.githubPrNumber ?? undefined,
221
+ gitFlow,
222
+ })
223
+
224
+ const issueRef = MutableRef.make(
225
+ chosenTask.prd.update({
226
+ state: "in-progress",
227
+ }),
228
+ )
229
+ const steer = yield* taskUpdateSteer({
230
+ issueId: taskId,
231
+ current: issueRef,
210
232
  })
211
233
 
212
234
  const exitCode = yield* agentWorker({
213
235
  stallTimeout: options.stallTimeout,
214
236
  preset: taskPreset,
215
237
  prompt: instructions,
238
+ steer,
216
239
  }).pipe(catchStallInReview, Effect.withSpan("Main.agentWorker"))
217
240
  yield* Effect.log(`Agent exited with code: ${exitCode}`)
218
241
 
@@ -507,3 +530,33 @@ const watchTaskState = Effect.fnUntraced(function* (options: {
507
530
  Effect.withSpan("Main.watchTaskState"),
508
531
  )
509
532
  })
533
+
534
+ const taskUpdateSteer = Effect.fnUntraced(function* (options: {
535
+ readonly issueId: string
536
+ readonly current: MutableRef.MutableRef<PrdIssue>
537
+ }) {
538
+ const registry = yield* AtomRegistry.AtomRegistry
539
+ const projectId = yield* CurrentProjectId
540
+
541
+ return AtomRegistry.toStreamResult(
542
+ registry,
543
+ currentIssuesAtom(projectId),
544
+ ).pipe(
545
+ Stream.drop(1),
546
+ Stream.retry(Schedule.forever),
547
+ Stream.orDie,
548
+ Stream.filterMap((issues) => {
549
+ const issue = issues.find((entry) => entry.id === options.issueId)
550
+ if (!issue) return Result.failVoid
551
+ if (!issue.isChangedComparedTo(options.current.current)) {
552
+ return Result.failVoid
553
+ }
554
+ MutableRef.set(options.current, issue)
555
+ return Result.succeed(`The task has been updated by the user. Here is the latest information:
556
+
557
+ # ${issue.title}
558
+
559
+ ${issue.description}`)
560
+ }),
561
+ )
562
+ })
@@ -105,4 +105,19 @@ export class PrdIssue extends Schema.Class<PrdIssue>("PrdIssue")({
105
105
  autoMerge,
106
106
  })
107
107
  }
108
+
109
+ update(options: {
110
+ readonly title?: string | undefined
111
+ readonly description?: string | undefined
112
+ readonly state?: PrdIssue["state"] | undefined
113
+ readonly blockedBy?: ReadonlyArray<string> | undefined
114
+ }): PrdIssue {
115
+ return new PrdIssue({
116
+ ...this,
117
+ title: options.title ?? this.title,
118
+ description: options.description ?? this.description,
119
+ state: options.state ?? this.state,
120
+ blockedBy: options.blockedBy ?? this.blockedBy,
121
+ })
122
+ }
108
123
  }