lalph 0.3.32 → 0.3.34

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.32",
4
+ "version": "0.3.34",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
@@ -23,13 +23,13 @@
23
23
  "@changesets/changelog-github": "^0.5.2",
24
24
  "@changesets/cli": "^2.29.8",
25
25
  "@effect/language-service": "^0.75.1",
26
- "@effect/platform-node": "4.0.0-beta.12",
26
+ "@effect/platform-node": "4.0.0-beta.21",
27
27
  "@linear/sdk": "^75.0.0",
28
28
  "@octokit/plugin-rest-endpoint-methods": "^17.0.0",
29
29
  "@octokit/types": "^16.0.0",
30
30
  "@typescript/native-preview": "7.0.0-dev.20260219.1",
31
31
  "concurrently": "^9.2.1",
32
- "effect": "4.0.0-beta.12",
32
+ "effect": "4.0.0-beta.21",
33
33
  "husky": "^9.1.7",
34
34
  "lint-staged": "^16.2.7",
35
35
  "octokit": "^5.0.5",
@@ -1,6 +1,6 @@
1
1
  import { Effect, Path, pipe } from "effect"
2
2
  import { PromptGen } from "../PromptGen.ts"
3
- import { ChildProcess } from "effect/unstable/process"
3
+ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
4
4
  import { Worktree } from "../Worktree.ts"
5
5
  import type { CliAgentPreset } from "../domain/CliAgentPreset.ts"
6
6
 
