lalph 0.1.0 → 0.1.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
@@ -5,24 +5,15 @@ A small CLI that connects to Linear, pulls the next set of unstarted issues into
5
5
  ## Setup
6
6
 
7
7
  - Install dependencies: `pnpm install`
8
- - Build the CLI: `pnpm exec tsc`
8
+ - Build the CLI: `pnpm build`
9
+ - Add `.lalph/` to `.gitignore` to keep local state private
9
10
 
10
11
  ## CLI usage
11
12
 
12
- - Run the main loop: `node dist/cli.js`
13
- - Select a Linear project: `node dist/cli.js select-project`
14
- - Select a label filter: `node dist/cli.js select-label`
15
- - Select a CLI agent: `node dist/cli.js select-agent`
13
+ - Run the main loop: `npx -y lalph@latest`
14
+ - Run multiple iterations with concurrency: `npx -y lalph@latest --iterations 4 --concurrency 2`
15
+ - Select a Linear project: `npx -y lalph@latest select-project`
16
+ - Select a label filter: `npx -y lalph@latest select-label`
17
+ - Select a CLI agent: `npx -y lalph@latest select-agent`
16
18
 
17
19
  The first run opens a Linear OAuth flow and stores the token locally.
18
-
19
- ## Generated files
20
-
21
- - `.lalph/prd.json`: synced task list pulled from Linear; update task states here.
22
- - `PROGRESS.md`: append-only log of work completed by the agent.
23
- - `.lalph/config`: local key-value store for Linear tokens and user selections.
24
-
25
- ## Checks
26
-
27
- - Type check: `pnpm exec tsc --noEmit`
28
- - Format check: `pnpm exec prettier --check .`
package/dist/cli.mjs CHANGED
@@ -8270,8 +8270,8 @@ const whileLoop$1 = /* @__PURE__ */ makePrimitive$1({
8270
8270
  /** @internal */
8271
8271
  const forEach$2 = /* @__PURE__ */ dual((args$1) => typeof args$1[1] === "function", (iterable, f, options) => withFiber$1((parent) => {
8272
8272
  const concurrencyOption = options?.concurrency === "inherit" ? parent.getRef(CurrentConcurrency) : options?.concurrency ?? 1;
8273
- const concurrency = concurrencyOption === "unbounded" ? Number.POSITIVE_INFINITY : Math.max(1, concurrencyOption);
8274
- if (concurrency === 1) return forEachSequential(iterable, f, options);
8273
+ const concurrency$1 = concurrencyOption === "unbounded" ? Number.POSITIVE_INFINITY : Math.max(1, concurrencyOption);
8274
+ if (concurrency$1 === 1) return forEachSequential(iterable, f, options);
8275
8275
  const items = fromIterable$2(iterable);
8276
8276
  let length = items.length;
8277
8277
  if (length === 0) return options?.discard ? void_$3 : succeed$4([]);
@@ -8288,7 +8288,7 @@ const forEach$2 = /* @__PURE__ */ dual((args$1) => typeof args$1[1] === "functio
8288
8288
  let interrupted = false;
8289
8289
  function pump() {
8290
8290
  pumping = true;
8291
- while (inProgress < concurrency && index < length) {
8291
+ while (inProgress < concurrency$1 && index < length) {
8292
8292
  const currentIndex = index;
8293
8293
  const item = items[currentIndex];
8294
8294
  index++;
@@ -8312,7 +8312,7 @@ const forEach$2 = /* @__PURE__ */ dual((args$1) => typeof args$1[1] === "functio
8312
8312
  doneCount++;
8313
8313
  inProgress--;
8314
8314
  if (doneCount === length) resume(failures.length > 0 ? exitFailCause(causeFromFailures(failures)) : succeed$4(out));
8315
- else if (!pumping && !failed && inProgress < concurrency) pump();
8315
+ else if (!pumping && !failed && inProgress < concurrency$1) pump();
8316
8316
  });
8317
8317
  } catch (err) {
8318
8318
  failed = true;
@@ -18354,6 +18354,35 @@ const logWithLevel = logWithLevel$1;
18354
18354
  */
18355
18355
  const log$1 = /* @__PURE__ */ logWithLevel$1();
18356
18356
  /**
18357
+ * Logs one or more messages at the WARNING level.
18358
+ *
18359
+ * @example
18360
+ * ```ts
18361
+ * import { Effect } from "effect"
18362
+ *
18363
+ * const program = Effect.gen(function*() {
18364
+ * yield* Effect.logWarning("API rate limit approaching")
18365
+ * yield* Effect.logWarning("Retries remaining:", 2, "Operation:", "fetchData")
18366
+ *
18367
+ * // Useful for non-critical issues
18368
+ * const deprecated = true
18369
+ * if (deprecated) {
18370
+ * yield* Effect.logWarning("Using deprecated API endpoint")
18371
+ * }
18372
+ * })
18373
+ *
18374
+ * Effect.runPromise(program)
18375
+ * // Output:
18376
+ * // timestamp=2023-... level=WARN message="API rate limit approaching"
18377
+ * // timestamp=2023-... level=WARN message="Retries remaining: 2 Operation: fetchData"
18378
+ * // timestamp=2023-... level=WARN message="Using deprecated API endpoint"
18379
+ * ```
18380
+ *
18381
+ * @since 2.0.0
18382
+ * @category logging
18383
+ */
18384
+ const logWarning = /* @__PURE__ */ logWithLevel$1("Warn");
18385
+ /**
18357
18386
  * Logs one or more messages at the ERROR level.
18358
18387
  *
18359
18388
  * @example
@@ -53211,12 +53240,12 @@ var require_limiter = /* @__PURE__ */ __commonJSMin$1(((exports, module) => {
53211
53240
  * @param {Number} [concurrency=Infinity] The maximum number of jobs allowed
53212
53241
  * to run concurrently
53213
53242
  */
53214
- constructor(concurrency) {
53243
+ constructor(concurrency$1) {
53215
53244
  this[kDone] = () => {
53216
53245
  this.pending--;
53217
53246
  this[kRun]();
53218
53247
  };
53219
- this.concurrency = concurrency || Infinity;
53248
+ this.concurrency = concurrency$1 || Infinity;
53220
53249
  this.jobs = [];
53221
53250
  this.pending = 0;
53222
53251
  }
@@ -132176,9 +132205,42 @@ const teamSelect = fnUntraced(function* (project) {
132176
132205
  yield* selectedTeamId.set(some(teamId));
132177
132206
  });
132178
132207
 
132208
+ //#endregion
132209
+ //#region src/Worktree.ts
132210
+ var Worktree = class extends Service()("lalph/Worktree", { make: gen(function* () {
132211
+ const fs = yield* FileSystem;
132212
+ const pathService = yield* Path$1;
132213
+ const directory = yield* fs.makeTempDirectory();
132214
+ yield* addFinalizer(fnUntraced(function* () {
132215
+ yield* execIgnore(make$21`git worktree remove ${directory}`);
132216
+ }));
132217
+ yield* exec(make$21`git worktree add ${directory} -d HEAD`);
132218
+ yield* fs.makeDirectory(pathService.join(directory, ".lalph"), { recursive: true });
132219
+ yield* forEach$1([
132220
+ make$21({
132221
+ cwd: directory,
132222
+ extendEnv: true,
132223
+ shell: process.env.SHELL ?? true
132224
+ })`direnv allow`,
132225
+ make$21({
132226
+ cwd: directory,
132227
+ extendEnv: true,
132228
+ shell: process.env.SHELL ?? true
132229
+ })`devenv allow`,
132230
+ make$21({ cwd: directory })`git submodule update --init --recursive`
132231
+ ], execIgnore, { concurrency: "unbounded" });
132232
+ return { directory };
132233
+ }) }) {
132234
+ static layer = effect(this, this.make);
132235
+ };
132236
+ const exec = (command) => command.asEffect().pipe(flatMap((proc) => proc.exitCode), scoped$1);
132237
+ const execIgnore = (command) => command.asEffect().pipe(flatMap((proc) => proc.exitCode), catchCause$1(logWarning), scoped$1);
132238
+
132179
132239
  //#endregion
132180
132240
  //#region src/Prd.ts
132181
132241
  var Prd = class extends Service()("lalph/Prd", { make: gen(function* () {
132242
+ const worktree = yield* Worktree;
132243
+ const pathService = yield* Path$1;
132182
132244
  const fs = yield* FileSystem;
132183
132245
  const linear = yield* Linear;
132184
132246
  const project = yield* CurrentProject;
@@ -132190,7 +132252,7 @@ var Prd = class extends Service()("lalph/Prd", { make: gen(function* () {
132190
132252
  } })).pipe(runCollect).pipe(map$4(PrdList.fromLinearIssues));
132191
132253
  if (initial.issues.size === 0) return yield* new NoMoreWork({});
132192
132254
  const current = yield* make$22(initial);
132193
- const prdFile = `.lalph/prd.json`;
132255
+ const prdFile = pathService.join(worktree.directory, `.lalph/prd.json`);
132194
132256
  yield* fs.writeFileString(prdFile, initial.toJson());
132195
132257
  const sync$2 = gen(function* () {
132196
132258
  const json = yield* fs.readFileString(prdFile);
@@ -132212,7 +132274,8 @@ var Prd = class extends Service()("lalph/Prd", { make: gen(function* () {
132212
132274
  }
132213
132275
  const existing = currentValue.issues.get(issue.id);
132214
132276
  if (!existing || !existing.isChangedComparedTo(issue)) continue;
132215
- yield* linear.use((c) => c.updateIssue(issue.id, {
132277
+ const original = currentValue.orignals.get(issue.id);
132278
+ yield* linear.use((c) => c.updateIssue(original.id, {
132216
132279
  description: issue.description,
132217
132280
  stateId: issue.stateId
132218
132281
  }));
@@ -132223,13 +132286,12 @@ var Prd = class extends Service()("lalph/Prd", { make: gen(function* () {
132223
132286
  capacity: 1,
132224
132287
  strategy: "dropping"
132225
132288
  }), runForEach(() => ignore(sync$2)), forkScoped);
132226
- return { current };
132289
+ return {
132290
+ current,
132291
+ path: prdFile
132292
+ };
132227
132293
  }) }) {
132228
- static layer = effect(this, this.make).pipe(provide$2([
132229
- Linear.layer,
132230
- Settings.layer,
132231
- CurrentProject.layer
132232
- ]));
132294
+ static layer = effect(this, this.make).pipe(provide$2([CurrentProject.layer, Worktree.layer]));
132233
132295
  };
132234
132296
  var NoMoreWork = class extends ErrorClass("lalph/Prd/NoMoreWork")({ _tag: tag("NoMoreWork") }) {
132235
132297
  message = "No more work to be done!";
@@ -132251,7 +132313,7 @@ var PrdIssue = class PrdIssue extends Class("PrdIssue")({
132251
132313
  };
132252
132314
  static fromLinearIssue(issue) {
132253
132315
  return new PrdIssue({
132254
- id: issue.id,
132316
+ id: issue.identifier,
132255
132317
  title: issue.title,
132256
132318
  description: issue.description ?? "",
132257
132319
  priority: issue.priority,
@@ -132266,12 +132328,17 @@ var PrdIssue = class PrdIssue extends Class("PrdIssue")({
132266
132328
  var PrdList = class PrdList extends Class$1 {
132267
132329
  static fromLinearIssues(issues) {
132268
132330
  const map$12 = /* @__PURE__ */ new Map();
132331
+ const originalMap = /* @__PURE__ */ new Map();
132269
132332
  for (const issue of issues) {
132270
132333
  const prdIssue = PrdIssue.fromLinearIssue(issue);
132271
132334
  if (!prdIssue.id) continue;
132272
132335
  map$12.set(prdIssue.id, prdIssue);
132336
+ originalMap.set(prdIssue.id, issue);
132273
132337
  }
132274
- return new PrdList({ issues: map$12 });
132338
+ return new PrdList({
132339
+ issues: map$12,
132340
+ orignals: originalMap
132341
+ });
132275
132342
  }
132276
132343
  static fromJson(json) {
132277
132344
  return decodeSync(PrdIssue.ArrayFromJson)(JSON.parse(json));
@@ -132287,20 +132354,25 @@ var PrdList = class PrdList extends Class$1 {
132287
132354
  //#region src/PromptGen.ts
132288
132355
  var PromptGen = class extends Service()("lalph/PromptGen", { make: gen(function* () {
132289
132356
  const linear = yield* Linear;
132290
- const fs = yield* FileSystem;
132291
- yield* scoped$1(fs.open("PROGRESS.md", { flag: "a+" }));
132292
132357
  return { prompt: `# Instructions
132293
132358
 
132294
132359
  1. Decide which single task to work on next from the prd.json file. This should
132295
132360
  be the task YOU decide as the most important to work on next, not just the
132296
132361
  first task in the list.
132297
- - **Important**: Before starting the chosen task, mark it as "in progress" by
132298
- updating its \`stateId\` in the prd.json file.
132299
- This prevents other people or agents from working on the same task simultaneously.
132300
- 3. Run any checks / feedback loops, such as type checks, unit tests, or linting.
132301
- 4. APPEND your progress to the PROGRESS.md file.
132302
- 5. Make a git commit when you have made significant progress or completed the task.
132303
- 6. Update the prd.json file to reflect any changes in task states.
132362
+ 2. Before starting the chosen task, mark it as "in progress" by updating its
132363
+ \`stateId\` in the prd.json file.
132364
+ This prevents other people or agents from working on the same task simultaneously.
132365
+ 3. Check if there is an existing Github PR for the task, otherwise create a new
132366
+ branch for the task.
132367
+ - If there is an existing PR, checkout the branch for that PR.
132368
+ - If there is an existing PR, check if there are any new comments or requested
132369
+ changes, and address them as part of the task.
132370
+ 4. Run any checks / feedback loops, such as type checks, unit tests, or linting.
132371
+ 5. APPEND your progress to the PROGRESS.md file.
132372
+ 6. Create or update the pull request with your changes once the task is complete. The title of
132373
+ the PR should include the task id. The PR description should include a
132374
+ summary of the changes made.
132375
+ 7. Update the prd.json file to reflect any changes in task states.
132304
132376
  - Add follow up tasks only if needed.
132305
132377
  - Append to the \`description\` field with any notes.
132306
132378
  - When a task is complete, set its \`stateId\` to the id that indicates
@@ -132336,6 +132408,7 @@ ${JSON.stringify(PrdIssue.jsonSchema, null, 2)}
132336
132408
  //#endregion
132337
132409
  //#region src/Runner.ts
132338
132410
  const run = gen(function* () {
132411
+ const worktree = yield* Worktree;
132339
132412
  const promptGen = yield* PromptGen;
132340
132413
  const cliCommand = (yield* getOrSelectCliAgent).command({
132341
132414
  prompt: promptGen.prompt,
@@ -132343,13 +132416,18 @@ const run = gen(function* () {
132343
132416
  progressFilePath: "PROGRESS.md"
132344
132417
  });
132345
132418
  const exitCode = yield* (yield* make$21(cliCommand[0], cliCommand.slice(1), {
132419
+ cwd: worktree.directory,
132346
132420
  extendEnv: true,
132347
132421
  stdout: "inherit",
132348
132422
  stderr: "inherit",
132349
132423
  stdin: "inherit"
132350
132424
  })).exitCode;
132351
132425
  yield* log$1(`Agent exited with code: ${exitCode}`);
132352
- }).pipe(scoped$1, provide([PromptGen.layer, Prd.layer]));
132426
+ }).pipe(scoped$1, provide([
132427
+ PromptGen.layer,
132428
+ Prd.layer,
132429
+ Worktree.layer
132430
+ ]));
132353
132431
  const selectCliAgent = gen(function* () {
132354
132432
  const agent = yield* select({
132355
132433
  message: "Select the CLI agent to use",
@@ -132379,18 +132457,51 @@ const selectLabel = make$26("select-label").pipe(withDescription("Select the lab
132379
132457
  onNone: () => "No Label",
132380
132458
  onSome: (l) => l.name
132381
132459
  })}`);
132382
- }, provide(Linear.layer))));
132460
+ })));
132383
132461
  const selectAgent = make$26("select-agent").pipe(withDescription("Select the CLI agent to use"), withHandler(() => selectCliAgent));
132384
- const iterations = integer("iterations").pipe(withAlias("i"), withDefault(1));
132385
- const root = make$26("lalph", { iterations }).pipe(withHandler(fnUntraced(function* ({ iterations: iterations$1 }) {
132386
- yield* log$1(`Executing ${iterations$1} iteration(s)`);
132387
- for (let i = 0; i < iterations$1; i++) yield* run;
132462
+ const iterations = integer("iterations").pipe(withDescription$1("Number of iterations to run, defaults to unlimited"), withAlias("i"), withDefault(Number.POSITIVE_INFINITY));
132463
+ const concurrency = integer("concurrency").pipe(withDescription$1("Number of concurrent agents, defaults to 1"), withAlias("c"), withDefault(1));
132464
+ const root = make$26("lalph", {
132465
+ iterations,
132466
+ concurrency
132467
+ }).pipe(withHandler(fnUntraced(function* ({ iterations: iterations$1, concurrency: concurrency$1 }) {
132468
+ const isFinite$3 = Number.isFinite(iterations$1);
132469
+ const iterationsDisplay = isFinite$3 ? iterations$1 : "unlimited";
132470
+ const runConcurrency = Math.max(1, concurrency$1);
132471
+ const semaphore = makeSemaphoreUnsafe(runConcurrency);
132472
+ yield* log$1(`Executing ${iterationsDisplay} iteration(s) with concurrency ${runConcurrency}`);
132473
+ let iteration = 0;
132474
+ let lastStartedAt = makeUnsafe$3(0);
132475
+ let inProgress = 0;
132476
+ while (true) {
132477
+ yield* semaphore.take(1);
132478
+ if (isFinite$3 && iteration >= iterations$1) break;
132479
+ const currentIteration = iteration;
132480
+ if (inProgress > 0) {
132481
+ const nextEarliestStart = lastStartedAt.pipe(add$1({ seconds: 30 }));
132482
+ const diff = distance(yield* now, nextEarliestStart);
132483
+ if (diff > 0) yield* sleep(diff);
132484
+ }
132485
+ lastStartedAt = yield* now;
132486
+ inProgress++;
132487
+ yield* run.pipe(catchTag("NoMoreWork", (e) => {
132488
+ if (isFinite$3) {
132489
+ iterations$1 = currentIteration;
132490
+ return fail$3(e);
132491
+ }
132492
+ return log$1("No more work to process, waiting 30 seconds...").pipe(andThen(sleep("30 seconds")));
132493
+ }), catchCause$1(logWarning), annotateLogs({ iteration: currentIteration }), ensuring(suspend$2(() => {
132494
+ inProgress--;
132495
+ return semaphore.release(1);
132496
+ })), forkChild);
132497
+ iteration++;
132498
+ }
132388
132499
  })), withSubcommands([
132389
132500
  selectProject,
132390
132501
  selectLabel,
132391
132502
  selectAgent
132392
132503
  ]));
132393
- run$1(root, { version: "0.1.0" }).pipe(provide(Settings.layer.pipe(provideMerge(layer$1))), runMain);
132504
+ run$1(root, { version: "0.1.0" }).pipe(provide(mergeAll(Settings.layer, Linear.layer).pipe(provideMerge(layer$1))), runMain);
132394
132505
 
132395
132506
  //#endregion
132396
132507
  export { };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "lalph",
3
3
  "type": "module",
4
- "version": "0.1.0",
4
+ "version": "0.1.2",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
package/src/Prd.ts CHANGED
@@ -4,17 +4,21 @@ import {
4
4
  FileSystem,
5
5
  Layer,
6
6
  Option,
7
+ Path,
7
8
  Schema,
8
9
  ServiceMap,
9
10
  Stream,
10
11
  SubscriptionRef,
11
12
  } from "effect"
12
13
  import { CurrentProject, Linear } from "./Linear.ts"
13
- import { selectedLabelId, selectedTeamId, Settings } from "./Settings.ts"
14
+ import { selectedLabelId, selectedTeamId } from "./Settings.ts"
14
15
  import type { Issue } from "@linear/sdk"
16
+ import { Worktree } from "./Worktree.ts"
15
17
 
16
18
  export class Prd extends ServiceMap.Service<Prd>()("lalph/Prd", {
17
19
  make: Effect.gen(function* () {
20
+ const worktree = yield* Worktree
21
+ const pathService = yield* Path.Path
18
22
  const fs = yield* FileSystem.FileSystem
19
23
  const linear = yield* Linear
20
24
  const project = yield* CurrentProject
@@ -46,7 +50,7 @@ export class Prd extends ServiceMap.Service<Prd>()("lalph/Prd", {
46
50
 
47
51
  const current = yield* SubscriptionRef.make(initial)
48
52
 
49
- const prdFile = `.lalph/prd.json`
53
+ const prdFile = pathService.join(worktree.directory, `.lalph/prd.json`)
50
54
 
51
55
  yield* fs.writeFileString(prdFile, initial.toJson())
52
56
 
@@ -74,10 +78,11 @@ export class Prd extends ServiceMap.Service<Prd>()("lalph/Prd", {
74
78
  }
75
79
  const existing = currentValue.issues.get(issue.id)
76
80
  if (!existing || !existing.isChangedComparedTo(issue)) continue
81
+ const original = currentValue.orignals.get(issue.id)!
77
82
 
78
83
  // update existing issue
79
84
  yield* linear.use((c) =>
80
- c.updateIssue(issue.id!, {
85
+ c.updateIssue(original.id, {
81
86
  description: issue.description,
82
87
  stateId: issue.stateId,
83
88
  }),
@@ -96,11 +101,11 @@ export class Prd extends ServiceMap.Service<Prd>()("lalph/Prd", {
96
101
  Effect.forkScoped,
97
102
  )
98
103
 
99
- return { current } as const
104
+ return { current, path: prdFile } as const
100
105
  }),
101
106
  }) {
102
107
  static layer = Layer.effect(this, this.make).pipe(
103
- Layer.provide([Linear.layer, Settings.layer, CurrentProject.layer]),
108
+ Layer.provide([CurrentProject.layer, Worktree.layer]),
104
109
  )
105
110
  }
106
111
 
@@ -146,7 +151,7 @@ export class PrdIssue extends Schema.Class<PrdIssue>("PrdIssue")({
146
151
 
147
152
  static fromLinearIssue(issue: Issue): PrdIssue {
148
153
  return new PrdIssue({
149
- id: issue.id,
154
+ id: issue.identifier,
150
155
  title: issue.title,
151
156
  description: issue.description ?? "",
152
157
  priority: issue.priority,
@@ -164,15 +169,18 @@ export class PrdIssue extends Schema.Class<PrdIssue>("PrdIssue")({
164
169
 
165
170
  export class PrdList extends Data.Class<{
166
171
  readonly issues: ReadonlyMap<string, PrdIssue>
172
+ readonly orignals: ReadonlyMap<string, Issue>
167
173
  }> {
168
174
  static fromLinearIssues(issues: Issue[]): PrdList {
169
175
  const map = new Map<string, PrdIssue>()
176
+ const originalMap = new Map<string, Issue>()
170
177
  for (const issue of issues) {
171
178
  const prdIssue = PrdIssue.fromLinearIssue(issue)
172
179
  if (!prdIssue.id) continue
173
180
  map.set(prdIssue.id, prdIssue)
181
+ originalMap.set(prdIssue.id, issue)
174
182
  }
175
- return new PrdList({ issues: map })
183
+ return new PrdList({ issues: map, orignals: originalMap })
176
184
  }
177
185
 
178
186
  static fromJson(json: string): ReadonlyArray<PrdIssue> {
package/src/PromptGen.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { Effect, FileSystem, Layer, ServiceMap } from "effect"
1
+ import { Effect, Layer, ServiceMap } from "effect"
2
2
  import { Linear } from "./Linear.ts"
3
3
  import { PrdIssue } from "./Prd.ts"
4
4
 
@@ -7,26 +7,26 @@ export class PromptGen extends ServiceMap.Service<PromptGen>()(
7
7
  {
8
8
  make: Effect.gen(function* () {
9
9
  const linear = yield* Linear
10
- const fs = yield* FileSystem.FileSystem
11
-
12
- yield* Effect.scoped(
13
- fs.open("PROGRESS.md", {
14
- flag: "a+",
15
- }),
16
- )
17
10
 
18
11
  const prompt = `# Instructions
19
12
 
20
13
  1. Decide which single task to work on next from the prd.json file. This should
21
14
  be the task YOU decide as the most important to work on next, not just the
22
15
  first task in the list.
23
- - **Important**: Before starting the chosen task, mark it as "in progress" by
24
- updating its \`stateId\` in the prd.json file.
25
- This prevents other people or agents from working on the same task simultaneously.
26
- 3. Run any checks / feedback loops, such as type checks, unit tests, or linting.
27
- 4. APPEND your progress to the PROGRESS.md file.
28
- 5. Make a git commit when you have made significant progress or completed the task.
29
- 6. Update the prd.json file to reflect any changes in task states.
16
+ 2. Before starting the chosen task, mark it as "in progress" by updating its
17
+ \`stateId\` in the prd.json file.
18
+ This prevents other people or agents from working on the same task simultaneously.
19
+ 3. Check if there is an existing Github PR for the task, otherwise create a new
20
+ branch for the task.
21
+ - If there is an existing PR, checkout the branch for that PR.
22
+ - If there is an existing PR, check if there are any new comments or requested
23
+ changes, and address them as part of the task.
24
+ 4. Run any checks / feedback loops, such as type checks, unit tests, or linting.
25
+ 5. APPEND your progress to the PROGRESS.md file.
26
+ 6. Create or update the pull request with your changes once the task is complete. The title of
27
+ the PR should include the task id. The PR description should include a
28
+ summary of the changes made.
29
+ 7. Update the prd.json file to reflect any changes in task states.
30
30
  - Add follow up tasks only if needed.
31
31
  - Append to the \`description\` field with any notes.
32
32
  - When a task is complete, set its \`stateId\` to the id that indicates
package/src/Runner.ts CHANGED
@@ -5,16 +5,20 @@ import { ChildProcess } from "effect/unstable/process"
5
5
  import { Prompt } from "effect/unstable/cli"
6
6
  import { allCliAgents } from "./domain/CliAgent.ts"
7
7
  import { selectedCliAgentId } from "./Settings.ts"
8
+ import { Worktree } from "./Worktree.ts"
8
9
 
9
10
  export const run = Effect.gen(function* () {
11
+ const worktree = yield* Worktree
10
12
  const promptGen = yield* PromptGen
11
13
  const cliAgent = yield* getOrSelectCliAgent
14
+
12
15
  const cliCommand = cliAgent.command({
13
16
  prompt: promptGen.prompt,
14
17
  prdFilePath: ".lalph/prd.json",
15
18
  progressFilePath: "PROGRESS.md",
16
19
  })
17
20
  const command = ChildProcess.make(cliCommand[0]!, cliCommand.slice(1), {
21
+ cwd: worktree.directory,
18
22
  extendEnv: true,
19
23
  stdout: "inherit",
20
24
  stderr: "inherit",
@@ -25,7 +29,10 @@ export const run = Effect.gen(function* () {
25
29
  const exitCode = yield* agent.exitCode
26
30
 
27
31
  yield* Effect.log(`Agent exited with code: ${exitCode}`)
28
- }).pipe(Effect.scoped, Effect.provide([PromptGen.layer, Prd.layer]))
32
+ }).pipe(
33
+ Effect.scoped,
34
+ Effect.provide([PromptGen.layer, Prd.layer, Worktree.layer]),
35
+ )
29
36
 
30
37
  export const selectCliAgent = Effect.gen(function* () {
31
38
  const agent = yield* Prompt.select({
@@ -0,0 +1,59 @@
1
+ import { Effect, FileSystem, Layer, Path, ServiceMap } from "effect"
2
+ import { ChildProcess } from "effect/unstable/process"
3
+
4
+ export class Worktree extends ServiceMap.Service<Worktree>()("lalph/Worktree", {
5
+ make: Effect.gen(function* () {
6
+ const fs = yield* FileSystem.FileSystem
7
+ const pathService = yield* Path.Path
8
+ const directory = yield* fs.makeTempDirectory()
9
+
10
+ yield* Effect.addFinalizer(
11
+ Effect.fnUntraced(function* () {
12
+ yield* execIgnore(ChildProcess.make`git worktree remove ${directory}`)
13
+ }),
14
+ )
15
+
16
+ yield* exec(ChildProcess.make`git worktree add ${directory} -d HEAD`)
17
+
18
+ yield* fs.makeDirectory(pathService.join(directory, ".lalph"), {
19
+ recursive: true,
20
+ })
21
+
22
+ yield* Effect.forEach(
23
+ [
24
+ ChildProcess.make({
25
+ cwd: directory,
26
+ extendEnv: true,
27
+ shell: process.env.SHELL ?? true,
28
+ })`direnv allow`,
29
+ ChildProcess.make({
30
+ cwd: directory,
31
+ extendEnv: true,
32
+ shell: process.env.SHELL ?? true,
33
+ })`devenv allow`,
34
+ ChildProcess.make({
35
+ cwd: directory,
36
+ })`git submodule update --init --recursive`,
37
+ ],
38
+ execIgnore,
39
+ { concurrency: "unbounded" },
40
+ )
41
+
42
+ return { directory } as const
43
+ }),
44
+ }) {
45
+ static layer = Layer.effect(this, this.make)
46
+ }
47
+
48
+ const exec = (command: ChildProcess.Command) =>
49
+ command.asEffect().pipe(
50
+ Effect.flatMap((proc) => proc.exitCode),
51
+ Effect.scoped,
52
+ )
53
+
54
+ const execIgnore = (command: ChildProcess.Command) =>
55
+ command.asEffect().pipe(
56
+ Effect.flatMap((proc) => proc.exitCode),
57
+ Effect.catchCause(Effect.logWarning),
58
+ Effect.scoped,
59
+ )
package/src/cli.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { Command, Flag } from "effect/unstable/cli"
4
- import { Effect, Layer, Option } from "effect"
4
+ import { DateTime, Effect, Layer, Option } from "effect"
5
5
  import { NodeRuntime, NodeServices } from "@effect/platform-node"
6
6
  import { CurrentProject, labelSelect, Linear } from "./Linear.ts"
7
7
  import { layerKvs } from "./Kvs.ts"
@@ -34,7 +34,7 @@ const selectLabel = Command.make("select-label").pipe(
34
34
  onSome: (l) => l.name,
35
35
  })}`,
36
36
  )
37
- }, Effect.provide(Linear.layer)),
37
+ }),
38
38
  ),
39
39
  )
40
40
 
@@ -44,17 +44,80 @@ const selectAgent = Command.make("select-agent").pipe(
44
44
  )
45
45
 
46
46
  const iterations = Flag.integer("iterations").pipe(
47
+ Flag.withDescription("Number of iterations to run, defaults to unlimited"),
47
48
  Flag.withAlias("i"),
49
+ Flag.withDefault(Number.POSITIVE_INFINITY),
50
+ )
51
+
52
+ const concurrency = Flag.integer("concurrency").pipe(
53
+ Flag.withDescription("Number of concurrent agents, defaults to 1"),
54
+ Flag.withAlias("c"),
48
55
  Flag.withDefault(1),
49
56
  )
50
57
 
51
- const root = Command.make("lalph", { iterations }).pipe(
58
+ const root = Command.make("lalph", { iterations, concurrency }).pipe(
52
59
  Command.withHandler(
53
- Effect.fnUntraced(function* ({ iterations }) {
54
- yield* Effect.log(`Executing ${iterations} iteration(s)`)
60
+ Effect.fnUntraced(function* ({ iterations, concurrency }) {
61
+ const isFinite = Number.isFinite(iterations)
62
+ const iterationsDisplay = isFinite ? iterations : "unlimited"
63
+ const runConcurrency = Math.max(1, concurrency)
64
+ const semaphore = Effect.makeSemaphoreUnsafe(runConcurrency)
65
+
66
+ yield* Effect.log(
67
+ `Executing ${iterationsDisplay} iteration(s) with concurrency ${runConcurrency}`,
68
+ )
69
+
70
+ let iteration = 0
71
+ let lastStartedAt = DateTime.makeUnsafe(0)
72
+ let inProgress = 0
55
73
 
56
- for (let i = 0; i < iterations; i++) {
57
- yield* run
74
+ while (true) {
75
+ yield* semaphore.take(1)
76
+ if (isFinite && iteration >= iterations) {
77
+ break
78
+ }
79
+
80
+ const currentIteration = iteration
81
+
82
+ if (inProgress > 0) {
83
+ // add delay to try keep task list in sync
84
+ const nextEarliestStart = lastStartedAt.pipe(
85
+ DateTime.add({ seconds: 30 }),
86
+ )
87
+ const diff = DateTime.distance(yield* DateTime.now, nextEarliestStart)
88
+ if (diff > 0) {
89
+ yield* Effect.sleep(diff)
90
+ }
91
+ }
92
+
93
+ lastStartedAt = yield* DateTime.now
94
+ inProgress++
95
+
96
+ yield* run.pipe(
97
+ Effect.catchTag("NoMoreWork", (e) => {
98
+ if (isFinite) {
99
+ // If we have a finite number of iterations, we exit when no more
100
+ // work is found
101
+ iterations = currentIteration
102
+ return Effect.fail(e)
103
+ }
104
+ return Effect.log(
105
+ "No more work to process, waiting 30 seconds...",
106
+ ).pipe(Effect.andThen(Effect.sleep("30 seconds")))
107
+ }),
108
+ Effect.catchCause(Effect.logWarning),
109
+ Effect.annotateLogs({
110
+ iteration: currentIteration,
111
+ }),
112
+ Effect.ensuring(
113
+ Effect.suspend(() => {
114
+ inProgress--
115
+ return semaphore.release(1)
116
+ }),
117
+ ),
118
+ Effect.forkChild,
119
+ )
120
+ iteration++
58
121
  }
59
122
  }),
60
123
  ),
@@ -64,6 +127,10 @@ const root = Command.make("lalph", { iterations }).pipe(
64
127
  Command.run(root, {
65
128
  version: "0.1.0",
66
129
  }).pipe(
67
- Effect.provide(Settings.layer.pipe(Layer.provideMerge(NodeServices.layer))),
130
+ Effect.provide(
131
+ Layer.mergeAll(Settings.layer, Linear.layer).pipe(
132
+ Layer.provideMerge(NodeServices.layer),
133
+ ),
134
+ ),
68
135
  NodeRuntime.runMain,
69
136
  )