lalph 0.1.96 → 0.1.98

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
@@ -28564,6 +28564,17 @@ const provideServiceEffect = /* @__PURE__ */ dual(3, (self$1, key, service$2) =>
28564
28564
  */
28565
28565
  const runForEach$1 = /* @__PURE__ */ dual(2, (self$1, f) => runWith$1(self$1, (pull) => forever(flatMap(pull, f), { autoYield: false })));
28566
28566
  /**
28567
+ * @since 2.0.0
28568
+ * @category execution
28569
+ */
28570
+ const runHead$1 = (self$1) => suspend$3(() => {
28571
+ let head = none$3();
28572
+ return runWith$1(self$1, (pull) => pull.pipe(asSome, flatMap((head_$1) => {
28573
+ head = head_$1;
28574
+ return done();
28575
+ })), () => succeed$1(head));
28576
+ });
28577
+ /**
28567
28578
  * Runs a channel and folds over all output elements with an accumulator.
28568
28579
  *
28569
28580
  * @example
@@ -30247,6 +30258,11 @@ const runCollect = (self$1) => runFold(self$1.channel, () => [], (acc, chunk) =>
30247
30258
  return acc;
30248
30259
  });
30249
30260
  /**
30261
+ * @since 2.0.0
30262
+ * @category destructors
30263
+ */
30264
+ const runHead = (self$1) => map$6(runHead$1(self$1.channel), map$12(getUnsafe$1(0)));
30265
+ /**
30250
30266
  * Consumes all elements of the stream, passing them to the specified
30251
30267
  * callback.
30252
30268
  *
@@ -46145,15 +46161,15 @@ const black = `${ESC}30m`;
46145
46161
  /** @internal */
46146
46162
  const red = `${ESC}31m`;
46147
46163
  /** @internal */
46148
- const green = `${ESC}32m`;
46164
+ const green$1 = `${ESC}32m`;
46149
46165
  /** @internal */
46150
- const yellow = `${ESC}33m`;
46166
+ const yellow$1 = `${ESC}33m`;
46151
46167
  /** @internal */
46152
46168
  const blue = `${ESC}34m`;
46153
46169
  /** @internal */
46154
46170
  const magenta = `${ESC}35m`;
46155
46171
  /** @internal */
46156
- const cyan = `${ESC}36m`;
46172
+ const cyan$1 = `${ESC}36m`;
46157
46173
  /** @internal */
46158
46174
  const white = `${ESC}37m`;
46159
46175
  /** @internal */
@@ -46543,12 +46559,12 @@ const renderAutoCompleteNextFrame = /* @__PURE__ */ fnUntraced(function* (state,
46543
46559
  const renderSelectSubmission = /* @__PURE__ */ fnUntraced(function* (state, options) {
46544
46560
  const figures = yield* platformFigures;
46545
46561
  const selected = options.choices[state].title;
46546
- return renderSelectOutput(annotate(figures.tick, green), annotate(figures.ellipsis, blackBright), options) + " " + annotate(selected, white) + "\n";
46562
+ return renderSelectOutput(annotate(figures.tick, green$1), annotate(figures.ellipsis, blackBright), options) + " " + annotate(selected, white) + "\n";
46547
46563
  });
46548
46564
  const renderAutoCompleteSubmission = /* @__PURE__ */ fnUntraced(function* (state, options) {
46549
46565
  const figures = yield* platformFigures;
46550
46566
  const selected = options.choices[state.index].title;
46551
- return renderAutoCompleteOutput(state, annotate(figures.tick, green), annotate(figures.ellipsis, blackBright), options) + " " + annotate(selected, white) + "\n";
46567
+ return renderAutoCompleteOutput(state, annotate(figures.tick, green$1), annotate(figures.ellipsis, blackBright), options) + " " + annotate(selected, white) + "\n";
46552
46568
  });
46553
46569
  const processSelectCursorUp = (state, choices) => {
46554
46570
  if (state === 0) return succeed$1(Action.NextFrame({ state: choices.length - 1 }));
@@ -46701,7 +46717,7 @@ const renderTextNextFrame = /* @__PURE__ */ fnUntraced(function* (state, options
46701
46717
  });
46702
46718
  const renderTextSubmission = /* @__PURE__ */ fnUntraced(function* (state, options) {
46703
46719
  const figures = yield* platformFigures;
46704
- return renderTextOutput(state, annotate(figures.tick, green), annotate(figures.ellipsis, blackBright), options, true) + "\n";
46720
+ return renderTextOutput(state, annotate(figures.tick, green$1), annotate(figures.ellipsis, blackBright), options, true) + "\n";
46705
46721
  });
46706
46722
  const processTextBackspace = (state) => {
46707
46723
  if (state.cursor <= 0) return succeed$1(Action.Beep());
@@ -62208,7 +62224,7 @@ const symlink = /* @__PURE__ */ (() => {
62208
62224
  const nodeSymlink = /* @__PURE__ */ effectify(NFS.symlink, /* @__PURE__ */ handleErrnoException("FileSystem", "symlink"), /* @__PURE__ */ handleBadArgument("symlink"));
62209
62225
  return (target, path$2) => nodeSymlink(target, path$2);
62210
62226
  })();
62211
- const truncate = /* @__PURE__ */ (() => {
62227
+ const truncate$1 = /* @__PURE__ */ (() => {
62212
62228
  const nodeTruncate = /* @__PURE__ */ effectify(NFS.truncate, /* @__PURE__ */ handleErrnoException("FileSystem", "truncate"), /* @__PURE__ */ handleBadArgument("truncate"));
62213
62229
  return (path$2, length) => nodeTruncate(path$2, length !== void 0 ? Number(length) : void 0);
62214
62230
  })();
@@ -62296,7 +62312,7 @@ const makeFileSystem = /* @__PURE__ */ map$6(/* @__PURE__ */ serviceOption(Watch
62296
62312
  rename,
62297
62313
  stat,
62298
62314
  symlink,
62299
- truncate,
62315
+ truncate: truncate$1,
62300
62316
  utimes,
62301
62317
  watch(path$2) {
62302
62318
  return watch(getOrUndefined(backend), path$2);
@@ -63229,14 +63245,6 @@ const runMain = runMain$1;
63229
63245
  //#region src/Kvs.ts
63230
63246
  const layerKvs = layerFileSystem(".lalph/config");
63231
63247
 
63232
- //#endregion
63233
- //#region src/shared/stream.ts
63234
- const streamFilterJson = (schema$1) => {
63235
- const fromString = fromJsonString(schema$1);
63236
- const decode$2 = decodeEffect(fromString);
63237
- return flow(splitLines, filterMapEffect((line) => decode$2(line).pipe(catch_$1(() => succeed$1(failVoid)))));
63238
- };
63239
-
63240
63248
  //#endregion
63241
63249
  //#region src/shared/ansi-colors.ts
63242
63250
  const ansiColors = {
@@ -63247,45 +63255,148 @@ const ansiColors = {
63247
63255
  yellow: "\x1B[33m"
63248
63256
  };
63249
63257
 
63258
+ //#endregion
63259
+ //#region src/shared/stream.ts
63260
+ const streamFilterJson = (schema$1) => {
63261
+ const fromString = fromJsonString(schema$1);
63262
+ const decode$2 = decodeEffect(fromString);
63263
+ return flow(splitLines, filterMapEffect((line) => decode$2(line).pipe(catch_$1(() => succeed$1(failVoid)))));
63264
+ };
63265
+
63250
63266
  //#endregion
63251
63267
  //#region src/CliAgent/claude.ts
63252
63268
  const claudeOutputTransformer = (stream$3) => stream$3.pipe(streamFilterJson(StreamJsonMessage), map$4((m) => m.format()));
63253
63269
  const ContentBlock = Struct({
63254
63270
  type: String$1,
63255
63271
  text: optional$2(String$1),
63256
- name: optional$2(String$1)
63272
+ name: optional$2(String$1),
63273
+ input: optional$2(Unknown),
63274
+ content: optional$2(String$1),
63275
+ is_error: optional$2(Boolean$2)
63276
+ });
63277
+ const ToolUseResult = Struct({
63278
+ stdout: optional$2(String$1),
63279
+ stderr: optional$2(String$1),
63280
+ interrupted: optional$2(Boolean$2),
63281
+ isImage: optional$2(Boolean$2)
63257
63282
  });
63258
63283
  var StreamJsonMessage = class extends Class("claude/StreamJsonMessage")({
63259
63284
  type: String$1,
63260
63285
  subtype: optional$2(String$1),
63261
63286
  message: optional$2(Struct({ content: optional$2(Array$1(ContentBlock)) })),
63287
+ tool_use_result: optional$2(ToolUseResult),
63262
63288
  duration_ms: optional$2(Number$1),
63263
63289
  total_cost_usd: optional$2(Number$1)
63264
63290
  }) {
63265
63291
  format() {
63266
63292
  switch (this.type) {
63267
- case "system": return this.subtype === "init" ? ansiColors.dim + "[Session started]" + ansiColors.reset + "\n" : "";
63293
+ case "system": return this.subtype === "init" ? dim("[Session started]") + "\n" : "";
63268
63294
  case "assistant": return formatAssistantMessage(this);
63295
+ case "user": return formatToolResult(this);
63269
63296
  case "result": return formatResult(this);
63270
63297
  default: return "";
63271
63298
  }
63272
63299
  }
63273
63300
  };
63301
+ const BashInput = Struct({ command: optional$2(String$1) });
63302
+ const FileInput = Struct({ file_path: optional$2(String$1) });
63303
+ const PatternInput = Struct({ pattern: optional$2(String$1) });
63304
+ const QuestionOption = Struct({
63305
+ label: String$1,
63306
+ description: optional$2(String$1)
63307
+ });
63308
+ const Question = Struct({
63309
+ question: String$1,
63310
+ header: optional$2(String$1),
63311
+ options: optional$2(Array$1(QuestionOption))
63312
+ });
63313
+ const AskUserQuestionInput = Struct({ questions: optional$2(Array$1(Question)) });
63314
+ const McpInputFields = [
63315
+ "query",
63316
+ "documentId",
63317
+ "page",
63318
+ "pattern",
63319
+ "relative_path",
63320
+ "name_path",
63321
+ "file_path",
63322
+ "root_path"
63323
+ ];
63324
+ const truncate = (s, max$3) => s.length > max$3 ? s.slice(0, max$3) + "..." : s;
63325
+ const dim = (s) => ansiColors.dim + s + ansiColors.reset;
63326
+ const cyan = (s) => ansiColors.cyan + s + ansiColors.reset;
63327
+ const yellow = (s) => ansiColors.yellow + s + ansiColors.reset;
63328
+ const green = (s) => ansiColors.green + s + ansiColors.reset;
63274
63329
  const formatToolName = (name) => name.replace("mcp__", "").replace(/__/g, ":");
63330
+ const withDetail = (display, detail) => display + getOrElse(detail, () => "");
63331
+ const formatBashInput = (input) => pipe(decodeUnknownOption(BashInput)(input), flatMap$3((data) => fromNullishOr$2(data.command)), filter$6((cmd) => cmd.length > 0), map$12((cmd) => dim("$ " + truncate(cmd, 100)) + "\n"));
63332
+ const formatFileInput = (input) => pipe(decodeUnknownOption(FileInput)(input), flatMap$3((data) => fromNullishOr$2(data.file_path)), filter$6((path$2) => path$2.length > 0), map$12((path$2) => dim(path$2) + "\n"));
63333
+ const formatPatternInput = (input) => pipe(decodeUnknownOption(PatternInput)(input), flatMap$3((data) => fromNullishOr$2(data.pattern)), filter$6((pattern) => pattern.length > 0), map$12((pattern) => dim(pattern) + "\n"));
63334
+ const formatMcpInput = (input) => {
63335
+ if (typeof input !== "object" || input === null) return none$3();
63336
+ const data = input;
63337
+ const parts$1 = McpInputFields.flatMap((field) => match$7(fromNullishOr$2(data[field]), {
63338
+ onNone: () => [],
63339
+ onSome: (value) => [`${field}=${truncate(String(value), 50)}`]
63340
+ }));
63341
+ return parts$1.length > 0 ? some(dim(parts$1.join(" ")) + "\n") : none$3();
63342
+ };
63343
+ const formatGenericInput = (input) => pipe(fromNullishOr$2(input), map$12((v) => dim(truncate(JSON.stringify(v), 100)) + "\n"));
63344
+ const formatUserQuestion = (input) => pipe(decodeUnknownOption(AskUserQuestionInput)(input), flatMap$3((data) => fromNullishOr$2(data.questions)), map$12((questions) => questions.map((q) => {
63345
+ let result$2 = "\n" + yellow("⚠ WAITING FOR INPUT") + "\n";
63346
+ result$2 += cyan((q.header ? `[${q.header}] ` : "") + q.question) + "\n";
63347
+ if (q.options) result$2 += q.options.map((opt, i) => ` ${i + 1}. ${opt.label}${opt.description ? dim(` - ${opt.description}`) : ""}`).join("\n") + "\n";
63348
+ return result$2;
63349
+ }).join("\n")), getOrElse(() => ""));
63350
+ const formatToolInput = (name, input) => {
63351
+ const display = "\n" + cyan("▶ " + formatToolName(name)) + "\n";
63352
+ if (name === "Bash") return withDetail(display, formatBashInput(input));
63353
+ if (name === "AskUserQuestion") return display + formatUserQuestion(input);
63354
+ if (name === "Read" || name === "Write" || name === "Edit") return withDetail(display, formatFileInput(input));
63355
+ if (name === "Grep" || name === "Glob") return withDetail(display, formatPatternInput(input));
63356
+ if (name.startsWith("mcp__")) return withDetail(display, formatMcpInput(input));
63357
+ return withDetail(display, formatGenericInput(input));
63358
+ };
63275
63359
  const formatAssistantMessage = (msg) => {
63276
63360
  const content = msg.message?.content;
63277
63361
  if (!content) return "";
63278
63362
  return content.map((block) => {
63279
63363
  if (block.type === "text" && block.text) return block.text;
63280
- else if (block.type === "tool_use" && block.name) return "\n" + ansiColors.cyan + "▶ " + formatToolName(block.name) + ansiColors.reset + "\n";
63364
+ if (block.type === "tool_use" && block.name) return formatToolInput(block.name, block.input);
63281
63365
  return "";
63282
63366
  }).join("");
63283
63367
  };
63368
+ const formatLongOutput = (text$3) => {
63369
+ const lines$1 = text$3.trim().split("\n");
63370
+ if (lines$1.length > 8) return dim([
63371
+ ...lines$1.slice(0, 4),
63372
+ `... (${lines$1.length - 7} more lines)`,
63373
+ ...lines$1.slice(-3)
63374
+ ].join("\n")) + "\n";
63375
+ return text$3.length > 500 ? dim(truncate(text$3, 500)) + "\n" : dim(text$3) + "\n";
63376
+ };
63377
+ const formatToolResult = (msg) => {
63378
+ let output = "";
63379
+ const result$2 = msg.tool_use_result;
63380
+ if (result$2) {
63381
+ if (result$2.stderr?.trim()) output += yellow("stderr: ") + truncate(result$2.stderr.trim(), 500) + "\n";
63382
+ if (result$2.interrupted) output += yellow("[interrupted]") + "\n";
63383
+ if (result$2.stdout?.trim()) output += formatLongOutput(result$2.stdout.trim());
63384
+ }
63385
+ const content = msg.message?.content;
63386
+ if (content) {
63387
+ for (const block of content) if (block.type === "tool_result") {
63388
+ if (block.is_error) output += yellow("✗ Tool error") + "\n";
63389
+ if (block.content) output += formatLongOutput(block.content);
63390
+ }
63391
+ }
63392
+ return output;
63393
+ };
63284
63394
  const formatResult = (msg) => {
63285
63395
  if (msg.subtype === "success") {
63286
63396
  const info = [msg.duration_ms ? (msg.duration_ms / 1e3).toFixed(1) + "s" : "", msg.total_cost_usd ? "$" + msg.total_cost_usd.toFixed(4) : ""].filter(Boolean).join(" | ");
63287
- return "\n" + ansiColors.green + "✓ Done" + ansiColors.reset + " " + ansiColors.dim + info + ansiColors.reset + "\n";
63288
- } else if (msg.subtype === "error") return "\n" + ansiColors.yellow + "✗ Error" + ansiColors.reset + "\n";
63397
+ return "\n" + green("✓ Done") + " " + dim(info) + "\n";
63398
+ }
63399
+ if (msg.subtype === "error") return "\n" + yellow("✗ Error") + "\n";
63289
63400
  return "";
63290
63401
  };
63291
63402
 
@@ -63326,7 +63437,7 @@ const claude = new CliAgent({
63326
63437
  stdout: outputMode,
63327
63438
  stderr: outputMode,
63328
63439
  stdin: "inherit"
63329
- })`claude --dangerously-skip-permissions --output-format stream-json --verbose -p ${`@${prdFilePath}
63440
+ })`claude --dangerously-skip-permissions --output-format stream-json --verbose --disallowed-tools AskUserQuestion -p ${`@${prdFilePath}
63330
63441
 
63331
63442
  ${prompt}`}`,
63332
63443
  outputTransformer: claudeOutputTransformer,
@@ -145054,6 +145165,14 @@ const getOrSelectCliAgent = gen(function* () {
145054
145165
  });
145055
145166
  const commandAgent = make$28("agent").pipe(withDescription("Select the CLI agent to use"), withHandler(() => selectCliAgent));
145056
145167
 
145168
+ //#endregion
145169
+ //#region src/shared/fs.ts
145170
+ const makeWaitForFile = gen(function* () {
145171
+ const fs = yield* FileSystem;
145172
+ const pathService = yield* Path$1;
145173
+ return (directory$2, name) => pipe(fs.watch(directory$2), filter((e) => pathService.basename(e.path) === name), runHead);
145174
+ });
145175
+
145057
145176
  //#endregion
145058
145177
  //#region src/Agents/instructor.ts
145059
145178
  const agentInstructor = fnUntraced(function* (options) {
@@ -145061,6 +145180,7 @@ const agentInstructor = fnUntraced(function* (options) {
145061
145180
  const pathService = yield* Path$1;
145062
145181
  const worktree = yield* Worktree;
145063
145182
  const promptGen = yield* PromptGen;
145183
+ const waitForFile = yield* makeWaitForFile;
145064
145184
  yield* pipe(options.cliAgent.command({
145065
145185
  outputMode: "pipe",
145066
145186
  prompt: promptGen.promptInstructions({
@@ -145073,7 +145193,7 @@ const agentInstructor = fnUntraced(function* (options) {
145073
145193
  }), setCwd(worktree.directory), options.commandPrefix, worktree.execWithStallTimeout({
145074
145194
  cliAgent: options.cliAgent,
145075
145195
  stallTimeout: options.stallTimeout
145076
- }));
145196
+ }), raceFirst(waitForFile(pathService.join(worktree.directory, ".lalph"), "instructions.md")));
145077
145197
  return yield* fs.readFileString(pathService.join(worktree.directory, ".lalph", "instructions.md"));
145078
145198
  });
145079
145199
 
@@ -145104,13 +145224,14 @@ const agentChooser = fnUntraced(function* (options) {
145104
145224
  const worktree = yield* Worktree;
145105
145225
  const promptGen = yield* PromptGen;
145106
145226
  const prd = yield* Prd;
145227
+ const taskJsonCreated = (yield* makeWaitForFile)(pathService.join(worktree.directory, ".lalph"), "task.json");
145107
145228
  yield* pipe(options.cliAgent.resolveCommandChoose({
145108
145229
  prompt: promptGen.promptChoose,
145109
145230
  prdFilePath: pathService.join(".lalph", "prd.yml")
145110
145231
  }), setCwd(worktree.directory), options.commandPrefix, exitCode, timeoutOrElse({
145111
145232
  duration: options.stallTimeout,
145112
145233
  onTimeout: () => fail$4(new RunnerStalled())
145113
- }));
145234
+ }), raceFirst(taskJsonCreated));
145114
145235
  const taskJson = yield* fs.readFileString(pathService.join(worktree.directory, ".lalph", "task.json"));
145115
145236
  return yield* decodeEffect(ChosenTask)(taskJson).pipe(mapError$2(() => new ChosenTaskNotFound()), flatMap(fnUntraced(function* (task) {
145116
145237
  const prdTask = yield* prd.findById(task.id);
@@ -145169,71 +145290,6 @@ const agentTimeout = fnUntraced(function* (options) {
145169
145290
 
145170
145291
  //#endregion
145171
145292
  //#region src/commands/root.ts
145172
- const iterations = integer("iterations").pipe(withDescription$1("Number of iterations to run, defaults to unlimited"), withAlias("i"), withDefault(Number.POSITIVE_INFINITY));
145173
- const concurrency = integer("concurrency").pipe(withDescription$1("Number of concurrent agents, defaults to 1"), withAlias("c"), withDefault(1));
145174
- const targetBranch = string$1("target-branch").pipe(withDescription$1("Target branch for PRs. Defaults to current branch. Env variable: LALPH_TARGET_BRANCH"), withAlias("b"), withFallbackConfig(string$4("LALPH_TARGET_BRANCH")), withDefault(make$21`git branch --show-current`.pipe(string, orDie$2, flatMap((output) => {
145175
- const branch = output.trim();
145176
- return branch === "" ? fail$4(new MissingOption({ option: "--target-branch" })) : succeed$1(branch);
145177
- }))), optional);
145178
- const maxIterationMinutes = integer("max-minutes").pipe(withDescription$1("Maximum number of minutes to allow an iteration to run. Defaults to 90 minutes. Env variable: LALPH_MAX_MINUTES"), withFallbackConfig(int("LALPH_MAX_MINUTES")), withDefault(90));
145179
- const stallMinutes = integer("stall-minutes").pipe(withDescription$1("If no activity occurs for this many minutes, the iteration will be stopped. Defaults to 5 minutes. Env variable: LALPH_STALL_MINUTES"), withFallbackConfig(int("LALPH_STALL_MINUTES")), withDefault(5));
145180
- const specsDirectory = directory("specs").pipe(withDescription$1("Directory to store plan specifications. Env variable: LALPH_SPECS"), withAlias("s"), withFallbackConfig(string$4("LALPH_SPECS")), withDefault(".specs"));
145181
- const verbose = boolean("verbose").pipe(withDescription$1("Enable verbose logging"), withAlias("v"));
145182
- const reset = boolean("reset").pipe(withDescription$1("Reset the current issue source before running"), withAlias("r"));
145183
- const commandRoot = make$28("lalph", {
145184
- iterations,
145185
- concurrency,
145186
- targetBranch,
145187
- maxIterationMinutes,
145188
- stallMinutes,
145189
- reset,
145190
- specsDirectory,
145191
- verbose
145192
- }).pipe(withHandler(fnUntraced(function* ({ iterations: iterations$1, concurrency: concurrency$1, targetBranch: targetBranch$1, maxIterationMinutes: maxIterationMinutes$1, stallMinutes: stallMinutes$1, specsDirectory: specsDirectory$1 }) {
145193
- const source = yield* build(CurrentIssueSource.layer);
145194
- const commandPrefix = yield* getCommandPrefix;
145195
- yield* getOrSelectCliAgent;
145196
- const isFinite$3 = Number.isFinite(iterations$1);
145197
- const iterationsDisplay = isFinite$3 ? iterations$1 : "unlimited";
145198
- const runConcurrency = Math.max(1, concurrency$1);
145199
- const semaphore = makeSemaphoreUnsafe(runConcurrency);
145200
- const fibers = yield* make$25();
145201
- yield* resetInProgress.pipe(provide$1(source), withSpan("Main.resetInProgress"));
145202
- yield* log$1(`Executing ${iterationsDisplay} iteration(s) with concurrency ${runConcurrency}`);
145203
- if (isSome(targetBranch$1)) yield* log$1(`Using target branch: ${targetBranch$1.value}`);
145204
- let iteration = 0;
145205
- let quit = false;
145206
- while (true) {
145207
- yield* semaphore.take(1);
145208
- if (quit || isFinite$3 && iteration >= iterations$1) break;
145209
- const currentIteration = iteration;
145210
- const startedDeferred = yield* make$49();
145211
- yield* checkForWork.pipe(andThen(run({
145212
- startedDeferred,
145213
- targetBranch: targetBranch$1,
145214
- specsDirectory: specsDirectory$1,
145215
- stallTimeout: minutes(stallMinutes$1),
145216
- runTimeout: minutes(maxIterationMinutes$1),
145217
- commandPrefix
145218
- })), catchFilter((e) => e._tag === "NoMoreWork" || e._tag === "QuitError" ? fail$8(e) : e, (e) => logWarning(fail$5(e))), catchTags({
145219
- NoMoreWork(_) {
145220
- if (isFinite$3) {
145221
- iterations$1 = currentIteration;
145222
- return log$1(`No more work to process, ending after ${currentIteration} iteration(s).`);
145223
- }
145224
- const log$2 = size$1(fibers) <= 1 ? log$1("No more work to process, waiting 30 seconds...") : void_$1;
145225
- return andThen(log$2, sleep(seconds(30)));
145226
- },
145227
- QuitError(_) {
145228
- quit = true;
145229
- return void_$1;
145230
- }
145231
- }), annotateLogs({ iteration: currentIteration }), ensuring(semaphore.release(1)), ensuring(completeWith(startedDeferred, void_$1)), provide$1(source), run$1(fibers));
145232
- yield* _await(startedDeferred);
145233
- iteration++;
145234
- }
145235
- yield* awaitEmpty(fibers);
145236
- }, scoped$1)));
145237
145293
  const run = fnUntraced(function* (options) {
145238
145294
  const fs = yield* FileSystem;
145239
145295
  const pathService = yield* Path$1;
@@ -145294,7 +145350,7 @@ const run = fnUntraced(function* (options) {
145294
145350
  instructions
145295
145351
  });
145296
145352
  yield* log$1(`Agent exited with code: ${exitCode$1}`);
145297
- yield* agentReviewer({
145353
+ if (options.review) yield* agentReviewer({
145298
145354
  specsDirectory: options.specsDirectory,
145299
145355
  stallTimeout: options.stallTimeout,
145300
145356
  cliAgent,
@@ -145324,6 +145380,74 @@ const run = fnUntraced(function* (options) {
145324
145380
  if ((yield* prd.findById(taskId))?.autoMerge) yield* autoMerge;
145325
145381
  else yield* prd.maybeRevertIssue({ issueId: taskId });
145326
145382
  }, scoped$1, provide$1([PromptGen.layer, Prd.layer]));
145383
+ const iterations = integer("iterations").pipe(withDescription$1("Number of iterations to run, defaults to unlimited"), withAlias("i"), withDefault(Number.POSITIVE_INFINITY));
145384
+ const concurrency = integer("concurrency").pipe(withDescription$1("Number of concurrent agents, defaults to 1"), withAlias("c"), withDefault(1));
145385
+ const targetBranch = string$1("target-branch").pipe(withDescription$1("Target branch for PRs. Defaults to current branch. Env variable: LALPH_TARGET_BRANCH"), withAlias("b"), withFallbackConfig(string$4("LALPH_TARGET_BRANCH")), withDefault(make$21`git branch --show-current`.pipe(string, orDie$2, flatMap((output) => {
145386
+ const branch = output.trim();
145387
+ return branch === "" ? fail$4(new MissingOption({ option: "--target-branch" })) : succeed$1(branch);
145388
+ }))), optional);
145389
+ const maxIterationMinutes = integer("max-minutes").pipe(withDescription$1("Maximum number of minutes to allow an iteration to run. Defaults to 90 minutes. Env variable: LALPH_MAX_MINUTES"), withFallbackConfig(int("LALPH_MAX_MINUTES")), withDefault(90));
145390
+ const stallMinutes = integer("stall-minutes").pipe(withDescription$1("If no activity occurs for this many minutes, the iteration will be stopped. Defaults to 5 minutes. Env variable: LALPH_STALL_MINUTES"), withFallbackConfig(int("LALPH_STALL_MINUTES")), withDefault(5));
145391
+ const specsDirectory = directory("specs").pipe(withDescription$1("Directory to store plan specifications. Env variable: LALPH_SPECS"), withAlias("s"), withFallbackConfig(string$4("LALPH_SPECS")), withDefault(".specs"));
145392
+ const verbose = boolean("verbose").pipe(withDescription$1("Enable verbose logging"), withAlias("v"));
145393
+ const review = boolean("review").pipe(withDescription$1("Enabled the AI peer-review step"));
145394
+ const reset = boolean("reset").pipe(withDescription$1("Reset the current issue source before running"), withAlias("r"));
145395
+ const commandRoot = make$28("lalph", {
145396
+ iterations,
145397
+ concurrency,
145398
+ targetBranch,
145399
+ maxIterationMinutes,
145400
+ stallMinutes,
145401
+ reset,
145402
+ review,
145403
+ specsDirectory,
145404
+ verbose
145405
+ }).pipe(withHandler(fnUntraced(function* ({ iterations: iterations$1, concurrency: concurrency$1, targetBranch: targetBranch$1, maxIterationMinutes: maxIterationMinutes$1, stallMinutes: stallMinutes$1, specsDirectory: specsDirectory$1, review: review$1 }) {
145406
+ const source = yield* build(CurrentIssueSource.layer);
145407
+ const commandPrefix = yield* getCommandPrefix;
145408
+ yield* getOrSelectCliAgent;
145409
+ const isFinite$3 = Number.isFinite(iterations$1);
145410
+ const iterationsDisplay = isFinite$3 ? iterations$1 : "unlimited";
145411
+ const runConcurrency = Math.max(1, concurrency$1);
145412
+ const semaphore = makeSemaphoreUnsafe(runConcurrency);
145413
+ const fibers = yield* make$25();
145414
+ yield* resetInProgress.pipe(provide$1(source), withSpan("Main.resetInProgress"));
145415
+ yield* log$1(`Executing ${iterationsDisplay} iteration(s) with concurrency ${runConcurrency}`);
145416
+ if (isSome(targetBranch$1)) yield* log$1(`Using target branch: ${targetBranch$1.value}`);
145417
+ let iteration = 0;
145418
+ let quit = false;
145419
+ while (true) {
145420
+ yield* semaphore.take(1);
145421
+ if (quit || isFinite$3 && iteration >= iterations$1) break;
145422
+ const currentIteration = iteration;
145423
+ const startedDeferred = yield* make$49();
145424
+ yield* checkForWork.pipe(andThen(run({
145425
+ startedDeferred,
145426
+ targetBranch: targetBranch$1,
145427
+ specsDirectory: specsDirectory$1,
145428
+ stallTimeout: minutes(stallMinutes$1),
145429
+ runTimeout: minutes(maxIterationMinutes$1),
145430
+ commandPrefix,
145431
+ review: review$1
145432
+ })), catchFilter((e) => e._tag === "NoMoreWork" || e._tag === "QuitError" ? fail$8(e) : e, (e) => logWarning(fail$5(e))), catchTags({
145433
+ NoMoreWork(_) {
145434
+ if (isFinite$3) {
145435
+ iterations$1 = currentIteration;
145436
+ return log$1(`No more work to process, ending after ${currentIteration} iteration(s).`);
145437
+ }
145438
+ const log$2 = size$1(fibers) <= 1 ? log$1("No more work to process, waiting 30 seconds...") : void_$1;
145439
+ return andThen(log$2, sleep(seconds(30)));
145440
+ },
145441
+ QuitError(_) {
145442
+ quit = true;
145443
+ return void_$1;
145444
+ }
145445
+ }), annotateLogs({ iteration: currentIteration }), ensuring(semaphore.release(1)), ensuring(completeWith(startedDeferred, void_$1)), provide$1(source), run$1(fibers));
145446
+ yield* _await(startedDeferred);
145447
+ iteration++;
145448
+ }
145449
+ yield* awaitEmpty(fibers);
145450
+ }, scoped$1)));
145327
145451
 
145328
145452
  //#endregion
145329
145453
  //#region src/commands/plan.ts
@@ -145455,7 +145579,7 @@ const commandSource = make$28("source").pipe(withDescription("Select the issue s
145455
145579
 
145456
145580
  //#endregion
145457
145581
  //#region package.json
145458
- var version = "0.1.96";
145582
+ var version = "0.1.98";
145459
145583
 
145460
145584
  //#endregion
145461
145585
  //#region src/Tracing.ts
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "lalph",
3
3
  "type": "module",
4
- "version": "0.1.96",
4
+ "version": "0.1.98",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
@@ -5,6 +5,7 @@ import { ChildProcess } from "effect/unstable/process"
5
5
  import { Worktree } from "../Worktree.ts"
6
6
  import { RunnerStalled } from "../domain/Errors.ts"
7
7
  import type { CliAgent } from "../domain/CliAgent.ts"
8
+ import { makeWaitForFile } from "../shared/fs.ts"
8
9
 
9
10
  export const agentChooser = Effect.fnUntraced(function* (options: {
10
11
  readonly stallTimeout: Duration.Duration
@@ -18,6 +19,12 @@ export const agentChooser = Effect.fnUntraced(function* (options: {
18
19
  const worktree = yield* Worktree
19
20
  const promptGen = yield* PromptGen
20
21
  const prd = yield* Prd
22
+ const waitForFile = yield* makeWaitForFile
23
+
24
+ const taskJsonCreated = waitForFile(
25
+ pathService.join(worktree.directory, ".lalph"),
26
+ "task.json",
27
+ )
21
28
 
22
29
  yield* pipe(
23
30
  options.cliAgent.resolveCommandChoose({
@@ -31,6 +38,7 @@ export const agentChooser = Effect.fnUntraced(function* (options: {
31
38
  duration: options.stallTimeout,
32
39
  onTimeout: () => Effect.fail(new RunnerStalled()),
33
40
  }),
41
+ Effect.raceFirst(taskJsonCreated),
34
42
  )
35
43
 
36
44
  const taskJson = yield* fs.readFileString(
@@ -4,6 +4,7 @@ import { ChildProcess } from "effect/unstable/process"
4
4
  import { Worktree } from "../Worktree.ts"
5
5
  import type { CliAgent } from "../domain/CliAgent.ts"
6
6
  import type { PrdIssue } from "../domain/PrdIssue.ts"
7
+ import { makeWaitForFile } from "../shared/fs.ts"
7
8
 
8
9
  export const agentInstructor = Effect.fnUntraced(function* (options: {
9
10
  readonly targetBranch: Option.Option<string>
@@ -20,6 +21,7 @@ export const agentInstructor = Effect.fnUntraced(function* (options: {
20
21
  const pathService = yield* Path.Path
21
22
  const worktree = yield* Worktree
22
23
  const promptGen = yield* PromptGen
24
+ const waitForFile = yield* makeWaitForFile
23
25
 
24
26
  yield* pipe(
25
27
  options.cliAgent.command({
@@ -38,6 +40,12 @@ export const agentInstructor = Effect.fnUntraced(function* (options: {
38
40
  cliAgent: options.cliAgent,
39
41
  stallTimeout: options.stallTimeout,
40
42
  }),
43
+ Effect.raceFirst(
44
+ waitForFile(
45
+ pathService.join(worktree.directory, ".lalph"),
46
+ "instructions.md",
47
+ ),
48
+ ),
41
49
  )
42
50
  return yield* fs.readFileString(
43
51
  pathService.join(worktree.directory, ".lalph", "instructions.md"),
@@ -1,7 +1,7 @@
1
- import { Schema, Stream } from "effect"
2
1
  import type { OutputTransformer } from "../domain/CliAgent.ts"
3
- import { streamFilterJson } from "../shared/stream.ts"
2
+ import { Option, pipe, Schema, Stream } from "effect"
4
3
  import { ansiColors } from "../shared/ansi-colors.ts"
4
+ import { streamFilterJson } from "../shared/stream.ts"
5
5
 
6
6
  export const claudeOutputTransformer: OutputTransformer = (stream) =>
7
7
  stream.pipe(
@@ -9,12 +9,20 @@ export const claudeOutputTransformer: OutputTransformer = (stream) =>
9
9
  Stream.map((m) => m.format()),
10
10
  )
11
11
 
12
- // Schema definitions
13
-
14
12
  const ContentBlock = Schema.Struct({
15
13
  type: Schema.String,
16
14
  text: Schema.optional(Schema.String),
17
15
  name: Schema.optional(Schema.String),
16
+ input: Schema.optional(Schema.Unknown),
17
+ content: Schema.optional(Schema.String),
18
+ is_error: Schema.optional(Schema.Boolean),
19
+ })
20
+
21
+ const ToolUseResult = Schema.Struct({
22
+ stdout: Schema.optional(Schema.String),
23
+ stderr: Schema.optional(Schema.String),
24
+ interrupted: Schema.optional(Schema.Boolean),
25
+ isImage: Schema.optional(Schema.Boolean),
18
26
  })
19
27
 
20
28
  class StreamJsonMessage extends Schema.Class<StreamJsonMessage>(
@@ -27,17 +35,18 @@ class StreamJsonMessage extends Schema.Class<StreamJsonMessage>(
27
35
  content: Schema.optional(Schema.Array(ContentBlock)),
28
36
  }),
29
37
  ),
38
+ tool_use_result: Schema.optional(ToolUseResult),
30
39
  duration_ms: Schema.optional(Schema.Number),
31
40
  total_cost_usd: Schema.optional(Schema.Number),
32
41
  }) {
33
42
  format(): string {
34
43
  switch (this.type) {
35
44
  case "system":
36
- return this.subtype === "init"
37
- ? ansiColors.dim + "[Session started]" + ansiColors.reset + "\n"
38
- : ""
45
+ return this.subtype === "init" ? dim("[Session started]") + "\n" : ""
39
46
  case "assistant":
40
47
  return formatAssistantMessage(this)
48
+ case "user":
49
+ return formatToolResult(this)
41
50
  case "result":
42
51
  return formatResult(this)
43
52
  default:
@@ -46,32 +55,179 @@ class StreamJsonMessage extends Schema.Class<StreamJsonMessage>(
46
55
  }
47
56
  }
48
57
 
49
- const formatToolName = (name: string): string =>
58
+ const BashInput = Schema.Struct({ command: Schema.optional(Schema.String) })
59
+ const FileInput = Schema.Struct({ file_path: Schema.optional(Schema.String) })
60
+ const PatternInput = Schema.Struct({ pattern: Schema.optional(Schema.String) })
61
+ const QuestionOption = Schema.Struct({
62
+ label: Schema.String,
63
+ description: Schema.optional(Schema.String),
64
+ })
65
+ const Question = Schema.Struct({
66
+ question: Schema.String,
67
+ header: Schema.optional(Schema.String),
68
+ options: Schema.optional(Schema.Array(QuestionOption)),
69
+ })
70
+ const AskUserQuestionInput = Schema.Struct({
71
+ questions: Schema.optional(Schema.Array(Question)),
72
+ })
73
+
74
+ const McpInputFields = [
75
+ "query",
76
+ "documentId",
77
+ "page",
78
+ "pattern",
79
+ "relative_path",
80
+ "name_path",
81
+ "file_path",
82
+ "root_path",
83
+ ] as const
84
+
85
+ const truncate = (s: string, max: number) =>
86
+ s.length > max ? s.slice(0, max) + "..." : s
87
+
88
+ const dim = (s: string) => ansiColors.dim + s + ansiColors.reset
89
+ const cyan = (s: string) => ansiColors.cyan + s + ansiColors.reset
90
+ const yellow = (s: string) => ansiColors.yellow + s + ansiColors.reset
91
+ const green = (s: string) => ansiColors.green + s + ansiColors.reset
92
+
93
+ const formatToolName = (name: string) =>
50
94
  name.replace("mcp__", "").replace(/__/g, ":")
51
95
 
96
+ const withDetail = (display: string, detail: Option.Option<string>) =>
97
+ display + Option.getOrElse(detail, () => "")
98
+
99
+ const formatBashInput = (input: unknown) =>
100
+ pipe(
101
+ Schema.decodeUnknownOption(BashInput)(input),
102
+ Option.flatMap((data) => Option.fromNullishOr(data.command)),
103
+ Option.filter((cmd) => cmd.length > 0),
104
+ Option.map((cmd) => dim("$ " + truncate(cmd, 100)) + "\n"),
105
+ )
106
+
107
+ const formatFileInput = (input: unknown) =>
108
+ pipe(
109
+ Schema.decodeUnknownOption(FileInput)(input),
110
+ Option.flatMap((data) => Option.fromNullishOr(data.file_path)),
111
+ Option.filter((path) => path.length > 0),
112
+ Option.map((path) => dim(path) + "\n"),
113
+ )
114
+
115
+ const formatPatternInput = (input: unknown) =>
116
+ pipe(
117
+ Schema.decodeUnknownOption(PatternInput)(input),
118
+ Option.flatMap((data) => Option.fromNullishOr(data.pattern)),
119
+ Option.filter((pattern) => pattern.length > 0),
120
+ Option.map((pattern) => dim(pattern) + "\n"),
121
+ )
122
+
123
+ const formatMcpInput = (input: unknown): Option.Option<string> => {
124
+ if (typeof input !== "object" || input === null) return Option.none()
125
+ const data = input as Record<string, unknown>
126
+ const parts = McpInputFields.flatMap((field) =>
127
+ Option.match(Option.fromNullishOr(data[field]), {
128
+ onNone: () => [],
129
+ onSome: (value) => [`${field}=${truncate(String(value), 50)}`],
130
+ }),
131
+ )
132
+ return parts.length > 0
133
+ ? Option.some(dim(parts.join(" ")) + "\n")
134
+ : Option.none()
135
+ }
136
+
137
+ const formatGenericInput = (input: unknown) =>
138
+ pipe(
139
+ Option.fromNullishOr(input),
140
+ Option.map((v) => dim(truncate(JSON.stringify(v), 100)) + "\n"),
141
+ )
142
+
143
+ type DecodedQuestion = typeof Question.Encoded
144
+
145
+ const formatUserQuestion = (input: unknown) =>
146
+ pipe(
147
+ Schema.decodeUnknownOption(AskUserQuestionInput)(input),
148
+ Option.flatMap((data) => Option.fromNullishOr(data.questions)),
149
+ Option.map((questions) =>
150
+ questions
151
+ .map((q: DecodedQuestion) => {
152
+ let result = "\n" + yellow("⚠ WAITING FOR INPUT") + "\n"
153
+ result += cyan((q.header ? `[${q.header}] ` : "") + q.question) + "\n"
154
+ if (q.options) {
155
+ result +=
156
+ q.options
157
+ .map(
158
+ (opt, i) =>
159
+ ` ${i + 1}. ${opt.label}${opt.description ? dim(` - ${opt.description}`) : ""}`,
160
+ )
161
+ .join("\n") + "\n"
162
+ }
163
+ return result
164
+ })
165
+ .join("\n"),
166
+ ),
167
+ Option.getOrElse(() => ""),
168
+ )
169
+
170
+ const formatToolInput = (name: string, input: unknown): string => {
171
+ const display = "\n" + cyan("▶ " + formatToolName(name)) + "\n"
172
+
173
+ if (name === "Bash") return withDetail(display, formatBashInput(input))
174
+ if (name === "AskUserQuestion") return display + formatUserQuestion(input)
175
+ if (name === "Read" || name === "Write" || name === "Edit")
176
+ return withDetail(display, formatFileInput(input))
177
+ if (name === "Grep" || name === "Glob")
178
+ return withDetail(display, formatPatternInput(input))
179
+ if (name.startsWith("mcp__"))
180
+ return withDetail(display, formatMcpInput(input))
181
+ return withDetail(display, formatGenericInput(input))
182
+ }
183
+
52
184
  const formatAssistantMessage = (msg: StreamJsonMessage): string => {
53
185
  const content = msg.message?.content
54
186
  if (!content) return ""
55
-
56
187
  return content
57
188
  .map((block) => {
58
- if (block.type === "text" && block.text) {
59
- return block.text
60
- } else if (block.type === "tool_use" && block.name) {
61
- return (
62
- "\n" +
63
- ansiColors.cyan +
64
- "▶ " +
65
- formatToolName(block.name) +
66
- ansiColors.reset +
67
- "\n"
68
- )
69
- }
189
+ if (block.type === "text" && block.text) return block.text
190
+ if (block.type === "tool_use" && block.name)
191
+ return formatToolInput(block.name, block.input)
70
192
  return ""
71
193
  })
72
194
  .join("")
73
195
  }
74
196
 
197
+ const formatLongOutput = (text: string): string => {
198
+ const lines = text.trim().split("\n")
199
+ if (lines.length > 8) {
200
+ const preview = [
201
+ ...lines.slice(0, 4),
202
+ `... (${lines.length - 7} more lines)`,
203
+ ...lines.slice(-3),
204
+ ].join("\n")
205
+ return dim(preview) + "\n"
206
+ }
207
+ return text.length > 500 ? dim(truncate(text, 500)) + "\n" : dim(text) + "\n"
208
+ }
209
+
210
+ const formatToolResult = (msg: StreamJsonMessage): string => {
211
+ let output = ""
212
+ const result = msg.tool_use_result
213
+ if (result) {
214
+ if (result.stderr?.trim())
215
+ output += yellow("stderr: ") + truncate(result.stderr.trim(), 500) + "\n"
216
+ if (result.interrupted) output += yellow("[interrupted]") + "\n"
217
+ if (result.stdout?.trim()) output += formatLongOutput(result.stdout.trim())
218
+ }
219
+ const content = msg.message?.content
220
+ if (content) {
221
+ for (const block of content) {
222
+ if (block.type === "tool_result") {
223
+ if (block.is_error) output += yellow("✗ Tool error") + "\n"
224
+ if (block.content) output += formatLongOutput(block.content)
225
+ }
226
+ }
227
+ }
228
+ return output
229
+ }
230
+
75
231
  const formatResult = (msg: StreamJsonMessage): string => {
76
232
  if (msg.subtype === "success") {
77
233
  const duration = msg.duration_ms
@@ -79,19 +235,8 @@ const formatResult = (msg: StreamJsonMessage): string => {
79
235
  : ""
80
236
  const cost = msg.total_cost_usd ? "$" + msg.total_cost_usd.toFixed(4) : ""
81
237
  const info = [duration, cost].filter(Boolean).join(" | ")
82
- return (
83
- "\n" +
84
- ansiColors.green +
85
- "✓ Done" +
86
- ansiColors.reset +
87
- " " +
88
- ansiColors.dim +
89
- info +
90
- ansiColors.reset +
91
- "\n"
92
- )
93
- } else if (msg.subtype === "error") {
94
- return "\n" + ansiColors.yellow + "✗ Error" + ansiColors.reset + "\n"
238
+ return "\n" + green("✓ Done") + " " + dim(info) + "\n"
95
239
  }
240
+ if (msg.subtype === "error") return "\n" + yellow("✗ Error") + "\n"
96
241
  return ""
97
242
  }
@@ -28,6 +28,174 @@ import { RunnerStalled } from "../domain/Errors.ts"
28
28
  import { agentReviewer } from "../Agents/reviewer.ts"
29
29
  import { agentTimeout } from "../Agents/timeout.ts"
30
30
 
31
+ // Main iteration run logic
32
+
33
+ const run = Effect.fnUntraced(
34
+ function* (options: {
35
+ readonly startedDeferred: Deferred.Deferred<void>
36
+ readonly targetBranch: Option.Option<string>
37
+ readonly specsDirectory: string
38
+ readonly stallTimeout: Duration.Duration
39
+ readonly runTimeout: Duration.Duration
40
+ readonly commandPrefix: (
41
+ command: ChildProcess.Command,
42
+ ) => ChildProcess.Command
43
+ readonly review: boolean
44
+ }) {
45
+ const fs = yield* FileSystem.FileSystem
46
+ const pathService = yield* Path.Path
47
+ const worktree = yield* Worktree
48
+ const gh = yield* GithubCli
49
+ const cliAgent = yield* getOrSelectCliAgent
50
+ const prd = yield* Prd
51
+ const source = yield* IssueSource
52
+
53
+ if (Option.isSome(options.targetBranch)) {
54
+ const targetWithRemote = options.targetBranch.value.includes("/")
55
+ ? options.targetBranch.value
56
+ : `origin/${options.targetBranch.value}`
57
+ yield* worktree.exec`git checkout ${targetWithRemote}`
58
+ }
59
+
60
+ // ensure cleanup of branch after run
61
+ yield* Effect.addFinalizer(
62
+ Effect.fnUntraced(function* () {
63
+ const currentBranchName = yield* worktree
64
+ .currentBranch(worktree.directory)
65
+ .pipe(Effect.option, Effect.map(Option.getOrUndefined))
66
+ if (!currentBranchName) return
67
+
68
+ // enter detached state
69
+ yield* worktree.exec`git checkout --detach ${currentBranchName}`
70
+ // delete the branch
71
+ yield* worktree.exec`git branch -D ${currentBranchName}`
72
+ }, Effect.ignore),
73
+ )
74
+
75
+ let taskId: string | undefined = undefined
76
+
77
+ // setup finalizer to revert issue if we fail
78
+ yield* Effect.addFinalizer(
79
+ Effect.fnUntraced(function* (exit) {
80
+ if (exit._tag === "Success") return
81
+ const prd = yield* Prd
82
+ if (taskId) {
83
+ yield* prd.maybeRevertIssue({
84
+ issueId: taskId,
85
+ })
86
+ } else {
87
+ yield* prd.revertUpdatedIssues
88
+ }
89
+ }, Effect.ignore),
90
+ )
91
+
92
+ // 1. Choose task
93
+ const chosenTask = yield* agentChooser({
94
+ stallTimeout: options.stallTimeout,
95
+ commandPrefix: options.commandPrefix,
96
+ cliAgent,
97
+ })
98
+ taskId = chosenTask.id
99
+ yield* prd.setChosenIssueId(taskId)
100
+
101
+ yield* source.ensureInProgress(taskId).pipe(
102
+ Effect.timeoutOrElse({
103
+ duration: "1 minute",
104
+ onTimeout: () => Effect.fail(new RunnerStalled()),
105
+ }),
106
+ )
107
+
108
+ yield* Deferred.completeWith(options.startedDeferred, Effect.void)
109
+
110
+ if (chosenTask.githubPrNumber) {
111
+ yield* worktree.exec`gh pr checkout ${chosenTask.githubPrNumber}`
112
+ const feedback = yield* gh.prFeedbackMd(chosenTask.githubPrNumber)
113
+ yield* fs.writeFileString(
114
+ pathService.join(worktree.directory, ".lalph", "feedback.md"),
115
+ feedback,
116
+ )
117
+ }
118
+
119
+ // 2. Generate instructions
120
+ const instructions = yield* agentInstructor({
121
+ stallTimeout: options.stallTimeout,
122
+ commandPrefix: options.commandPrefix,
123
+ specsDirectory: options.specsDirectory,
124
+ targetBranch: options.targetBranch,
125
+ task: chosenTask.prd,
126
+ cliAgent,
127
+ githubPrNumber: chosenTask.githubPrNumber ?? undefined,
128
+ })
129
+
130
+ yield* Effect.gen(function* () {
131
+ // 3. Work on task
132
+ const exitCode = yield* agentWorker({
133
+ specsDirectory: options.specsDirectory,
134
+ stallTimeout: options.stallTimeout,
135
+ cliAgent,
136
+ commandPrefix: options.commandPrefix,
137
+ instructions,
138
+ })
139
+ yield* Effect.log(`Agent exited with code: ${exitCode}`)
140
+
141
+ // 4. Review task
142
+ if (options.review) {
143
+ yield* agentReviewer({
144
+ specsDirectory: options.specsDirectory,
145
+ stallTimeout: options.stallTimeout,
146
+ cliAgent,
147
+ commandPrefix: options.commandPrefix,
148
+ instructions,
149
+ })
150
+ }
151
+ }).pipe(
152
+ Effect.timeout(options.runTimeout),
153
+ Effect.tapErrorTag("TimeoutError", () =>
154
+ agentTimeout({
155
+ specsDirectory: options.specsDirectory,
156
+ stallTimeout: options.stallTimeout,
157
+ cliAgent,
158
+ commandPrefix: options.commandPrefix,
159
+ task: chosenTask.prd,
160
+ }),
161
+ ),
162
+ )
163
+
164
+ // Auto-merge logic
165
+
166
+ const autoMerge = Effect.gen(function* () {
167
+ let prState = yield* worktree.viewPrState()
168
+ yield* Effect.log("PR state", prState)
169
+ if (Option.isNone(prState)) {
170
+ return yield* prd.maybeRevertIssue({ issueId: taskId })
171
+ }
172
+ if (Option.isSome(options.targetBranch)) {
173
+ yield* worktree.exec`gh pr edit --base ${options.targetBranch.value}`
174
+ }
175
+ yield* worktree.exec`gh pr merge -sd`
176
+ yield* Effect.sleep(Duration.seconds(3))
177
+ prState = yield* worktree.viewPrState(prState.value.number)
178
+ yield* Effect.log("PR state after merge", prState)
179
+ if (Option.isSome(prState) && prState.value.state === "MERGED") {
180
+ return
181
+ }
182
+ yield* Effect.log("Flagging unmergable PR")
183
+ yield* prd.flagUnmergable({ issueId: taskId })
184
+ }).pipe(Effect.annotateLogs({ phase: "autoMerge" }))
185
+
186
+ const task = yield* prd.findById(taskId)
187
+ if (task?.autoMerge) {
188
+ yield* autoMerge
189
+ } else {
190
+ yield* prd.maybeRevertIssue({ issueId: taskId })
191
+ }
192
+ },
193
+ Effect.scoped,
194
+ Effect.provide([PromptGen.layer, Prd.layer]),
195
+ )
196
+
197
+ // Command
198
+
31
199
  const iterations = Flag.integer("iterations").pipe(
32
200
  Flag.withDescription("Number of iterations to run, defaults to unlimited"),
33
201
  Flag.withAlias("i"),
@@ -95,6 +263,10 @@ const verbose = Flag.boolean("verbose").pipe(
95
263
  Flag.withAlias("v"),
96
264
  )
97
265
 
266
+ const review = Flag.boolean("review").pipe(
267
+ Flag.withDescription("Enabled the AI peer-review step"),
268
+ )
269
+
98
270
  // handled in cli.ts
99
271
  const reset = Flag.boolean("reset").pipe(
100
272
  Flag.withDescription("Reset the current issue source before running"),
@@ -108,6 +280,7 @@ export const commandRoot = Command.make("lalph", {
108
280
  maxIterationMinutes,
109
281
  stallMinutes,
110
282
  reset,
283
+ review,
111
284
  specsDirectory,
112
285
  verbose,
113
286
  }).pipe(
@@ -119,6 +292,7 @@ export const commandRoot = Command.make("lalph", {
119
292
  maxIterationMinutes,
120
293
  stallMinutes,
121
294
  specsDirectory,
295
+ review,
122
296
  }) {
123
297
  const source = yield* Layer.build(CurrentIssueSource.layer)
124
298
  const commandPrefix = yield* getCommandPrefix
@@ -164,6 +338,7 @@ export const commandRoot = Command.make("lalph", {
164
338
  stallTimeout: Duration.minutes(stallMinutes),
165
339
  runTimeout: Duration.minutes(maxIterationMinutes),
166
340
  commandPrefix,
341
+ review,
167
342
  }),
168
343
  ),
169
344
  Effect.catchFilter(
@@ -212,164 +387,3 @@ export const commandRoot = Command.make("lalph", {
212
387
  }, Effect.scoped),
213
388
  ),
214
389
  )
215
-
216
- const run = Effect.fnUntraced(
217
- function* (options: {
218
- readonly startedDeferred: Deferred.Deferred<void>
219
- readonly targetBranch: Option.Option<string>
220
- readonly specsDirectory: string
221
- readonly stallTimeout: Duration.Duration
222
- readonly runTimeout: Duration.Duration
223
- readonly commandPrefix: (
224
- command: ChildProcess.Command,
225
- ) => ChildProcess.Command
226
- }) {
227
- const fs = yield* FileSystem.FileSystem
228
- const pathService = yield* Path.Path
229
- const worktree = yield* Worktree
230
- const gh = yield* GithubCli
231
- const cliAgent = yield* getOrSelectCliAgent
232
- const prd = yield* Prd
233
- const source = yield* IssueSource
234
-
235
- if (Option.isSome(options.targetBranch)) {
236
- const targetWithRemote = options.targetBranch.value.includes("/")
237
- ? options.targetBranch.value
238
- : `origin/${options.targetBranch.value}`
239
- yield* worktree.exec`git checkout ${targetWithRemote}`
240
- }
241
-
242
- // ensure cleanup of branch after run
243
- yield* Effect.addFinalizer(
244
- Effect.fnUntraced(function* () {
245
- const currentBranchName = yield* worktree
246
- .currentBranch(worktree.directory)
247
- .pipe(Effect.option, Effect.map(Option.getOrUndefined))
248
- if (!currentBranchName) return
249
-
250
- // enter detached state
251
- yield* worktree.exec`git checkout --detach ${currentBranchName}`
252
- // delete the branch
253
- yield* worktree.exec`git branch -D ${currentBranchName}`
254
- }, Effect.ignore),
255
- )
256
-
257
- let taskId: string | undefined = undefined
258
-
259
- // setup finalizer to revert issue if we fail
260
- yield* Effect.addFinalizer(
261
- Effect.fnUntraced(function* (exit) {
262
- if (exit._tag === "Success") return
263
- const prd = yield* Prd
264
- if (taskId) {
265
- yield* prd.maybeRevertIssue({
266
- issueId: taskId,
267
- })
268
- } else {
269
- yield* prd.revertUpdatedIssues
270
- }
271
- }, Effect.ignore),
272
- )
273
-
274
- // 1. Choose task
275
- const chosenTask = yield* agentChooser({
276
- stallTimeout: options.stallTimeout,
277
- commandPrefix: options.commandPrefix,
278
- cliAgent,
279
- })
280
- taskId = chosenTask.id
281
- yield* prd.setChosenIssueId(taskId)
282
-
283
- yield* source.ensureInProgress(taskId).pipe(
284
- Effect.timeoutOrElse({
285
- duration: "1 minute",
286
- onTimeout: () => Effect.fail(new RunnerStalled()),
287
- }),
288
- )
289
-
290
- yield* Deferred.completeWith(options.startedDeferred, Effect.void)
291
-
292
- if (chosenTask.githubPrNumber) {
293
- yield* worktree.exec`gh pr checkout ${chosenTask.githubPrNumber}`
294
- const feedback = yield* gh.prFeedbackMd(chosenTask.githubPrNumber)
295
- yield* fs.writeFileString(
296
- pathService.join(worktree.directory, ".lalph", "feedback.md"),
297
- feedback,
298
- )
299
- }
300
-
301
- // 2. Generate instructions
302
- const instructions = yield* agentInstructor({
303
- stallTimeout: options.stallTimeout,
304
- commandPrefix: options.commandPrefix,
305
- specsDirectory: options.specsDirectory,
306
- targetBranch: options.targetBranch,
307
- task: chosenTask.prd,
308
- cliAgent,
309
- githubPrNumber: chosenTask.githubPrNumber ?? undefined,
310
- })
311
-
312
- yield* Effect.gen(function* () {
313
- // 3. Work on task
314
- const exitCode = yield* agentWorker({
315
- specsDirectory: options.specsDirectory,
316
- stallTimeout: options.stallTimeout,
317
- cliAgent,
318
- commandPrefix: options.commandPrefix,
319
- instructions,
320
- })
321
- yield* Effect.log(`Agent exited with code: ${exitCode}`)
322
-
323
- // 4. Review task
324
- yield* agentReviewer({
325
- specsDirectory: options.specsDirectory,
326
- stallTimeout: options.stallTimeout,
327
- cliAgent,
328
- commandPrefix: options.commandPrefix,
329
- instructions,
330
- })
331
- }).pipe(
332
- Effect.timeout(options.runTimeout),
333
- Effect.tapErrorTag("TimeoutError", () =>
334
- agentTimeout({
335
- specsDirectory: options.specsDirectory,
336
- stallTimeout: options.stallTimeout,
337
- cliAgent,
338
- commandPrefix: options.commandPrefix,
339
- task: chosenTask.prd,
340
- }),
341
- ),
342
- )
343
-
344
- // Auto-merge logic
345
-
346
- const autoMerge = Effect.gen(function* () {
347
- let prState = yield* worktree.viewPrState()
348
- yield* Effect.log("PR state", prState)
349
- if (Option.isNone(prState)) {
350
- return yield* prd.maybeRevertIssue({ issueId: taskId })
351
- }
352
- if (Option.isSome(options.targetBranch)) {
353
- yield* worktree.exec`gh pr edit --base ${options.targetBranch.value}`
354
- }
355
- yield* worktree.exec`gh pr merge -sd`
356
- yield* Effect.sleep(Duration.seconds(3))
357
- prState = yield* worktree.viewPrState(prState.value.number)
358
- yield* Effect.log("PR state after merge", prState)
359
- if (Option.isSome(prState) && prState.value.state === "MERGED") {
360
- return
361
- }
362
- yield* Effect.log("Flagging unmergable PR")
363
- yield* prd.flagUnmergable({ issueId: taskId })
364
- }).pipe(Effect.annotateLogs({ phase: "autoMerge" }))
365
-
366
- const task = yield* prd.findById(taskId)
367
- if (task?.autoMerge) {
368
- yield* autoMerge
369
- } else {
370
- yield* prd.maybeRevertIssue({ issueId: taskId })
371
- }
372
- },
373
- Effect.scoped,
374
- Effect.provide([PromptGen.layer, Prd.layer]),
375
- )
@@ -77,7 +77,7 @@ const claude = new CliAgent({
77
77
  stdout: outputMode,
78
78
  stderr: outputMode,
79
79
  stdin: "inherit",
80
- })`claude --dangerously-skip-permissions --output-format stream-json --verbose -p ${`@${prdFilePath}
80
+ })`claude --dangerously-skip-permissions --output-format stream-json --verbose --disallowed-tools AskUserQuestion -p ${`@${prdFilePath}
81
81
 
82
82
  ${prompt}`}`,
83
83
  outputTransformer: claudeOutputTransformer,
@@ -0,0 +1,12 @@
1
+ import { Effect, FileSystem, Path, pipe, Stream } from "effect"
2
+
3
+ export const makeWaitForFile = Effect.gen(function* () {
4
+ const fs = yield* FileSystem.FileSystem
5
+ const pathService = yield* Path.Path
6
+ return (directory: string, name: string) =>
7
+ pipe(
8
+ fs.watch(directory),
9
+ Stream.filter((e) => pathService.basename(e.path) === name),
10
+ Stream.runHead,
11
+ )
12
+ })