lalph 0.3.46 → 0.3.48

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.46",
4
+ "version": "0.3.48",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
@@ -30,7 +30,7 @@
30
30
  "@octokit/plugin-rest-endpoint-methods": "^17.0.0",
31
31
  "@octokit/types": "^16.0.0",
32
32
  "@typescript/native-preview": "7.0.0-dev.20260310.1",
33
- "clanka": "^0.0.16",
33
+ "clanka": "^0.0.21",
34
34
  "concurrently": "^9.2.1",
35
35
  "effect": "4.0.0-beta.30",
36
36
  "husky": "^9.1.7",
@@ -1,4 +1,4 @@
1
- import { Duration, Effect, Path, pipe } from "effect"
1
+ import { Duration, Effect, 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"
@@ -10,6 +10,7 @@ export const agentWorker = Effect.fnUntraced(function* (options: {
10
10
  readonly preset: CliAgentPreset
11
11
  readonly system?: string
12
12
  readonly prompt: string
13
+ readonly steer?: Stream.Stream<string>
13
14
  }) {
14
15
  const pathService = yield* Path.Path
15
16
  const worktree = yield* Worktree
@@ -22,6 +23,7 @@ export const agentWorker = Effect.fnUntraced(function* (options: {
22
23
  system: options.system,
23
24
  prompt: options.prompt,
24
25
  stallTimeout: options.stallTimeout,
26
+ steer: options.steer,
25
27
  })
26
28
  return ExitCode(0)
27
29
  }
package/src/Clanka.ts CHANGED
@@ -1,47 +1,65 @@
1
1
  import { Agent, OutputFormatter } from "clanka"
2
- import { Duration, Effect, Stream } from "effect"
2
+ import { Duration, Effect, Layer, Stdio, Stream } from "effect"
3
3
  import {
4
+ TaskChooseTools,
4
5
  TaskTools,
5
6
  TaskToolsHandlers,
6
7
  TaskToolsWithChoose,
7
8
  } from "./TaskTools.ts"
8
9
  import { ClankaModels, clankaSubagent } from "./ClankaModels.ts"
9
10
  import { withStallTimeout } from "./shared/stream.ts"
10
- import type { AiError } from "effect/unstable/ai"
11
- import type { RunnerStalled } from "./domain/Errors.ts"
11
+
12
+ export const ClankaMuxerLayer = Layer.effectDiscard(
13
+ Effect.gen(function* () {
14
+ const muxer = yield* OutputFormatter.Muxer
15
+ const stdio = yield* Stdio.Stdio
16
+ yield* muxer.output.pipe(Stream.run(stdio.stdout()), Effect.forkScoped)
17
+ }),
18
+ ).pipe(Layer.provideMerge(OutputFormatter.layerMuxer(OutputFormatter.pretty)))
12
19
 
13
20
  export const runClanka = Effect.fnUntraced(
14
- /** The working directory to run the agent in */
15
21
  function* (options: {
16
22
  readonly directory: string
17
23
  readonly model: string
18
24
  readonly prompt: string
19
25
  readonly system?: string | undefined
20
26
  readonly stallTimeout?: Duration.Input | undefined
27
+ readonly steer?: Stream.Stream<string> | undefined
21
28
  readonly withChoose?: boolean | undefined
22
29
  }) {
23
30
  const models = yield* ClankaModels
31
+ const muxer = yield* OutputFormatter.Muxer
32
+
24
33
  const agent = yield* Agent.make({
25
34
  ...options,
26
- tools: options.withChoose
27
- ? TaskToolsWithChoose
28
- : (TaskTools as unknown as typeof TaskToolsWithChoose),
35
+ tools: (options.withChoose
36
+ ? TaskChooseTools
37
+ : TaskTools) as unknown as typeof TaskToolsWithChoose,
29
38
  subagentModel: clankaSubagent(models, options.model),
30
39
  }).pipe(Effect.provide(models.get(options.model)))
31
40
 
41
+ yield* muxer.add(agent.output)
42
+
32
43
  let stream = options.stallTimeout
33
44
  ? withStallTimeout(options.stallTimeout)(agent.output)
34
45
  : agent.output
35
46
 
36
- return yield* stream.pipe(
37
- OutputFormatter.pretty,
38
- Stream.runForEachArray((out) => {
39
- for (const item of out) {
40
- process.stdout.write(item)
41
- }
42
- return Effect.void
43
- }),
44
- (_) => _ as Effect.Effect<void, AiError.AiError | RunnerStalled>,
47
+ if (options.steer) {
48
+ yield* options.steer.pipe(
49
+ Stream.switchMap(
50
+ Effect.fnUntraced(function* (message) {
51
+ yield* Effect.log(`Received steer message: ${message}`)
52
+ yield* agent.steer(message)
53
+ }, Stream.fromEffectDrain),
54
+ ),
55
+ Stream.runDrain,
56
+ Effect.forkScoped,
57
+ )
58
+ }
59
+
60
+ yield* stream.pipe(
61
+ Stream.runDrain,
62
+ Effect.catchTag("AgentFinished", () => Effect.void),
45
63
  )
46
64
  },
47
65
  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
@@ -90,12 +90,11 @@ Set \`githubPrNumber\` to the PR number if one exists, otherwise use \`null\`.
90
90
 
91
91
  const promptChooseClanka = (options: {
92
92
  readonly gitFlow: GitFlow["Service"]
93
- }) => `Your job is to choose the next task to work on using "listEligibleTasks".
94
- **DO NOT** implement the task yet.
95
-
96
- The following instructions should be done without interaction or asking for permission.
97
-
98
- - Decide which single task to work on next from "listEligibleTasks". This should
93
+ }) => `- Use the "listEligibleTasks" function to view the list of tasks that you can start working on.
94
+ - **NO NOT PARSE THE yaml OUTPUT IN ANY WAY**
95
+ - **DO NOT** implement the task yet.
96
+ - **DO NOT** use the "delegate" function for any step in this workflow
97
+ - After reading through the list of tasks, choose the task to work on. This should
99
98
  be the task YOU decide as the most important to work on next, not just the
100
99
  first task in the list.${
101
100
  options.gitFlow.requiresGithubPr
package/src/TaskTools.ts CHANGED
@@ -1,8 +1,18 @@
1
- import { Deferred, Effect, Schema, ServiceMap, Struct } from "effect"
1
+ import {
2
+ Deferred,
3
+ Effect,
4
+ MutableRef,
5
+ Option,
6
+ Random,
7
+ Schema,
8
+ ServiceMap,
9
+ Struct,
10
+ } from "effect"
2
11
  import { Tool, Toolkit } from "effect/unstable/ai"
3
12
  import { PrdIssue } from "./domain/PrdIssue.ts"
4
13
  import { IssueSource } from "./IssueSource.ts"
5
14
  import { CurrentProjectId } from "./Settings.ts"
15
+ import * as Yaml from "yaml"
6
16
 
7
17
  export class ChosenTaskDeferred extends ServiceMap.Reference(
8
18
  "lalph/TaskTools/ChosenTaskDeferred",
@@ -14,6 +24,17 @@ export class ChosenTaskDeferred extends ServiceMap.Reference(
14
24
  },
15
25
  ) {}
16
26
 
27
+ export class CurrentTaskRef extends ServiceMap.Service<
28
+ CurrentTaskRef,
29
+ MutableRef.MutableRef<PrdIssue>
30
+ >()("lalph/TaskTools/CurrentTaskRef") {
31
+ static update(f: (prev: PrdIssue) => PrdIssue) {
32
+ return Effect.serviceOption(CurrentTaskRef).pipe(
33
+ Effect.map(Option.map((ref) => MutableRef.updateAndGet(ref, f))),
34
+ )
35
+ }
36
+ }
37
+
17
38
  const TaskList = Schema.Array(
18
39
  Schema.Struct({
19
40
  id: Schema.String.annotate({
@@ -24,12 +45,20 @@ const TaskList = Schema.Array(
24
45
  "description",
25
46
  "state",
26
47
  "priority",
27
- "estimate",
28
48
  "blockedBy",
29
49
  ]),
30
50
  }),
31
51
  )
32
52
 
53
+ const toTaskListItem = (issue: PrdIssue) => ({
54
+ id: issue.id ?? "",
55
+ title: issue.title,
56
+ description: issue.description,
57
+ state: issue.state,
58
+ priority: issue.priority,
59
+ blockedBy: issue.blockedBy,
60
+ })
61
+
33
62
  export class TaskTools extends Toolkit.make(
34
63
  Tool.make("listTasks", {
35
64
  description: "Returns the current list of tasks.",
@@ -43,7 +72,6 @@ export class TaskTools extends Toolkit.make(
43
72
  description: PrdIssue.fields.description,
44
73
  state: PrdIssue.fields.state,
45
74
  priority: PrdIssue.fields.priority,
46
- estimate: PrdIssue.fields.estimate,
47
75
  blockedBy: PrdIssue.fields.blockedBy,
48
76
  }),
49
77
  success: Schema.String,
@@ -69,22 +97,25 @@ export class TaskTools extends Toolkit.make(
69
97
  }),
70
98
  ) {}
71
99
 
100
+ export class TaskChooseTools extends Toolkit.make(
101
+ Tool.make("chooseTask", {
102
+ description: "Choose the task to work on",
103
+ parameters: Schema.Struct({
104
+ taskId: Schema.String,
105
+ githubPrNumber: Schema.optional(Schema.Number),
106
+ }),
107
+ }),
108
+ Tool.make("listEligibleTasks", {
109
+ description:
110
+ "List tasks eligible for being chosen with chooseTask in yaml format.",
111
+ success: Schema.String,
112
+ dependencies: [CurrentProjectId],
113
+ }),
114
+ ) {}
115
+
72
116
  export class TaskToolsWithChoose extends Toolkit.merge(
73
117
  TaskTools,
74
- Toolkit.make(
75
- Tool.make("chooseTask", {
76
- description: "Choose the task to work on",
77
- parameters: Schema.Struct({
78
- taskId: Schema.String,
79
- githubPrNumber: Schema.optional(Schema.Number),
80
- }),
81
- }),
82
- Tool.make("listEligibleTasks", {
83
- description: "List tasks eligible for being chosen with chooseTask.",
84
- success: TaskList,
85
- dependencies: [CurrentProjectId],
86
- }),
87
- ),
118
+ TaskChooseTools,
88
119
  ) {}
89
120
 
90
121
  export const TaskToolsHandlers = TaskToolsWithChoose.toLayer(
@@ -96,31 +127,16 @@ export const TaskToolsHandlers = TaskToolsWithChoose.toLayer(
96
127
  yield* Effect.log(`Calling "listTasks"`)
97
128
  const projectId = yield* CurrentProjectId
98
129
  const tasks = yield* source.issues(projectId)
99
- return tasks.map((issue) => ({
100
- id: issue.id ?? "",
101
- title: issue.title,
102
- description: issue.description,
103
- state: issue.state,
104
- priority: issue.priority,
105
- estimate: issue.estimate,
106
- blockedBy: issue.blockedBy,
107
- }))
130
+ return tasks.map(toTaskListItem)
108
131
  }, Effect.orDie),
109
132
  listEligibleTasks: Effect.fn("TaskTools.listEligibleTasks")(function* () {
110
133
  yield* Effect.log(`Calling "listEligibleTasks"`)
111
134
  const projectId = yield* CurrentProjectId
112
- const tasks = yield* source.issues(projectId)
113
- return tasks
114
- .filter((t) => t.blockedBy.length === 0 && t.state === "todo")
115
- .map((issue) => ({
116
- id: issue.id ?? "",
117
- title: issue.title,
118
- description: issue.description,
119
- state: issue.state,
120
- priority: issue.priority,
121
- estimate: issue.estimate,
122
- blockedBy: issue.blockedBy,
123
- }))
135
+ const tasks = (yield* source.issues(projectId))
136
+ .filter((t) => t.state === "todo" && t.blockedBy.length === 0)
137
+ .map(toTaskListItem)
138
+ const shuffled = yield* Random.shuffle(tasks)
139
+ return Yaml.stringify(shuffled, null, 2)
124
140
  }, Effect.orDie),
125
141
  chooseTask: Effect.fn("TaskTools.chooseTask")(function* (options) {
126
142
  yield* Effect.log(`Calling "chooseTask"`).pipe(
@@ -137,6 +153,7 @@ export const TaskToolsHandlers = TaskToolsWithChoose.toLayer(
137
153
  new PrdIssue({
138
154
  ...options,
139
155
  id: null,
156
+ estimate: null,
140
157
  autoMerge: false,
141
158
  }),
142
159
  )
@@ -147,6 +164,7 @@ export const TaskToolsHandlers = TaskToolsWithChoose.toLayer(
147
164
  Effect.annotateLogs({ taskId: options.taskId }),
148
165
  )
149
166
  const projectId = yield* CurrentProjectId
167
+ yield* CurrentTaskRef.update((prev) => prev.update(options))
150
168
  yield* source.updateIssue({
151
169
  projectId,
152
170
  issueId: options.taskId,
@@ -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
  )
@@ -11,6 +11,7 @@ import { selectCliAgentPreset } from "../../Presets.ts"
11
11
  import { CurrentIssueSource } from "../../CurrentIssueSource.ts"
12
12
  import type { CliAgentPreset } from "../../domain/CliAgentPreset.ts"
13
13
  import { ClankaModels } from "../../ClankaModels.ts"
14
+ import { ClankaMuxerLayer } from "../../Clanka.ts"
14
15
 
15
16
  const specificationPath = Argument.path("spec", {
16
17
  pathType: "file",
@@ -72,6 +73,7 @@ const generateTasks = Effect.fnUntraced(
72
73
  },
73
74
  Effect.provide([
74
75
  ClankaModels.layer,
76
+ ClankaMuxerLayer,
75
77
  Settings.layer,
76
78
  PromptGen.layer,
77
79
  Prd.layerProvided.pipe(Layer.provideMerge(layerProjectIdPrompt)),
@@ -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"
@@ -16,6 +25,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
16
25
  import { parseBranch } from "../shared/git.ts"
17
26
  import type { CliAgentPreset } from "../domain/CliAgentPreset.ts"
18
27
  import { ClankaModels } from "../ClankaModels.ts"
28
+ import { ClankaMuxerLayer } from "../Clanka.ts"
19
29
 
20
30
  const dangerous = Flag.boolean("dangerous").pipe(
21
31
  Flag.withAlias("d"),
@@ -48,41 +58,55 @@ export const commandPlan = Command.make("plan", {
48
58
  "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
59
  ),
50
60
  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)),
61
+ Effect.fnUntraced(
62
+ function* ({ dangerous, withNewProject, file }) {
63
+ const editor = yield* Editor
64
+ const fs = yield* FileSystem.FileSystem
65
+
66
+ const thePlan = yield* Effect.matchEffect(file.asEffect(), {
67
+ onFailure: () => editor.editTemp({ suffix: ".md" }),
68
+ onSuccess: (path) => fs.readFileString(path).pipe(Effect.asSome),
69
+ })
70
+
71
+ if (Option.isNone(thePlan)) return
72
+
73
+ yield* Effect.addFinalizer((exit) => {
74
+ if (Exit.isSuccess(exit)) return Effect.void
75
+ return pipe(
76
+ editor.saveTemp(thePlan.value, { suffix: ".md" }),
77
+ Effect.flatMap((file) => Effect.log(`Saved your plan to: ${file}`)),
78
+ Effect.ignore,
79
+ )
80
+ })
81
+
82
+ // We nest this effect, so we can launch the editor first as fast as
83
+ // possible
84
+ yield* Effect.gen(function* () {
85
+ const project = withNewProject
86
+ ? yield* addOrUpdateProject()
87
+ : yield* selectProject
88
+ const { specsDirectory } = yield* commandRoot
89
+ const preset = yield* selectCliAgentPreset
90
+
91
+ yield* plan({
92
+ plan: thePlan.value,
93
+ specsDirectory,
94
+ targetBranch: project.targetBranch,
95
+ dangerous,
96
+ preset,
97
+ }).pipe(Effect.provideService(CurrentProjectId, project.id))
98
+ }).pipe(
99
+ Effect.provide([
100
+ Settings.layer,
101
+ CurrentIssueSource.layer,
102
+ ClankaModels.layer,
103
+ ClankaMuxerLayer,
104
+ ]),
105
+ )
106
+ },
107
+ Effect.scoped,
108
+ Effect.provide(Editor.layer),
109
+ ),
86
110
  ),
87
111
  Command.withSubcommands([commandPlanTasks]),
88
112
  )
@@ -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,10 @@ 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"
55
+ import { CurrentTaskRef } from "../TaskTools.ts"
56
+ import type { OutputFormatter } from "clanka"
57
+ import { ClankaMuxerLayer } from "../Clanka.ts"
51
58
 
52
59
  // Main iteration run logic
53
60
 
@@ -84,6 +91,7 @@ const run = Effect.fnUntraced(
84
91
  | Prd
85
92
  | Worktree
86
93
  | ClankaModels
94
+ | OutputFormatter.Muxer
87
95
  | Scope.Scope
88
96
  > {
89
97
  const projectId = yield* CurrentProjectId
@@ -217,11 +225,26 @@ const run = Effect.fnUntraced(
217
225
  gitFlow,
218
226
  })
219
227
 
228
+ const issueRef = MutableRef.make(
229
+ chosenTask.prd.update({
230
+ state: "in-progress",
231
+ }),
232
+ )
233
+ const steer = yield* taskUpdateSteer({
234
+ issueId: taskId,
235
+ current: issueRef,
236
+ })
237
+
220
238
  const exitCode = yield* agentWorker({
221
239
  stallTimeout: options.stallTimeout,
222
240
  preset: taskPreset,
223
241
  prompt: instructions,
224
- }).pipe(catchStallInReview, Effect.withSpan("Main.agentWorker"))
242
+ steer,
243
+ }).pipe(
244
+ Effect.provideService(CurrentTaskRef, issueRef),
245
+ catchStallInReview,
246
+ Effect.withSpan("Main.agentWorker"),
247
+ )
225
248
  yield* Effect.log(`Agent exited with code: ${exitCode}`)
226
249
 
227
250
  // 3. Review task
@@ -469,6 +492,7 @@ export const commandRoot = Command.make("lalph", {
469
492
  Effect.scoped,
470
493
  Effect.provide([
471
494
  ClankaModels.layer,
495
+ ClankaMuxerLayer,
472
496
  PromptGen.layer,
473
497
  GithubCli.layer,
474
498
  Settings.layer,
@@ -515,3 +539,33 @@ const watchTaskState = Effect.fnUntraced(function* (options: {
515
539
  Effect.withSpan("Main.watchTaskState"),
516
540
  )
517
541
  })
542
+
543
+ const taskUpdateSteer = Effect.fnUntraced(function* (options: {
544
+ readonly issueId: string
545
+ readonly current: MutableRef.MutableRef<PrdIssue>
546
+ }) {
547
+ const registry = yield* AtomRegistry.AtomRegistry
548
+ const projectId = yield* CurrentProjectId
549
+
550
+ return AtomRegistry.toStreamResult(
551
+ registry,
552
+ currentIssuesAtom(projectId),
553
+ ).pipe(
554
+ Stream.drop(1),
555
+ Stream.retry(Schedule.forever),
556
+ Stream.orDie,
557
+ Stream.filterMap((issues) => {
558
+ const issue = issues.find((entry) => entry.id === options.issueId)
559
+ if (!issue) return Result.failVoid
560
+ if (!issue.isChangedComparedTo(options.current.current)) {
561
+ return Result.failVoid
562
+ }
563
+ MutableRef.set(options.current, issue)
564
+ return Result.succeed(`The task has been updated by the user. Here is the latest information:
565
+
566
+ # ${issue.title}
567
+
568
+ ${issue.description}`)
569
+ }),
570
+ )
571
+ })
@@ -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
  }