lalph 0.1.16 → 0.1.18

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/dist/cli.mjs CHANGED
@@ -59594,7 +59594,7 @@ const selectedCliAgentId = new Setting("selectedCliAgentId", Literals(allCliAgen
59594
59594
 
59595
59595
  //#endregion
59596
59596
  //#region src/domain/PrdIssue.ts
59597
- var PrdIssue = class extends Class("PrdIssue")({
59597
+ var PrdIssue = class PrdIssue extends Class("PrdIssue")({
59598
59598
  id: NullOr(String$1).annotate({ description: "The unique identifier of the issue. If null, it is considered a new issue." }),
59599
59599
  title: String$1.annotate({ description: "The title of the issue" }),
59600
59600
  description: String$1.annotate({ description: "The description of the issue in markdown format." }),
@@ -59607,8 +59607,13 @@ var PrdIssue = class extends Class("PrdIssue")({
59607
59607
  }) {
59608
59608
  static Array = Array$1(this);
59609
59609
  static ArrayFromJson = toCodecJson(this.Array);
59610
- static arrayToJson(issues) {
59611
- return JSON.stringify(encodeSync(this.ArrayFromJson)(issues), null, 2);
59610
+ static arrayToYaml(issues) {
59611
+ const json = encodeSync(this.ArrayFromJson)(issues);
59612
+ return import_dist.stringify(json, { blockQuote: "literal" });
59613
+ }
59614
+ static arrayFromYaml(yaml) {
59615
+ const json = import_dist.parse(yaml);
59616
+ return decodeSync(PrdIssue.ArrayFromJson)(json);
59612
59617
  }
59613
59618
  static jsonSchemaDoc = toJsonSchemaDocument(this);
59614
59619
  static jsonSchema = {
@@ -59619,18 +59624,6 @@ var PrdIssue = class extends Class("PrdIssue")({
59619
59624
  return this.title !== issue.title || this.description !== issue.description || this.stateId !== issue.stateId || !makeEquivalence$1(asEquivalence())(this.blockedBy, issue.blockedBy);
59620
59625
  }
59621
59626
  };
59622
- var PrdList = class extends Class$1 {
59623
- static fromJson(json) {
59624
- return decodeSync(PrdIssue.ArrayFromJson)(JSON.parse(json));
59625
- }
59626
- toJson() {
59627
- const issuesArray = fromIterable$2(this.issues.values());
59628
- return PrdIssue.arrayToJson(issuesArray);
59629
- }
59630
- cast() {
59631
- return this;
59632
- }
59633
- };
59634
59627
 
59635
59628
  //#endregion
59636
59629
  //#region src/IssueSource.ts
@@ -133887,7 +133880,13 @@ const LinearIssueSource = effect(IssueSource, gen(function* () {
133887
133880
  "started",
133888
133881
  "completed"
133889
133882
  ] } }
133890
- } })).pipe(mapEffect$1(fnUntraced(function* (issue) {
133883
+ } })).pipe(filter((issue) => {
133884
+ const completedAt = issue.completedAt;
133885
+ if (!completedAt) return true;
133886
+ const completed = makeUnsafe$3(completedAt);
133887
+ const threeDaysAgo = nowUnsafe().pipe(subtract({ days: 3 }));
133888
+ return isGreaterThanOrEqualTo$1(completed, threeDaysAgo);
133889
+ }), mapEffect$1(fnUntraced(function* (issue) {
133891
133890
  identifierMap.set(issue.identifier, issue.id);
133892
133891
  const state = linear.states.get(issue.stateId);
133893
133892
  const blockedBy = yield* runCollect(linear.blockedBy(issue));
@@ -133898,7 +133897,7 @@ const LinearIssueSource = effect(IssueSource, gen(function* () {
133898
133897
  priority: issue.priority,
133899
133898
  estimate: issue.estimate ?? null,
133900
133899
  stateId: issue.stateId,
133901
- complete: state.type === "completed",
133900
+ complete: state.type === "completed" || state.name.toLowerCase().includes("review"),
133902
133901
  blockedBy: blockedBy.map((i) => i.identifier),
133903
133902
  githubPrNumber: null
133904
133903
  });
@@ -140192,14 +140191,15 @@ const GithubIssueSource = effect(IssueSource, gen(function* () {
140192
140191
  labels: getOrUndefined(labelFilter$1)
140193
140192
  })).pipe(merge$2(recentlyClosed), filter((issue) => issue.pull_request === void 0), mapEffect$1(fnUntraced(function* (issue) {
140194
140193
  const dependencies = yield* listOpenBlockedBy(issue.number).pipe(runCollect);
140194
+ const stateId = issue.state === "closed" ? "closed" : hasLabel(issue.labels, "in-progress") ? "in-progress" : hasLabel(issue.labels, "in-review") ? "in-review" : "open";
140195
140195
  return new PrdIssue({
140196
140196
  id: `#${issue.number}`,
140197
140197
  title: issue.title,
140198
140198
  description: issue.body ?? "",
140199
140199
  priority: 0,
140200
140200
  estimate: null,
140201
- stateId: issue.state === "closed" ? "closed" : hasLabel(issue.labels, "in-progress") ? "in-progress" : hasLabel(issue.labels, "in-review") ? "in-review" : "open",
140202
- complete: issue.state === "closed",
140201
+ stateId,
140202
+ complete: stateId === "closed" || stateId === "in-review",
140203
140203
  blockedBy: dependencies.map((dep) => `#${dep.number}`),
140204
140204
  githubPrNumber: null
140205
140205
  });
@@ -140371,9 +140371,9 @@ var CurrentIssueSource = class CurrentIssueSource extends Service()("lalph/Curre
140371
140371
  var PromptGen = class extends Service()("lalph/PromptGen", { make: gen(function* () {
140372
140372
  const sourceMeta = yield* CurrentIssueSource;
140373
140373
  const states = yield* (yield* IssueSource).states;
140374
- const prdNotes = `## prd.json format
140374
+ const prdNotes = `## prd.yml format
140375
140375
 
140376
- Each item in the prd.json file represents a task for the current project.
140376
+ Each item in the prd.yml file represents a task for the current project.
140377
140377
 
140378
140378
  The \`stateId\` field indicates the current state of the task. The possible states
140379
140379
  are:
@@ -140382,35 +140382,68 @@ ${Array.from(states.values(), (state) => `- **${state.name}** (stateId: \`${stat
140382
140382
 
140383
140383
  ### Adding tasks
140384
140384
 
140385
- To add a new task, append a new item to the prd.json file with the id set to
140385
+ To add a new task, append a new item to the prd.yml file with the id set to
140386
140386
  \`null\`.
140387
140387
 
140388
140388
  When adding a new task, it will take about 5 seconds for the system to update the
140389
- prd.json file with a new id for the task.
140389
+ prd.yml file with a new id for the task.
140390
140390
 
140391
140391
  ### Removing tasks
140392
140392
 
140393
- To remove a task, simply delete the item from the prd.json file.
140393
+ To remove a task, simply delete the item from the prd.yml file.
140394
140394
 
140395
- ### prd.json json schema
140395
+ ### prd.yml json schema
140396
140396
 
140397
140397
  \`\`\`json
140398
140398
  ${JSON.stringify(PrdIssue.jsonSchema, null, 2)}
140399
140399
  \`\`\``;
140400
- const prompt = `# Instructions
140400
+ const promptChoose = `# Instructions
140401
140401
 
140402
- The following instructions should be done without interaction or asking for
140403
- permission.
140402
+ Your job is to choose the next task to work on from the prd.yml file. **DO NOT** implement the task yet.
140403
+
140404
+ The following instructions should be done without interaction or asking for permission.
140404
140405
 
140405
- 1. Decide which single task to work on next from the prd.json file. This should
140406
+ 1. Decide which single task to work on next from the prd.yml file. This should
140406
140407
  be the task YOU decide as the most important to work on next, not just the
140407
140408
  first task in the list.
140408
140409
  - Only start tasks that are in a "todo" state (i.e., not started yet).
140409
140410
  - If the \`blockedBy\` field is not empty, skip the task.
140410
140411
  2. **Before doing anything else**, mark the task as "in progress" by updating its
140411
- \`stateId\` in the prd.json file.
140412
+ \`stateId\` in the prd.yml file.
140412
140413
  This prevents other people or agents from working on the same task simultaneously.
140413
- 3. Check if there is an existing Github PR for the task, otherwise create a new
140414
+ 3. Research the task. If it seems like too many steps are needed to complete the task,
140415
+ break it down into smaller tasks and add them to the prd.yml file, marking the
140416
+ original task as "closed" by updating its \`stateId\`.
140417
+ 4. Once you have chosen a task of reasonable size, save its information in a
140418
+ "task.json" file alongside the prd.yml file. Use the following format:
140419
+
140420
+ \`\`\`json
140421
+ {
140422
+ "id": "task id",
140423
+ "todoStateId": "id of the todo state",
140424
+ "inProgressStateId": "id of the in progress state",
140425
+ "reviewStateId": "id of the review state"
140426
+ }
140427
+ \`\`\`
140428
+
140429
+ ## Important: Task sizing
140430
+
140431
+ If at any point you decide that a task is too large or complex to complete in a
140432
+ single iteration, break it down into smaller tasks and add them to the prd.yml
140433
+ file. Then, mark the original task as "closed" by updating its \`stateId\`.
140434
+
140435
+ Each task should be small and specific.
140436
+ Instead of creating tasks like "Refactor the authentication system", create
140437
+ smaller tasks like "Implement OAuth2 login endpoint", "Add JWT token refresh mechanism", etc.
140438
+
140439
+ ${prdNotes}`;
140440
+ const prompt = (taskId) => `# Instructions
140441
+
140442
+ The following instructions should be done without interaction or asking for
140443
+ permission.
140444
+
140445
+ 1. Your job is to complete the task with id \`${taskId}\` from the prd.yml file.
140446
+ 2. Check if there is an existing Github PR for the task, otherwise create a new
140414
140447
  branch for the task.
140415
140448
  - If there is an existing PR, checkout the branch for that PR.
140416
140449
  - If there is an existing PR, check if there are any new comments or requested
@@ -140419,10 +140452,7 @@ permission.
140419
140452
  HEAD as the base.
140420
140453
  - New branches should be named using the format \`{task id}/description\`.
140421
140454
  - When checking for PR reviews, make sure to check the "reviews" field and read ALL unresolved comments.
140422
- 4. Research the task. If it seems like too many steps are needed to complete the task,
140423
- break it down into smaller tasks and add them to the prd.json file, marking the
140424
- original task as "closed" by updating its \`stateId\`.
140425
- Otherwise, implement the task.
140455
+ 4. Implement the task.
140426
140456
  5. Run any checks / feedback loops, such as type checks, unit tests, or linting.
140427
140457
  6. Create or update the pull request with your progress.
140428
140458
  ${sourceMeta.githubPrInstructions}
@@ -140430,25 +140460,12 @@ permission.
140430
140460
  - None of the files in the \`.lalph\` directory should be committed.
140431
140461
  - You have permission to create or update the PR as needed. You have full
140432
140462
  permission to push branches, create PRs or create git commits.
140433
- 7. Update the prd.json file to reflect any changes in task states.
140463
+ 7. Update the prd.yml file to reflect any changes in task states.
140434
140464
  - Add follow up tasks only if needed.
140435
140465
  - Append to the \`description\` field with any notes or important discoveries.
140436
140466
  - If you believe the task is complete, update the \`stateId\` for "review".
140437
140467
  Only if no "review" state exists, use a completed state.
140438
140468
 
140439
- Remember, only work on a single task at a time, that you decide is the most
140440
- important to work on next.
140441
-
140442
- ## Important: Task sizing
140443
-
140444
- If at any point you decide that a task is too large or complex to complete in a
140445
- single iteration, break it down into smaller tasks and add them to the prd.json
140446
- file. Then, mark the original task as "closed" by updating its \`stateId\`.
140447
-
140448
- Each task should be small and specific.
140449
- Instead of creating tasks like "Refactor the authentication system", create
140450
- smaller tasks like "Implement OAuth2 login endpoint", "Add JWT token refresh mechanism", etc.
140451
-
140452
140469
  ## Handling blockers
140453
140470
 
140454
140471
  If for any reason you get stuck on a task, mark the task back as "todo" by updating its
@@ -140464,27 +140481,28 @@ ${prdNotes}`;
140464
140481
  Users idea / request: ${idea}
140465
140482
 
140466
140483
  1. For the users idea / request above, break it down into multiple smaller tasks
140467
- that can be added to the prd.json file.
140484
+ that can be added to the prd.yml file.
140468
140485
  - Make sure to research the codebase before creating any tasks, to ensure they
140469
140486
  are relevant and feasible.
140470
- - Check if similar tasks already exist in the prd.json file to avoid duplication.
140487
+ - Check if similar tasks already exist in the prd.yml file to avoid duplication.
140471
140488
  2. Each task should have a id of \`null\`, a title, and a concise description that
140472
140489
  includes a short summary of the task and a brief list of steps to complete it.
140473
140490
  - The tasks should start in a "Todo" state (i.e., not started yet).
140474
140491
  - Each task should be small and specific.
140475
140492
  Instead of creating tasks like "Refactor the authentication system", create
140476
140493
  smaller tasks like "Implement OAuth2 login endpoint", "Add JWT token refresh mechanism", etc.
140477
- 3. Add the new tasks to the prd.json file.
140494
+ 3. Add the new tasks to the prd.yml file.
140478
140495
  4. Add a brief outline of the plan to a "lalph-plan.md" file, that will help guide future
140479
140496
  iterations.
140480
140497
 
140481
140498
  ${prdNotes}`;
140482
140499
  return {
140500
+ promptChoose,
140483
140501
  prompt,
140484
140502
  planPrompt,
140485
140503
  planContinuePrompt: `# Instructions
140486
140504
 
140487
- 1. Review the existing prd.json file and lalph-plan.md file to understand the current
140505
+ 1. Review the existing prd.yml file and lalph-plan.md file to understand the current
140488
140506
  plan and tasks.
140489
140507
  2. Ask the user for feedback to iterate on the existing plan.
140490
140508
 
@@ -140536,17 +140554,17 @@ var Prd = class extends Service()("lalph/Prd", { make: gen(function* () {
140536
140554
  const fs = yield* FileSystem;
140537
140555
  const source = yield* IssueSource;
140538
140556
  const lalphDir = pathService.join(worktree.directory, `.lalph`);
140539
- const prdFile = pathService.join(worktree.directory, `.lalph`, `prd.json`);
140557
+ const prdFile = pathService.join(worktree.directory, `.lalph`, `prd.yml`);
140540
140558
  let current = yield* source.issues;
140541
- yield* fs.writeFileString(prdFile, PrdIssue.arrayToJson(current));
140559
+ yield* fs.writeFileString(prdFile, PrdIssue.arrayToYaml(current));
140542
140560
  const updatedIssues = /* @__PURE__ */ new Map();
140543
140561
  yield* fs.watch(lalphDir).pipe(buffer({
140544
140562
  capacity: 1,
140545
140563
  strategy: "dropping"
140546
140564
  }), runForEach((_) => ignore(sync$3)), retry$1(forever$1), forkScoped);
140547
140565
  const sync$3 = gen(function* () {
140548
- const json = yield* fs.readFileString(prdFile);
140549
- const updated = PrdList.fromJson(json);
140566
+ const yaml = yield* fs.readFileString(prdFile);
140567
+ const updated = PrdIssue.arrayFromYaml(yaml);
140550
140568
  if (!(updated.length !== current.length || updated.some((u, i) => u.isChangedComparedTo(current[i])))) return;
140551
140569
  const githubPrs = /* @__PURE__ */ new Map();
140552
140570
  const toRemove = new Set(current.filter((i) => i.id !== null).map((i) => i.id));
@@ -140568,20 +140586,14 @@ var Prd = class extends Service()("lalph/Prd", { make: gen(function* () {
140568
140586
  stateId: issue.stateId,
140569
140587
  blockedBy: issue.blockedBy
140570
140588
  });
140571
- let entry = updatedIssues.get(issue.id);
140572
- if (!entry) {
140573
- entry = {
140574
- issue,
140575
- originalStateId: existing.stateId,
140576
- count: 0
140577
- };
140578
- updatedIssues.set(issue.id, entry);
140579
- }
140580
- entry.count++;
140589
+ if (!updatedIssues.has(issue.id)) updatedIssues.set(issue.id, {
140590
+ issue,
140591
+ originalStateId: existing.stateId
140592
+ });
140581
140593
  }
140582
140594
  yield* forEach$1(toRemove, (issueId) => source.cancelIssue(issueId), { concurrency: "unbounded" });
140583
140595
  current = yield* source.issues;
140584
- yield* fs.writeFileString(prdFile, PrdIssue.arrayToJson(current.map((issue) => {
140596
+ yield* fs.writeFileString(prdFile, PrdIssue.arrayToYaml(current.map((issue) => {
140585
140597
  const prNumber = githubPrs.get(issue.id);
140586
140598
  if (!prNumber) return issue;
140587
140599
  return new PrdIssue({
@@ -140590,28 +140602,35 @@ var Prd = class extends Service()("lalph/Prd", { make: gen(function* () {
140590
140602
  });
140591
140603
  })));
140592
140604
  }).pipe(uninterruptible);
140593
- const mergableGithubPrs = gen(function* () {
140594
- const json = yield* fs.readFileString(prdFile);
140595
- const updated = PrdList.fromJson(json);
140596
- const prs = empty$11();
140597
- for (const issue of updated) {
140598
- const entry = updatedIssues.get(issue.id ?? "");
140599
- if (!entry || !issue.githubPrNumber || issue.stateId === entry.originalStateId) continue;
140600
- prs.push(issue.githubPrNumber);
140601
- }
140602
- return prs;
140603
- });
140604
- const revertStateIds = (options) => suspend$2(() => forEach$1(updatedIssues.values(), ({ issue, originalStateId, count }) => options.reason === "error" || count === 1 ? source.updateIssue({
140605
- issueId: issue.id,
140606
- stateId: originalStateId
140607
- }) : void_$1, {
140608
- concurrency: "unbounded",
140609
- discard: true
140610
- }));
140611
140605
  return {
140612
140606
  path: prdFile,
140613
- mergableGithubPrs,
140614
- revertStateIds
140607
+ mergableGithubPrs: gen(function* () {
140608
+ const yaml = yield* fs.readFileString(prdFile);
140609
+ const updated = PrdIssue.arrayFromYaml(yaml);
140610
+ const prs = empty$11();
140611
+ for (const issue of updated) {
140612
+ const entry = updatedIssues.get(issue.id ?? "");
140613
+ if (!entry || !issue.githubPrNumber || issue.stateId === entry.originalStateId) continue;
140614
+ prs.push(issue.githubPrNumber);
140615
+ }
140616
+ return prs;
140617
+ }),
140618
+ revertStateIds: suspend$2(() => forEach$1(updatedIssues.values(), ({ issue, originalStateId }) => source.updateIssue({
140619
+ issueId: issue.id,
140620
+ stateId: originalStateId
140621
+ }), {
140622
+ concurrency: "unbounded",
140623
+ discard: true
140624
+ })),
140625
+ maybeRevertIssue: fnUntraced(function* (options) {
140626
+ const yaml = yield* fs.readFileString(prdFile);
140627
+ const issue = PrdIssue.arrayFromYaml(yaml).find((i) => i.id === options.issueId);
140628
+ if (!issue || issue.stateId === options.reviewStateId) return;
140629
+ yield* source.updateIssue({
140630
+ issueId: issue.id,
140631
+ stateId: options.todoStateId
140632
+ });
140633
+ })
140615
140634
  };
140616
140635
  }) }) {
140617
140636
  static layer = effect(this, this.make).pipe(provide$3(Worktree.layer));
@@ -140639,14 +140658,28 @@ const getOrSelectCliAgent = gen(function* () {
140639
140658
  //#endregion
140640
140659
  //#region src/Runner.ts
140641
140660
  const run = fnUntraced(function* (options) {
140661
+ const fs = yield* FileSystem;
140642
140662
  const pathService = yield* Path$1;
140643
140663
  const worktree = yield* Worktree;
140644
140664
  const promptGen = yield* PromptGen;
140645
140665
  const cliAgent = yield* getOrSelectCliAgent;
140646
140666
  const prd = yield* Prd;
140667
+ const chooseCommand = cliAgent.command({
140668
+ prompt: promptGen.promptChoose,
140669
+ prdFilePath: pathService.join(".lalph", "prd.yml")
140670
+ });
140671
+ yield* make$22(chooseCommand[0], chooseCommand.slice(1), {
140672
+ cwd: worktree.directory,
140673
+ extendEnv: true,
140674
+ stdout: "inherit",
140675
+ stderr: "inherit",
140676
+ stdin: "inherit"
140677
+ }).pipe(exitCode);
140678
+ const taskJson = yield* fs.readFileString(pathService.join(worktree.directory, ".lalph", "task.json"));
140679
+ const task = yield* decodeEffect(ChosenTask)(taskJson);
140647
140680
  const cliCommand = cliAgent.command({
140648
- prompt: promptGen.prompt,
140649
- prdFilePath: pathService.join(".lalph", "prd.json")
140681
+ prompt: promptGen.prompt(task.id),
140682
+ prdFilePath: pathService.join(".lalph", "prd.yml")
140650
140683
  });
140651
140684
  const handle = yield* make$22(cliCommand[0], cliCommand.slice(1), {
140652
140685
  cwd: worktree.directory,
@@ -140671,11 +140704,14 @@ const run = fnUntraced(function* (options) {
140671
140704
  const exitCode$1 = yield* handle.exitCode;
140672
140705
  yield* log$1(`Agent exited with code: ${exitCode$1}`);
140673
140706
  const prs = yield* prd.mergableGithubPrs;
140674
- if (prs.length === 0) yield* prd.revertStateIds({ reason: "inactivity" });
140707
+ if (prs.length === 0) yield* prd.maybeRevertIssue({
140708
+ ...task,
140709
+ issueId: task.id
140710
+ });
140675
140711
  else if (options.autoMerge) for (const pr of prs) yield* make$22`gh pr merge ${pr} -sd`.pipe(exitCode);
140676
140712
  }, onError(fnUntraced(function* () {
140677
140713
  const prd = yield* Prd;
140678
- yield* ignore(prd.revertStateIds({ reason: "error" }));
140714
+ yield* ignore(prd.revertStateIds);
140679
140715
  })), scoped$1, provide$1([
140680
140716
  PromptGen.layer,
140681
140717
  Prd.layer,
@@ -140684,6 +140720,12 @@ const run = fnUntraced(function* (options) {
140684
140720
  var RunnerStalled = class extends TaggedError("RunnerStalled") {
140685
140721
  message = "The runner has stalled due to inactivity.";
140686
140722
  };
140723
+ const ChosenTask = fromJsonString(Struct({
140724
+ id: String$1,
140725
+ todoStateId: String$1,
140726
+ inProgressStateId: String$1,
140727
+ reviewStateId: String$1
140728
+ }));
140687
140729
 
140688
140730
  //#endregion
140689
140731
  //#region src/Planner.ts
@@ -140698,7 +140740,7 @@ const plan = gen(function* () {
140698
140740
  yield* scoped$1(fs.open(lalphPlanPath, { flag: "a+" }));
140699
140741
  const cliCommand = cliAgent.commandPlan({
140700
140742
  prompt: promptGen.planPrompt(idea),
140701
- prdFilePath: pathService.join(worktree.directory, ".lalph", "prd.json")
140743
+ prdFilePath: pathService.join(worktree.directory, ".lalph", "prd.yml")
140702
140744
  });
140703
140745
  const exitCode$1 = yield* make$22(cliCommand[0], cliCommand.slice(1), {
140704
140746
  cwd: worktree.directory,
@@ -140726,7 +140768,7 @@ const planContinue = gen(function* () {
140726
140768
  yield* scoped$1(fs.open(lalphPlanPath, { flag: "a+" }));
140727
140769
  const cliCommand = cliAgent.commandPlan({
140728
140770
  prompt: promptGen.planContinuePrompt,
140729
- prdFilePath: pathService.join(worktree.directory, ".lalph", "prd.json")
140771
+ prdFilePath: pathService.join(worktree.directory, ".lalph", "prd.yml")
140730
140772
  });
140731
140773
  const exitCode$1 = yield* make$22(cliCommand[0], cliCommand.slice(1), {
140732
140774
  cwd: worktree.directory,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "lalph",
3
3
  "type": "module",
4
- "version": "0.1.16",
4
+ "version": "0.1.18",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
@@ -31,7 +31,8 @@
31
31
  "octokit": "^5.0.5",
32
32
  "prettier": "^3.7.4",
33
33
  "tsdown": "^0.19.0",
34
- "typescript": "^5.9.3"
34
+ "typescript": "^5.9.3",
35
+ "yaml": "^2.8.2"
35
36
  },
36
37
  "scripts": {
37
38
  "check": "tsc --noEmit && prettier --check .",
package/src/Github.ts CHANGED
@@ -185,21 +185,22 @@ export const GithubIssueSource = Layer.effect(
185
185
  const dependencies = yield* listOpenBlockedBy(issue.number).pipe(
186
186
  Stream.runCollect,
187
187
  )
188
+ const stateId =
189
+ issue.state === "closed"
190
+ ? "closed"
191
+ : hasLabel(issue.labels, "in-progress")
192
+ ? "in-progress"
193
+ : hasLabel(issue.labels, "in-review")
194
+ ? "in-review"
195
+ : "open"
188
196
  return new PrdIssue({
189
197
  id: `#${issue.number}`,
190
198
  title: issue.title,
191
199
  description: issue.body ?? "",
192
200
  priority: 0,
193
201
  estimate: null,
194
- stateId:
195
- issue.state === "closed"
196
- ? "closed"
197
- : hasLabel(issue.labels, "in-progress")
198
- ? "in-progress"
199
- : hasLabel(issue.labels, "in-review")
200
- ? "in-review"
201
- : "open",
202
- complete: issue.state === "closed",
202
+ stateId,
203
+ complete: stateId === "closed" || stateId === "in-review",
203
204
  blockedBy: dependencies.map((dep) => `#${dep.number}`),
204
205
  githubPrNumber: null,
205
206
  })
package/src/Linear.ts CHANGED
@@ -6,6 +6,7 @@ import {
6
6
  ServiceMap,
7
7
  Option,
8
8
  RcMap,
9
+ DateTime,
9
10
  } from "effect"
10
11
  import {
11
12
  Connection,
@@ -168,6 +169,15 @@ export const LinearIssueSource = Layer.effect(
168
169
  }),
169
170
  )
170
171
  .pipe(
172
+ Stream.filter((issue) => {
173
+ const completedAt = issue.completedAt
174
+ if (!completedAt) return true
175
+ const completed = DateTime.makeUnsafe(completedAt)
176
+ const threeDaysAgo = DateTime.nowUnsafe().pipe(
177
+ DateTime.subtract({ days: 3 }),
178
+ )
179
+ return DateTime.isGreaterThanOrEqualTo(completed, threeDaysAgo)
180
+ }),
171
181
  Stream.mapEffect(
172
182
  Effect.fnUntraced(function* (issue) {
173
183
  identifierMap.set(issue.identifier, issue.id)
@@ -180,7 +190,9 @@ export const LinearIssueSource = Layer.effect(
180
190
  priority: issue.priority,
181
191
  estimate: issue.estimate ?? null,
182
192
  stateId: issue.stateId!,
183
- complete: state.type === "completed",
193
+ complete:
194
+ state.type === "completed" ||
195
+ state.name.toLowerCase().includes("review"),
184
196
  blockedBy: blockedBy.map((i) => i.identifier),
185
197
  githubPrNumber: null,
186
198
  })
package/src/Planner.ts CHANGED
@@ -22,7 +22,7 @@ export const plan = Effect.gen(function* () {
22
22
 
23
23
  const cliCommand = cliAgent.commandPlan({
24
24
  prompt: promptGen.planPrompt(idea),
25
- prdFilePath: pathService.join(worktree.directory, ".lalph", "prd.json"),
25
+ prdFilePath: pathService.join(worktree.directory, ".lalph", "prd.yml"),
26
26
  })
27
27
  const exitCode = yield* ChildProcess.make(
28
28
  cliCommand[0]!,
@@ -60,7 +60,7 @@ export const planContinue = Effect.gen(function* () {
60
60
 
61
61
  const cliCommand = cliAgent.commandPlan({
62
62
  prompt: promptGen.planContinuePrompt,
63
- prdFilePath: pathService.join(worktree.directory, ".lalph", "prd.json"),
63
+ prdFilePath: pathService.join(worktree.directory, ".lalph", "prd.yml"),
64
64
  })
65
65
  const exitCode = yield* ChildProcess.make(
66
66
  cliCommand[0]!,
package/src/Prd.ts CHANGED
@@ -9,7 +9,7 @@ import {
9
9
  Stream,
10
10
  } from "effect"
11
11
  import { Worktree } from "./Worktree.ts"
12
- import { PrdIssue, PrdList } from "./domain/PrdIssue.ts"
12
+ import { PrdIssue } from "./domain/PrdIssue.ts"
13
13
  import { IssueSource } from "./IssueSource.ts"
14
14
 
15
15
  export class Prd extends ServiceMap.Service<Prd>()("lalph/Prd", {
@@ -20,17 +20,16 @@ export class Prd extends ServiceMap.Service<Prd>()("lalph/Prd", {
20
20
  const source = yield* IssueSource
21
21
 
22
22
  const lalphDir = pathService.join(worktree.directory, `.lalph`)
23
- const prdFile = pathService.join(worktree.directory, `.lalph`, `prd.json`)
23
+ const prdFile = pathService.join(worktree.directory, `.lalph`, `prd.yml`)
24
24
 
25
25
  let current = yield* source.issues
26
- yield* fs.writeFileString(prdFile, PrdIssue.arrayToJson(current))
26
+ yield* fs.writeFileString(prdFile, PrdIssue.arrayToYaml(current))
27
27
 
28
28
  const updatedIssues = new Map<
29
29
  string,
30
30
  {
31
31
  readonly issue: PrdIssue
32
32
  readonly originalStateId: string
33
- count: number
34
33
  }
35
34
  >()
36
35
 
@@ -45,8 +44,8 @@ export class Prd extends ServiceMap.Service<Prd>()("lalph/Prd", {
45
44
  )
46
45
 
47
46
  const sync = Effect.gen(function* () {
48
- const json = yield* fs.readFileString(prdFile)
49
- const updated = PrdList.fromJson(json)
47
+ const yaml = yield* fs.readFileString(prdFile)
48
+ const updated = PrdIssue.arrayFromYaml(yaml)
50
49
  const anyChanges =
51
50
  updated.length !== current.length ||
52
51
  updated.some((u, i) => u.isChangedComparedTo(current[i]!))
@@ -84,12 +83,12 @@ export class Prd extends ServiceMap.Service<Prd>()("lalph/Prd", {
84
83
  blockedBy: issue.blockedBy,
85
84
  })
86
85
 
87
- let entry = updatedIssues.get(issue.id)
88
- if (!entry) {
89
- entry = { issue, originalStateId: existing.stateId, count: 0 }
90
- updatedIssues.set(issue.id, entry)
86
+ if (!updatedIssues.has(issue.id!)) {
87
+ updatedIssues.set(issue.id, {
88
+ issue,
89
+ originalStateId: existing.stateId,
90
+ })
91
91
  }
92
- entry.count++
93
92
  }
94
93
 
95
94
  yield* Effect.forEach(
@@ -101,7 +100,7 @@ export class Prd extends ServiceMap.Service<Prd>()("lalph/Prd", {
101
100
  current = yield* source.issues
102
101
  yield* fs.writeFileString(
103
102
  prdFile,
104
- PrdIssue.arrayToJson(
103
+ PrdIssue.arrayToYaml(
105
104
  current.map((issue) => {
106
105
  const prNumber = githubPrs.get(issue.id!)
107
106
  if (!prNumber) return issue
@@ -112,8 +111,8 @@ export class Prd extends ServiceMap.Service<Prd>()("lalph/Prd", {
112
111
  }).pipe(Effect.uninterruptible)
113
112
 
114
113
  const mergableGithubPrs = Effect.gen(function* () {
115
- const json = yield* fs.readFileString(prdFile)
116
- const updated = PrdList.fromJson(json)
114
+ const yaml = yield* fs.readFileString(prdFile)
115
+ const updated = PrdIssue.arrayFromYaml(yaml)
117
116
  const prs = Array.empty<number>()
118
117
  for (const issue of updated) {
119
118
  const entry = updatedIssues.get(issue.id ?? "")
@@ -129,24 +128,39 @@ export class Prd extends ServiceMap.Service<Prd>()("lalph/Prd", {
129
128
  return prs
130
129
  })
131
130
 
132
- const revertStateIds = (options: {
133
- readonly reason: "inactivity" | "error"
134
- }) =>
135
- Effect.suspend(() =>
136
- Effect.forEach(
137
- updatedIssues.values(),
138
- ({ issue, originalStateId, count }) =>
139
- options.reason === "error" || count === 1
140
- ? source.updateIssue({
141
- issueId: issue.id!,
142
- stateId: originalStateId,
143
- })
144
- : Effect.void,
145
- { concurrency: "unbounded", discard: true },
146
- ),
147
- )
131
+ const revertStateIds = Effect.suspend(() =>
132
+ Effect.forEach(
133
+ updatedIssues.values(),
134
+ ({ issue, originalStateId }) =>
135
+ source.updateIssue({
136
+ issueId: issue.id!,
137
+ stateId: originalStateId,
138
+ }),
139
+ { concurrency: "unbounded", discard: true },
140
+ ),
141
+ )
142
+ const maybeRevertIssue = Effect.fnUntraced(function* (options: {
143
+ readonly issueId: string
144
+ readonly todoStateId: string
145
+ readonly inProgressStateId: string
146
+ readonly reviewStateId: string
147
+ }) {
148
+ const yaml = yield* fs.readFileString(prdFile)
149
+ const updated = PrdIssue.arrayFromYaml(yaml)
150
+ const issue = updated.find((i) => i.id === options.issueId)
151
+ if (!issue || issue.stateId === options.reviewStateId) return
152
+ yield* source.updateIssue({
153
+ issueId: issue.id!,
154
+ stateId: options.todoStateId,
155
+ })
156
+ })
148
157
 
149
- return { path: prdFile, mergableGithubPrs, revertStateIds } as const
158
+ return {
159
+ path: prdFile,
160
+ mergableGithubPrs,
161
+ revertStateIds,
162
+ maybeRevertIssue,
163
+ } as const
150
164
  }),
151
165
  }) {
152
166
  static layer = Layer.effect(this, this.make).pipe(
package/src/PromptGen.ts CHANGED
@@ -11,9 +11,9 @@ export class PromptGen extends ServiceMap.Service<PromptGen>()(
11
11
  const source = yield* IssueSource
12
12
  const states = yield* source.states
13
13
 
14
- const prdNotes = `## prd.json format
14
+ const prdNotes = `## prd.yml format
15
15
 
16
- Each item in the prd.json file represents a task for the current project.
16
+ Each item in the prd.yml file represents a task for the current project.
17
17
 
18
18
  The \`stateId\` field indicates the current state of the task. The possible states
19
19
  are:
@@ -22,36 +22,70 @@ ${Array.from(states.values(), (state) => `- **${state.name}** (stateId: \`${stat
22
22
 
23
23
  ### Adding tasks
24
24
 
25
- To add a new task, append a new item to the prd.json file with the id set to
25
+ To add a new task, append a new item to the prd.yml file with the id set to
26
26
  \`null\`.
27
27
 
28
28
  When adding a new task, it will take about 5 seconds for the system to update the
29
- prd.json file with a new id for the task.
29
+ prd.yml file with a new id for the task.
30
30
 
31
31
  ### Removing tasks
32
32
 
33
- To remove a task, simply delete the item from the prd.json file.
33
+ To remove a task, simply delete the item from the prd.yml file.
34
34
 
35
- ### prd.json json schema
35
+ ### prd.yml json schema
36
36
 
37
37
  \`\`\`json
38
38
  ${JSON.stringify(PrdIssue.jsonSchema, null, 2)}
39
39
  \`\`\``
40
40
 
41
- const prompt = `# Instructions
41
+ const promptChoose = `# Instructions
42
42
 
43
- The following instructions should be done without interaction or asking for
44
- permission.
43
+ Your job is to choose the next task to work on from the prd.yml file. **DO NOT** implement the task yet.
44
+
45
+ The following instructions should be done without interaction or asking for permission.
45
46
 
46
- 1. Decide which single task to work on next from the prd.json file. This should
47
+ 1. Decide which single task to work on next from the prd.yml file. This should
47
48
  be the task YOU decide as the most important to work on next, not just the
48
49
  first task in the list.
49
50
  - Only start tasks that are in a "todo" state (i.e., not started yet).
50
51
  - If the \`blockedBy\` field is not empty, skip the task.
51
52
  2. **Before doing anything else**, mark the task as "in progress" by updating its
52
- \`stateId\` in the prd.json file.
53
+ \`stateId\` in the prd.yml file.
53
54
  This prevents other people or agents from working on the same task simultaneously.
54
- 3. Check if there is an existing Github PR for the task, otherwise create a new
55
+ 3. Research the task. If it seems like too many steps are needed to complete the task,
56
+ break it down into smaller tasks and add them to the prd.yml file, marking the
57
+ original task as "closed" by updating its \`stateId\`.
58
+ 4. Once you have chosen a task of reasonable size, save its information in a
59
+ "task.json" file alongside the prd.yml file. Use the following format:
60
+
61
+ \`\`\`json
62
+ {
63
+ "id": "task id",
64
+ "todoStateId": "id of the todo state",
65
+ "inProgressStateId": "id of the in progress state",
66
+ "reviewStateId": "id of the review state"
67
+ }
68
+ \`\`\`
69
+
70
+ ## Important: Task sizing
71
+
72
+ If at any point you decide that a task is too large or complex to complete in a
73
+ single iteration, break it down into smaller tasks and add them to the prd.yml
74
+ file. Then, mark the original task as "closed" by updating its \`stateId\`.
75
+
76
+ Each task should be small and specific.
77
+ Instead of creating tasks like "Refactor the authentication system", create
78
+ smaller tasks like "Implement OAuth2 login endpoint", "Add JWT token refresh mechanism", etc.
79
+
80
+ ${prdNotes}`
81
+
82
+ const prompt = (taskId: string) => `# Instructions
83
+
84
+ The following instructions should be done without interaction or asking for
85
+ permission.
86
+
87
+ 1. Your job is to complete the task with id \`${taskId}\` from the prd.yml file.
88
+ 2. Check if there is an existing Github PR for the task, otherwise create a new
55
89
  branch for the task.
56
90
  - If there is an existing PR, checkout the branch for that PR.
57
91
  - If there is an existing PR, check if there are any new comments or requested
@@ -60,10 +94,7 @@ permission.
60
94
  HEAD as the base.
61
95
  - New branches should be named using the format \`{task id}/description\`.
62
96
  - When checking for PR reviews, make sure to check the "reviews" field and read ALL unresolved comments.
63
- 4. Research the task. If it seems like too many steps are needed to complete the task,
64
- break it down into smaller tasks and add them to the prd.json file, marking the
65
- original task as "closed" by updating its \`stateId\`.
66
- Otherwise, implement the task.
97
+ 4. Implement the task.
67
98
  5. Run any checks / feedback loops, such as type checks, unit tests, or linting.
68
99
  6. Create or update the pull request with your progress.
69
100
  ${sourceMeta.githubPrInstructions}
@@ -71,25 +102,12 @@ permission.
71
102
  - None of the files in the \`.lalph\` directory should be committed.
72
103
  - You have permission to create or update the PR as needed. You have full
73
104
  permission to push branches, create PRs or create git commits.
74
- 7. Update the prd.json file to reflect any changes in task states.
105
+ 7. Update the prd.yml file to reflect any changes in task states.
75
106
  - Add follow up tasks only if needed.
76
107
  - Append to the \`description\` field with any notes or important discoveries.
77
108
  - If you believe the task is complete, update the \`stateId\` for "review".
78
109
  Only if no "review" state exists, use a completed state.
79
110
 
80
- Remember, only work on a single task at a time, that you decide is the most
81
- important to work on next.
82
-
83
- ## Important: Task sizing
84
-
85
- If at any point you decide that a task is too large or complex to complete in a
86
- single iteration, break it down into smaller tasks and add them to the prd.json
87
- file. Then, mark the original task as "closed" by updating its \`stateId\`.
88
-
89
- Each task should be small and specific.
90
- Instead of creating tasks like "Refactor the authentication system", create
91
- smaller tasks like "Implement OAuth2 login endpoint", "Add JWT token refresh mechanism", etc.
92
-
93
111
  ## Handling blockers
94
112
 
95
113
  If for any reason you get stuck on a task, mark the task back as "todo" by updating its
@@ -106,17 +124,17 @@ ${prdNotes}`
106
124
  Users idea / request: ${idea}
107
125
 
108
126
  1. For the users idea / request above, break it down into multiple smaller tasks
109
- that can be added to the prd.json file.
127
+ that can be added to the prd.yml file.
110
128
  - Make sure to research the codebase before creating any tasks, to ensure they
111
129
  are relevant and feasible.
112
- - Check if similar tasks already exist in the prd.json file to avoid duplication.
130
+ - Check if similar tasks already exist in the prd.yml file to avoid duplication.
113
131
  2. Each task should have a id of \`null\`, a title, and a concise description that
114
132
  includes a short summary of the task and a brief list of steps to complete it.
115
133
  - The tasks should start in a "Todo" state (i.e., not started yet).
116
134
  - Each task should be small and specific.
117
135
  Instead of creating tasks like "Refactor the authentication system", create
118
136
  smaller tasks like "Implement OAuth2 login endpoint", "Add JWT token refresh mechanism", etc.
119
- 3. Add the new tasks to the prd.json file.
137
+ 3. Add the new tasks to the prd.yml file.
120
138
  4. Add a brief outline of the plan to a "lalph-plan.md" file, that will help guide future
121
139
  iterations.
122
140
 
@@ -124,7 +142,7 @@ ${prdNotes}`
124
142
 
125
143
  const planContinuePrompt = `# Instructions
126
144
 
127
- 1. Review the existing prd.json file and lalph-plan.md file to understand the current
145
+ 1. Review the existing prd.yml file and lalph-plan.md file to understand the current
128
146
  plan and tasks.
129
147
  2. Ask the user for feedback to iterate on the existing plan.
130
148
 
@@ -141,7 +159,7 @@ ${prdNotes}`
141
159
 
142
160
  ${prdNotes}`
143
161
 
144
- return { prompt, planPrompt, planContinuePrompt } as const
162
+ return { promptChoose, prompt, planPrompt, planContinuePrompt } as const
145
163
  }),
146
164
  },
147
165
  ) {
package/src/Runner.ts CHANGED
@@ -1,4 +1,13 @@
1
- import { Data, DateTime, Duration, Effect, Path, Stream } from "effect"
1
+ import {
2
+ Data,
3
+ DateTime,
4
+ Duration,
5
+ Effect,
6
+ FileSystem,
7
+ Path,
8
+ Schema,
9
+ Stream,
10
+ } from "effect"
2
11
  import { PromptGen } from "./PromptGen.ts"
3
12
  import { Prd } from "./Prd.ts"
4
13
  import { ChildProcess } from "effect/unstable/process"
@@ -10,15 +19,34 @@ export const run = Effect.fnUntraced(
10
19
  readonly autoMerge: boolean
11
20
  readonly stallTimeout: Duration.Duration
12
21
  }) {
22
+ const fs = yield* FileSystem.FileSystem
13
23
  const pathService = yield* Path.Path
14
24
  const worktree = yield* Worktree
15
25
  const promptGen = yield* PromptGen
16
26
  const cliAgent = yield* getOrSelectCliAgent
17
27
  const prd = yield* Prd
18
28
 
29
+ const chooseCommand = cliAgent.command({
30
+ prompt: promptGen.promptChoose,
31
+ prdFilePath: pathService.join(".lalph", "prd.yml"),
32
+ })
33
+
34
+ yield* ChildProcess.make(chooseCommand[0]!, chooseCommand.slice(1), {
35
+ cwd: worktree.directory,
36
+ extendEnv: true,
37
+ stdout: "inherit",
38
+ stderr: "inherit",
39
+ stdin: "inherit",
40
+ }).pipe(ChildProcess.exitCode)
41
+
42
+ const taskJson = yield* fs.readFileString(
43
+ pathService.join(worktree.directory, ".lalph", "task.json"),
44
+ )
45
+ const task = yield* Schema.decodeEffect(ChosenTask)(taskJson)
46
+
19
47
  const cliCommand = cliAgent.command({
20
- prompt: promptGen.prompt,
21
- prdFilePath: pathService.join(".lalph", "prd.json"),
48
+ prompt: promptGen.prompt(task.id),
49
+ prdFilePath: pathService.join(".lalph", "prd.yml"),
22
50
  })
23
51
  const handle = yield* ChildProcess.make(
24
52
  cliCommand[0]!,
@@ -62,7 +90,10 @@ export const run = Effect.fnUntraced(
62
90
 
63
91
  const prs = yield* prd.mergableGithubPrs
64
92
  if (prs.length === 0) {
65
- yield* prd.revertStateIds({ reason: "inactivity" })
93
+ yield* prd.maybeRevertIssue({
94
+ ...task,
95
+ issueId: task.id,
96
+ })
66
97
  } else if (options.autoMerge) {
67
98
  for (const pr of prs) {
68
99
  yield* ChildProcess.make`gh pr merge ${pr} -sd`.pipe(
@@ -75,7 +106,7 @@ export const run = Effect.fnUntraced(
75
106
  Effect.onError(
76
107
  Effect.fnUntraced(function* () {
77
108
  const prd = yield* Prd
78
- yield* Effect.ignore(prd.revertStateIds({ reason: "error" }))
109
+ yield* Effect.ignore(prd.revertStateIds)
79
110
  }),
80
111
  ),
81
112
  Effect.scoped,
@@ -85,3 +116,12 @@ export const run = Effect.fnUntraced(
85
116
  export class RunnerStalled extends Data.TaggedError("RunnerStalled") {
86
117
  readonly message = "The runner has stalled due to inactivity."
87
118
  }
119
+
120
+ const ChosenTask = Schema.fromJsonString(
121
+ Schema.Struct({
122
+ id: Schema.String,
123
+ todoStateId: Schema.String,
124
+ inProgressStateId: Schema.String,
125
+ reviewStateId: Schema.String,
126
+ }),
127
+ )
@@ -1,4 +1,5 @@
1
- import { Schema, Data, Array, Equal } from "effect"
1
+ import { Schema, Array, Equal } from "effect"
2
+ import * as Yaml from "yaml"
2
3
 
3
4
  export class PrdIssue extends Schema.Class<PrdIssue>("PrdIssue")({
4
5
  id: Schema.NullOr(Schema.String).annotate({
@@ -36,12 +37,14 @@ export class PrdIssue extends Schema.Class<PrdIssue>("PrdIssue")({
36
37
  }) {
37
38
  static Array = Schema.Array(this)
38
39
  static ArrayFromJson = Schema.toCodecJson(this.Array)
39
- static arrayToJson(issues: ReadonlyArray<PrdIssue>): string {
40
- return JSON.stringify(
41
- Schema.encodeSync(this.ArrayFromJson)(issues),
42
- null,
43
- 2,
44
- )
40
+ static arrayToYaml(issues: ReadonlyArray<PrdIssue>): string {
41
+ const json = Schema.encodeSync(this.ArrayFromJson)(issues)
42
+ return Yaml.stringify(json, { blockQuote: "literal" })
43
+ }
44
+ static arrayFromYaml(yaml: string): ReadonlyArray<PrdIssue> {
45
+ const json = Yaml.parse(yaml)
46
+ const issues = Schema.decodeSync(PrdIssue.ArrayFromJson)(json)
47
+ return issues
45
48
  }
46
49
 
47
50
  static jsonSchemaDoc = Schema.toJsonSchemaDocument(this)
@@ -62,22 +65,3 @@ export class PrdIssue extends Schema.Class<PrdIssue>("PrdIssue")({
62
65
  )
63
66
  }
64
67
  }
65
-
66
- export class PrdList<O = unknown> extends Data.Class<{
67
- readonly issues: ReadonlyMap<string, PrdIssue>
68
- readonly orignals: ReadonlyMap<string, O>
69
- }> {
70
- static fromJson(json: string): ReadonlyArray<PrdIssue> {
71
- const issues = Schema.decodeSync(PrdIssue.ArrayFromJson)(JSON.parse(json))
72
- return issues
73
- }
74
-
75
- toJson(): string {
76
- const issuesArray = Array.fromIterable(this.issues.values())
77
- return PrdIssue.arrayToJson(issuesArray)
78
- }
79
-
80
- cast<T>(): PrdList<T> {
81
- return this as any
82
- }
83
- }