@@ -13,6 +13,7 @@ export const agentPlanner = Effect.fnUntraced(function* (options: {
13
13
  const pathService = yield* Path.Path
14
14
  const worktree = yield* Worktree
15
15
  const promptGen = yield* PromptGen
16
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
16
17
 
17
18
  yield* pipe(
18
19
  options.preset.cliAgent.commandPlan({
@@ -22,6 +23,6 @@ export const agentPlanner = Effect.fnUntraced(function* (options: {
22
23
  }),
23
24
  ChildProcess.setCwd(worktree.directory),
24
25
  options.preset.withCommandPrefix,
25
- ChildProcess.exitCode,
26
+ spawner.exitCode,
26
27
  )
27
28
  })
package/src/Editor.ts CHANGED
@@ -15,7 +15,7 @@ export class Editor extends ServiceMap.Service<Editor>()("lalph/Editor", {
15
15
  stdout: "inherit",
16
16
  stderr: "inherit",
17
17
  }).pipe(
18
- ChildProcess.exitCode,
18
+ spawner.exitCode,
19
19
  Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
20
20
  Effect.orDie,
21
21
  )
@@ -41,7 +41,7 @@ export class Editor extends ServiceMap.Service<Editor>()("lalph/Editor", {
41
41
  stdout: "inherit",
42
42
  stderr: "inherit",
43
43
  },
44
- ).pipe(ChildProcess.exitCode)
44
+ ).pipe(spawner.exitCode)
45
45
 
46
46
  if (exitCode !== 0) {
47
47
  return yield* new Cause.NoSuchElementError()
package/src/GitFlow.ts CHANGED
@@ -81,6 +81,8 @@ After making any changes, commit and push them to the same pull request.
81
81
  postWork: () => Effect.void,
82
82
  autoMerge: Effect.fnUntraced(function* (options) {
83
83
  const prd = yield* Prd
84
+ const source = yield* IssueSource
85
+ const projectId = yield* CurrentProjectId
84
86
  const worktree = options.worktree
85
87
 
86
88
  let prState = (yield* worktree.viewPrState()).pipe(
@@ -101,6 +103,14 @@ After making any changes, commit and push them to the same pull request.
101
103
  prState = yield* worktree.viewPrState(prState.value.number)
102
104
  yield* Effect.log("PR state after merge", prState)
103
105
  if (Option.isSome(prState) && prState.value.state === "MERGED") {
106
+ const issue = yield* prd.findById(options.issueId)
107
+ if (issue && issue.state !== "done") {
108
+ yield* source.updateIssue({
109
+ projectId,
110
+ issueId: options.issueId,
111
+ state: "done",
112
+ })
113
+ }
104
114
  return
105
115
  }
106
116
  yield* Effect.log("Flagging unmergable PR")
package/src/Github/Cli.ts CHANGED
@@ -23,7 +23,7 @@ export class GithubCli extends ServiceMap.Service<GithubCli>()(
23
23
 
24
24
  const nameWithOwner =
25
25
  yield* ChildProcess.make`gh repo view --json nameWithOwner -q ${".nameWithOwner"}`.pipe(
26
- ChildProcess.string,
26
+ spawner.string,
27
27
  Effect.option,
28
28
  Effect.flatMap(
29
29
  flow(
@@ -40,7 +40,7 @@ export class GithubCli extends ServiceMap.Service<GithubCli>()(
40
40
 
41
41
  const reviewComments = (pr: number) =>
42
42
  ChildProcess.make`gh api graphql -f owner=${owner} -f repo=${repo} -F pr=${pr} -f query=${githubReviewCommentsQuery}`.pipe(
43
- ChildProcess.string,
43
+ spawner.string,
44
44
  Effect.flatMap(Schema.decodeEffect(PullRequestDataFromJson)),
45
45
  Effect.map((data) => {
46
46
  const comments =
package/src/Github.ts CHANGED
@@ -33,6 +33,7 @@ export class GithubError extends Data.TaggedError("GithubError")<{
33
33
 
34
34
  export type GithubApi = Api["rest"]
35
35
  export type GithubResponse<A> = OctokitResponse<A>
36
+ type GithubResponseHeaders = OctokitResponse<unknown>["headers"]
36
37
 
37
38
  export interface GithubService {
38
39
  readonly request: <A>(
@@ -46,6 +47,13 @@ export interface GithubService {
46
47
  ) => Stream.Stream<A, GithubError, never>
47
48
  }
48
49
 
50
+ type CachedGetResponse = {
51
+ readonly etag: string
52
+ readonly data: unknown
53
+ readonly headers: GithubResponseHeaders
54
+ readonly url: string
55
+ }
56
+
49
57
  export class Github extends ServiceMap.Service<Github, GithubService>()(
50
58
  "lalph/Github",
51
59
  {
@@ -53,7 +61,54 @@ export class Github extends ServiceMap.Service<Github, GithubService>()(
53
61
  const tokens = yield* TokenManager
54
62
  const clients = yield* RcMap.make({
55
63
  lookup: (token: string) =>
56
- Effect.succeed(new Octokit({ auth: token }).rest),
64
+ Effect.sync(() => {
65
+ const octokit = new Octokit({ auth: token })
66
+ const etagCache = new Map<string, CachedGetResponse>()
67
+
68
+ octokit.hook.wrap("request", async (request, options) => {
69
+ if (requestMethod(options) !== "GET") {
70
+ return request(options)
71
+ }
72
+
73
+ const key = requestCacheKey(options)
74
+ const cached = etagCache.get(key)
75
+ const ifNoneMatchHeader = cached
76
+ ? {
77
+ ...options.headers,
78
+ "if-none-match": cached.etag,
79
+ }
80
+ : options.headers
81
+
82
+ try {
83
+ const response = await request({
84
+ ...options,
85
+ headers: ifNoneMatchHeader,
86
+ })
87
+ const etag = etagHeaderValue(response.headers)
88
+ if (etag !== undefined) {
89
+ etagCache.set(key, {
90
+ etag,
91
+ data: response.data,
92
+ headers: response.headers,
93
+ url: response.url,
94
+ })
95
+ }
96
+ return response
97
+ } catch (cause) {
98
+ if (isNotModifiedError(cause) && cached) {
99
+ return {
100
+ status: 200,
101
+ headers: cached.headers,
102
+ url: cached.url,
103
+ data: cached.data,
104
+ }
105
+ }
106
+ throw cause
107
+ }
108
+ })
109
+
110
+ return octokit.rest
111
+ }),
57
112
  idleTimeToLive: "1 minute",
58
113
  })
59
114
  const getClient = tokens.get.pipe(
@@ -620,3 +675,36 @@ const maybeNextPage = (page: number, linkHeader?: string) =>
620
675
  Option.filter((_) => _.includes(`rel="next"`)),
621
676
  Option.as(page + 1),
622
677
  )
678
+
679
+ const requestMethod = (options: { readonly method?: string }) =>
680
+ options.method?.toUpperCase() ?? "GET"
681
+
682
+ const requestCacheKey = (options: {
683
+ readonly method?: string
684
+ readonly url?: string
685
+ readonly headers?: Record<string, unknown>
686
+ }) => {
687
+ const method = requestMethod(options)
688
+ const url = options.url ?? ""
689
+ const acceptHeader = options.headers?.accept
690
+ const accept = typeof acceptHeader === "string" ? acceptHeader : ""
691
+ return `${method}:${url}:${accept}`
692
+ }
693
+
694
+ const etagHeaderValue = (
695
+ headers: GithubResponseHeaders,
696
+ ): string | undefined => {
697
+ const etag = headers.etag
698
+ if (typeof etag === "string" && etag.length > 0) {
699
+ return etag
700
+ }
701
+ return undefined
702
+ }
703
+
704
+ const isNotModifiedError = (
705
+ cause: unknown,
706
+ ): cause is { readonly status: number } =>
707
+ typeof cause === "object" &&
708
+ cause !== null &&
709
+ "status" in cause &&
710
+ (cause as { readonly status: unknown }).status === 304
package/src/Worktree.ts CHANGED
@@ -28,6 +28,7 @@ export class Worktree extends ServiceMap.Service<Worktree>()("lalph/Worktree", {
28
28
  make: Effect.gen(function* () {
29
29
  const fs = yield* FileSystem.FileSystem
30
30
  const pathService = yield* Path.Path
31
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
31
32
 
32
33
  const inExisting = yield* fs.exists(pathService.join(".lalph", "prd.yml"))
33
34
  if (inExisting) {
@@ -44,13 +45,14 @@ export class Worktree extends ServiceMap.Service<Worktree>()("lalph/Worktree", {
44
45
  yield* Effect.addFinalizer(
45
46
  Effect.fnUntraced(function* () {
46
47
  yield* execIgnore(
48
+ spawner,
47
49
  ChildProcess.make`git worktree remove --force ${directory}`,
48
50
  )
49
51
  }),
50
52
  )
51
53
 
52
54
  yield* ChildProcess.make`git worktree add ${directory} -d HEAD`.pipe(
53
- ChildProcess.exitCode,
55
+ spawner.exitCode,
54
56
  )
55
57
 
56
58
  yield* fs.makeDirectory(pathService.join(directory, ".lalph"), {
@@ -86,8 +88,10 @@ export class Worktree extends ServiceMap.Service<Worktree>()("lalph/Worktree", {
86
88
  )
87
89
  }
88
90
 
89
- const execIgnore = (command: ChildProcess.Command) =>
90
- command.pipe(ChildProcess.exitCode, Effect.catchCause(Effect.logWarning))
91
+ const execIgnore = (
92
+ spawner: ChildProcessSpawner.ChildProcessSpawner["Service"],
93
+ command: ChildProcess.Command,
94
+ ) => command.pipe(spawner.exitCode, Effect.catchCause(Effect.logWarning))
91
95
 
92
96
  const seedSetupScript = Effect.fnUntraced(function* (setupPath: string) {
93
97
  const fs = yield* FileSystem.FileSystem
@@ -111,6 +115,7 @@ const setupWorktree = Effect.fnUntraced(function* (options: {
111
115
  ...args: Array<string | number | boolean>
112
116
  ) => Effect.Effect<ChildProcessSpawner.ExitCode, PlatformError.PlatformError>
113
117
  }) {
118
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
114
119
  const fs = yield* FileSystem.FileSystem
115
120
  const pathService = yield* Path.Path
116
121
  const targetBranch = yield* getTargetBranch
@@ -142,7 +147,7 @@ const setupWorktree = Effect.fnUntraced(function* (options: {
142
147
  yield* ChildProcess.make({
143
148
  cwd: options.directory,
144
149
  shell: process.env.SHELL ?? true,
145
- })`${setupPath}`.pipe(ChildProcess.exitCode)
150
+ })`${setupPath}`.pipe(spawner.exitCode)
146
151
  })
147
152
 
148
153
  const getTargetBranch = Effect.gen(function* () {
@@ -178,7 +183,7 @@ const makeExecHelpers = Effect.fnUntraced(function* (options: {
178
183
  cwd: options.directory,
179
184
  stderr: "inherit",
180
185
  stdout: "inherit",
181
- })(template, ...args).pipe(ChildProcess.exitCode, provide)
186
+ })(template, ...args).pipe(spawner.exitCode, provide)
182
187
 
183
188
  const execString = (
184
189
  template: TemplateStringsArray,
@@ -186,7 +191,7 @@ const makeExecHelpers = Effect.fnUntraced(function* (options: {
186
191
  ) =>
187
192
  ChildProcess.make({
188
193
  cwd: options.directory,
189
- })(template, ...args).pipe(ChildProcess.string, provide)
194
+ })(template, ...args).pipe(spawner.string, provide)
190
195
 
191
196
  const viewPrState = (prNumber?: number) =>
192
197
  execString`gh pr view ${prNumber ? prNumber : ""} --json number,state`.pipe(
@@ -298,7 +303,7 @@ const makeExecHelpers = Effect.fnUntraced(function* (options: {
298
303
  ChildProcess.make({
299
304
  cwd: dir,
300
305
  })`git branch --show-current`.pipe(
301
- ChildProcess.string,
306
+ spawner.string,
302
307
  provide,
303
308
  Effect.flatMap((output) =>
304
309
  Option.some(output.trim()).pipe(
package/src/cli.ts CHANGED
@@ -6,17 +6,17 @@ import { NodeRuntime } from "@effect/platform-node"
6
6
  import { Settings } from "./Settings.ts"
7
7
  import { commandRoot } from "./commands/root.ts"
8
8
  import { commandPlan } from "./commands/plan.ts"
9
- import { commandIssue, commandIssueAlias } from "./commands/issue.ts"
10
- import { commandEdit, commandEditAlias } from "./commands/edit.ts"
9
+ import { commandIssue } from "./commands/issue.ts"
10
+ import { commandEdit } from "./commands/edit.ts"
11
11
  import { commandSource } from "./commands/source.ts"
12
12
  import PackageJson from "../package.json" with { type: "json" }
13
13
  import { TracingLayer } from "./Tracing.ts"
14
14
  import { MinimumLogLevel } from "effect/References"
15
15
  import { atomRuntime, lalphMemoMap } from "./shared/runtime.ts"
16
16
  import { PlatformServices } from "./shared/platform.ts"
17
- import { commandProjects, commandProjectsAlias } from "./commands/projects.ts"
17
+ import { commandProjects } from "./commands/projects.ts"
18
18
  import { commandSh } from "./commands/sh.ts"
19
- import { commandAgents, commandAgentsAlias } from "./commands/agents.ts"
19
+ import { commandAgents } from "./commands/agents.ts"
20
20
 
21
21
  commandRoot.pipe(
22
22
  Command.withSubcommands([
@@ -27,10 +27,6 @@ commandRoot.pipe(
27
27
  commandSource,
28
28
  commandAgents,
29
29
  commandProjects,
30
- commandAgentsAlias,
31
- commandEditAlias,
32
- commandIssueAlias,
33
- commandProjectsAlias,
34
30
  ]),
35
31
  Command.provide(Settings.layer),
36
32
  Command.provide(TracingLayer),
@@ -15,12 +15,6 @@ export const commandAgents = Command.make("agents").pipe(
15
15
  Command.withDescription(
16
16
  "Manage agent presets used to run tasks. Use 'ls' to inspect presets and 'add'/'edit' to configure agents, arguments, and any issue-source options.",
17
17
  ),
18
- subcommands,
19
- )
20
-
21
- export const commandAgentsAlias = Command.make("a").pipe(
22
- Command.withDescription(
23
- "Alias for 'agents' (manage agent presets used to run tasks).",
24
- ),
18
+ Command.withAlias("a"),
25
19
  subcommands,
26
20
  )
@@ -22,12 +22,6 @@ export const commandEdit = Command.make("edit").pipe(
22
22
  Command.withDescription(
23
23
  "Open the selected project's .lalph/prd.yml in your editor.",
24
24
  ),
25
- handler,
26
- )
27
-
28
- export const commandEditAlias = Command.make("e").pipe(
29
- Command.withDescription(
30
- "Alias for 'edit' (open the selected project's .lalph/prd.yml in your editor).",
31
- ),
25
+ Command.withAlias("e"),
32
26
  handler,
33
27
  )
@@ -88,12 +88,6 @@ const handler = flow(
88
88
 
89
89
  export const commandIssue = Command.make("issue").pipe(
90
90
  Command.withDescription("Create a new issue in your editor."),
91
- handler,
92
- )
93
-
94
- export const commandIssueAlias = Command.make("i").pipe(
95
- Command.withDescription(
96
- "Alias for 'issue' (create a new issue in your editor).",
97
- ),
91
+ Command.withAlias("i"),
98
92
  handler,
99
93
  )
@@ -7,7 +7,7 @@ import { PromptGen } from "../../PromptGen.ts"
7
7
  import { Settings } from "../../Settings.ts"
8
8
  import { Worktree } from "../../Worktree.ts"
9
9
  import { commandRoot } from "../root.ts"
10
- import { getDefaultCliAgentPreset } from "../../Presets.ts"
10
+ import { selectCliAgentPreset } from "../../Presets.ts"
11
11
  import { CurrentIssueSource } from "../../CurrentIssueSource.ts"
12
12
 
13
13
  const specificationPath = Argument.path("spec", {
@@ -32,7 +32,7 @@ export const commandPlanTasks = Command.make("tasks", {
32
32
  const fs = yield* FileSystem.FileSystem
33
33
  const pathService = yield* Path.Path
34
34
  const worktree = yield* Worktree
35
- const preset = yield* getDefaultCliAgentPreset
35
+ const preset = yield* selectCliAgentPreset
36
36
 
37
37
  const content = yield* fs.readFileString(specificationPath)
38
38
  const relative = pathService.relative(
@@ -11,8 +11,8 @@ import { agentPlanner } from "../Agents/planner.ts"
11
11
  import { agentTasker } from "../Agents/tasker.ts"
12
12
  import { commandPlanTasks } from "./plan/tasks.ts"
13
13
  import { Editor } from "../Editor.ts"
14
- import { getDefaultCliAgentPreset } from "../Presets.ts"
15
- import { ChildProcess } from "effect/unstable/process"
14
+ import { selectCliAgentPreset } from "../Presets.ts"
15
+ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
16
16
  import { parseBranch } from "../shared/git.ts"
17
17
 
18
18
  const dangerous = Flag.boolean("dangerous").pipe(
@@ -87,7 +87,7 @@ const plan = Effect.fnUntraced(
87
87
  const fs = yield* FileSystem.FileSystem
88
88
  const pathService = yield* Path.Path
89
89
  const worktree = yield* Worktree
90
- const preset = yield* getDefaultCliAgentPreset
90
+ const preset = yield* selectCliAgentPreset
91
91
 
92
92
  yield* agentPlanner({
93
93
  plan: options.plan,
@@ -155,6 +155,7 @@ const commitAndPushSpecification = Effect.fnUntraced(
155
155
  }) {
156
156
  const worktree = yield* Worktree
157
157
  const pathService = yield* Path.Path
158
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
158
159
 
159
160
  const absSpecsDirectory = pathService.join(
160
161
  worktree.directory,
@@ -166,7 +167,7 @@ const commitAndPushSpecification = Effect.fnUntraced(
166
167
  cwd: worktree.directory,
167
168
  stdout: "inherit",
168
169
  stderr: "inherit",
169
- }).pipe(ChildProcess.exitCode)
170
+ }).pipe(spawner.exitCode)
170
171
 
171
172
  const addCode = yield* git(["add", absSpecsDirectory])
172
173
  if (addCode !== 0) {
@@ -17,10 +17,6 @@ export const commandProjects = Command.make("projects").pipe(
17
17
  Command.withDescription(
18
18
  "Manage projects and their execution settings (enabled state, concurrency, target branch, git flow, review agent). Use 'ls' to inspect and 'add', 'edit', or 'toggle' to configure.",
19
19
  ),
20
- subcommands,
21
- )
22
-
23
- export const commandProjectsAlias = Command.make("p").pipe(
24
- Command.withDescription("Alias for 'projects'."),
20
+ Command.withAlias("p"),
25
21
  subcommands,
26
22
  )
@@ -1,6 +1,6 @@
1
1
  import { Command } from "effect/unstable/cli"
2
2
  import { Effect, FileSystem, Layer, Path } from "effect"
3
- import { ChildProcess } from "effect/unstable/process"
3
+ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
4
4
  import { Prd } from "../Prd.ts"
5
5
  import { Worktree } from "../Worktree.ts"
6
6
  import { layerProjectIdPrompt } from "../Projects.ts"
@@ -17,6 +17,7 @@ export const commandSh = Command.make("sh").pipe(
17
17
  const fs = yield* FileSystem.FileSystem
18
18
  const pathService = yield* Path.Path
19
19
  const lalphDirectory = yield* resolveLalphDirectory
20
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
20
21
 
21
22
  // link to lalph config
22
23
  yield* fs.symlink(
@@ -33,7 +34,7 @@ export const commandSh = Command.make("sh").pipe(
33
34
  stdin: "inherit",
34
35
  stdout: "inherit",
35
36
  stderr: "inherit",
36
- }).pipe(ChildProcess.exitCode)
37
+ }).pipe(spawner.exitCode)
37
38
  },
38
39
  Effect.scoped,
39
40
  Effect.provide(
@@ -4,5 +4,5 @@ import { parseCommand } from "./child-process.ts"
4
4
  export const configEditor = Config.string("LALPH_EDITOR").pipe(
5
5
  Config.orElse(() => Config.string("EDITOR")),
6
6
  Config.map(parseCommand),
7
- Config.withDefault(() => ["nano"]),
7
+ Config.withDefault(["nano"]),
8
8
  )
@@ -5,6 +5,6 @@ export const streamFilterJson = <S extends Schema.Top>(schema: S) => {
5
5
  const decode = Schema.decodeEffect(fromString)
6
6
  return flow(
7
7
  Stream.splitLines,
8
- Stream.filterEffect((line) => decode(line).pipe(Effect.result)),
8
+ Stream.filterMapEffect((line) => decode(line).pipe(Effect.result)),
9
9
  )
10
10
  }