ralphctl 0.7.0 → 0.7.1

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
@@ -2253,30 +2253,6 @@ var runSignalParsers = (text, timestamp, parsers = DEFAULT_SIGNAL_PARSERS) => {
2253
2253
  var parseHarnessSignals = (text, timestamp) => runSignalParsers(text, timestamp);
2254
2254
 
2255
2255
  // src/integration/ai/providers/claude/parse-stream.ts
2256
- var parseClaudeJsonEnvelope = (stdout) => {
2257
- const trimmed = stdout.trim();
2258
- if (trimmed.length === 0) {
2259
- return { body: "", sessionId: void 0, model: void 0 };
2260
- }
2261
- let parsed;
2262
- try {
2263
- parsed = JSON.parse(trimmed);
2264
- } catch {
2265
- return { body: stdout, sessionId: void 0, model: void 0 };
2266
- }
2267
- if (typeof parsed !== "object" || parsed === null) {
2268
- return { body: stdout, sessionId: void 0, model: void 0 };
2269
- }
2270
- const obj = parsed;
2271
- const result = stringField(obj, "result");
2272
- const sessionId2 = stringField(obj, "session_id", "sessionId");
2273
- const model = stringField(obj, "model");
2274
- return {
2275
- body: result ?? stdout,
2276
- sessionId: sessionId2,
2277
- model
2278
- };
2279
- };
2280
2256
  var stringField = (obj, ...names) => {
2281
2257
  for (const name of names) {
2282
2258
  const v = obj[name];
@@ -2284,6 +2260,63 @@ var stringField = (obj, ...names) => {
2284
2260
  }
2285
2261
  return void 0;
2286
2262
  };
2263
+ var createClaudeStreamParser = () => {
2264
+ let buffer = "";
2265
+ let body = "";
2266
+ let sessionId2;
2267
+ let model;
2268
+ const emit = (raw, onLine) => {
2269
+ if (raw.length === 0) return;
2270
+ if (raw.startsWith("{") && raw.endsWith("}")) {
2271
+ try {
2272
+ const json = JSON.parse(raw);
2273
+ onLine({ raw, json });
2274
+ return;
2275
+ } catch {
2276
+ }
2277
+ }
2278
+ onLine({ raw });
2279
+ };
2280
+ const ingest = (line) => {
2281
+ const json = line.json;
2282
+ if (json === void 0) return;
2283
+ if (sessionId2 === void 0) {
2284
+ const seen = stringField(json, "session_id", "sessionId");
2285
+ if (seen !== void 0) sessionId2 = seen;
2286
+ }
2287
+ const type = stringField(json, "type");
2288
+ if (type === "system" && model === void 0) {
2289
+ const m = stringField(json, "model");
2290
+ if (m !== void 0) model = m;
2291
+ }
2292
+ if (type === "result") {
2293
+ const r = stringField(json, "result");
2294
+ if (r !== void 0) body = r;
2295
+ }
2296
+ };
2297
+ return {
2298
+ feed(chunk, onLine) {
2299
+ buffer += chunk;
2300
+ let nl = buffer.indexOf("\n");
2301
+ while (nl !== -1) {
2302
+ const line = buffer.slice(0, nl);
2303
+ buffer = buffer.slice(nl + 1);
2304
+ emit(line, onLine);
2305
+ nl = buffer.indexOf("\n");
2306
+ }
2307
+ },
2308
+ flush(onLine) {
2309
+ if (buffer.length > 0) {
2310
+ emit(buffer, onLine);
2311
+ buffer = "";
2312
+ }
2313
+ },
2314
+ ingest,
2315
+ snapshot() {
2316
+ return { body, sessionId: sessionId2, model };
2317
+ }
2318
+ };
2319
+ };
2287
2320
 
2288
2321
  // src/integration/ai/providers/_engine/idle-watchdog.ts
2289
2322
  var DEFAULT_IDLE_MS = 5 * 6e4;
@@ -2428,7 +2461,7 @@ var buildClaudeArgs = (session) => {
2428
2461
  })
2429
2462
  );
2430
2463
  }
2431
- const args = ["-p", "--output-format", "json", "--model", session.model];
2464
+ const args = ["-p", "--verbose", "--output-format", "stream-json", "--model", session.model];
2432
2465
  args.push("--permission-mode", "bypassPermissions");
2433
2466
  const denied = disallowedToolsFor(session.permissions);
2434
2467
  if (denied.length > 0) {
@@ -2505,13 +2538,14 @@ var spawnAttempt = async (input) => {
2505
2538
  const child = spawnFn(command, args, {
2506
2539
  stdio: ["pipe", "pipe", "pipe"]
2507
2540
  });
2508
- let stdoutBuf = "";
2541
+ const parser = createClaudeStreamParser();
2509
2542
  let stderrBuf = "";
2543
+ const onLine = (line) => {
2544
+ parser.ingest(line);
2545
+ };
2510
2546
  const { code, signal } = await runHeadlessSpawn({
2511
2547
  child,
2512
- onStdout: (chunk) => {
2513
- stdoutBuf += chunk;
2514
- },
2548
+ onStdout: (chunk) => parser.feed(chunk, onLine),
2515
2549
  onStderr: (chunk) => {
2516
2550
  stderrBuf += chunk;
2517
2551
  },
@@ -2530,6 +2564,7 @@ var spawnAttempt = async (input) => {
2530
2564
  });
2531
2565
  }
2532
2566
  });
2567
+ parser.flush(onLine);
2533
2568
  if (signal === "SIGTERM") {
2534
2569
  return {
2535
2570
  kind: "error",
@@ -2541,9 +2576,8 @@ var spawnAttempt = async (input) => {
2541
2576
  })
2542
2577
  };
2543
2578
  }
2579
+ const envelope = parser.snapshot();
2544
2580
  if (code === 0) {
2545
- const envelope = parseClaudeJsonEnvelope(stdoutBuf);
2546
- stdoutBuf = "";
2547
2581
  if (envelope.sessionId !== void 0) {
2548
2582
  deps.eventBus.publish({
2549
2583
  type: "log",
@@ -2578,13 +2612,12 @@ var spawnAttempt = async (input) => {
2578
2612
  };
2579
2613
  }
2580
2614
  if (RATE_LIMIT_RE.test(stderrBuf)) {
2581
- const sessionIdOnFailure = parseClaudeJsonEnvelope(stdoutBuf).sessionId;
2582
2615
  return {
2583
2616
  kind: "rate-limit",
2584
2617
  error: new RateLimitError({
2585
2618
  subCode: "spawn-stderr",
2586
2619
  message: `claude-provider: rate-limit detected in stderr (exit ${String(code)})`,
2587
- ...sessionIdOnFailure !== void 0 ? { sessionId: sessionIdOnFailure } : {}
2620
+ ...envelope.sessionId !== void 0 ? { sessionId: envelope.sessionId } : {}
2588
2621
  })
2589
2622
  };
2590
2623
  }
@@ -3211,6 +3244,7 @@ var stringifyError4 = (cause) => cause instanceof Error ? cause.message : String
3211
3244
 
3212
3245
  // src/integration/ai/providers/codex/interactive.ts
3213
3246
  import { spawn as nodeSpawn7 } from "child_process";
3247
+ import { dirname as dirname4 } from "path";
3214
3248
  var defaultSpawn7 = (command, args, options) => nodeSpawn7(command, [...args], { stdio: options.stdio, cwd: options.cwd });
3215
3249
  var createInteractiveCodexProvider = (deps) => {
3216
3250
  const spawnFn = deps.spawn ?? defaultSpawn7;
@@ -3227,10 +3261,23 @@ var createInteractiveCodexProvider = (deps) => {
3227
3261
  })
3228
3262
  );
3229
3263
  }
3264
+ const allRoots = [
3265
+ String(input.cwd),
3266
+ ...input.additionalRoots?.map((r) => String(r)) ?? [],
3267
+ dirname4(String(input.outputFile)),
3268
+ dirname4(String(input.promptFile))
3269
+ ];
3270
+ const seen = /* @__PURE__ */ new Set();
3271
+ const dirFlags = allRoots.filter((p) => {
3272
+ if (seen.has(p)) return false;
3273
+ seen.add(p);
3274
+ return true;
3275
+ }).flatMap((p) => ["--add-dir", shellQuote2(p)]);
3230
3276
  const inner = [
3231
3277
  "codex",
3232
3278
  "--cd",
3233
3279
  shellQuote2(String(input.cwd)),
3280
+ ...dirFlags,
3234
3281
  "--model",
3235
3282
  shellQuote2(input.model),
3236
3283
  "-s",
@@ -3286,7 +3333,7 @@ var stringifyError5 = (cause) => cause instanceof Error ? cause.message : String
3286
3333
  // src/integration/ai/providers/copilot/interactive.ts
3287
3334
  import { promises as fs6 } from "fs";
3288
3335
  import { spawn as nodeSpawn8 } from "child_process";
3289
- import { dirname as dirname4 } from "path";
3336
+ import { dirname as dirname5 } from "path";
3290
3337
  var defaultSpawn8 = (command, args, options) => nodeSpawn8(command, [...args], { stdio: options.stdio, cwd: options.cwd });
3291
3338
  var defaultReadFile = (path) => fs6.readFile(path, "utf8");
3292
3339
  var createInteractiveCopilotProvider = (deps) => {
@@ -3320,8 +3367,8 @@ var createInteractiveCopilotProvider = (deps) => {
3320
3367
  const allRoots = [
3321
3368
  String(input.cwd),
3322
3369
  ...input.additionalRoots?.map((r) => String(r)) ?? [],
3323
- dirname4(String(input.outputFile)),
3324
- dirname4(String(input.promptFile))
3370
+ dirname5(String(input.outputFile)),
3371
+ dirname5(String(input.promptFile))
3325
3372
  ];
3326
3373
  const seen = /* @__PURE__ */ new Set();
3327
3374
  const dirFlags = allRoots.filter((p) => {
@@ -3864,7 +3911,7 @@ var createPullRequestCreator = (deps) => async (input) => {
3864
3911
 
3865
3912
  // src/integration/ai/prompts/_engine/fs-template-loader.ts
3866
3913
  import { promises as fs7 } from "fs";
3867
- import { dirname as dirname5, join as join6 } from "path";
3914
+ import { dirname as dirname6, join as join6 } from "path";
3868
3915
  import { fileURLToPath } from "url";
3869
3916
  var createFsTemplateLoader = (templatesDir) => ({
3870
3917
  async load(name) {
@@ -3898,7 +3945,7 @@ var tryRead = async (path) => {
3898
3945
  }
3899
3946
  };
3900
3947
  var TEMPLATES_DIR = (() => {
3901
- const here = dirname5(fileURLToPath(import.meta.url));
3948
+ const here = dirname6(fileURLToPath(import.meta.url));
3902
3949
  const isBundled = import.meta.url.endsWith("/cli.mjs") || import.meta.url.endsWith("\\cli.mjs");
3903
3950
  const path = isBundled ? join6(here, "prompts") : join6(here, "..");
3904
3951
  const parsed = AbsolutePath.parse(path);
@@ -4296,7 +4343,7 @@ var createNpmVersionChecker = (deps) => {
4296
4343
  // package.json
4297
4344
  var package_default = {
4298
4345
  name: "ralphctl",
4299
- version: "0.7.0",
4346
+ version: "0.7.1",
4300
4347
  description: "Agent harness for long-running AI coding tasks \u2014 orchestrates Claude Code & GitHub Copilot across repositories",
4301
4348
  homepage: "https://github.com/lukas-grigis/ralphctl",
4302
4349
  type: "module",
@@ -4605,7 +4652,7 @@ var createSkillsAdapter = (deps) => {
4605
4652
  };
4606
4653
 
4607
4654
  // src/integration/ai/skills/bundled/source.ts
4608
- import { dirname as dirname6, join as join12 } from "path";
4655
+ import { dirname as dirname7, join as join12 } from "path";
4609
4656
  import { fileURLToPath as fileURLToPath2 } from "url";
4610
4657
  import { readFile } from "fs/promises";
4611
4658
 
@@ -4632,7 +4679,7 @@ var skillsForFlow = (flowId) => FLOW_SKILLS[flowId];
4632
4679
 
4633
4680
  // src/integration/ai/skills/bundled/source.ts
4634
4681
  var defaultBundledRoot = (() => {
4635
- const here = dirname6(fileURLToPath2(import.meta.url));
4682
+ const here = dirname7(fileURLToPath2(import.meta.url));
4636
4683
  const isBundled = import.meta.url.endsWith("/cli.mjs") || import.meta.url.endsWith("\\cli.mjs");
4637
4684
  return isBundled ? join12(here, "skills") : here;
4638
4685
  })();
@@ -9350,7 +9397,7 @@ var refineTicketInteractiveLeaf = (deps, ticket) => leaf(`refine-ticket-${String
9350
9397
  execute: async (input) => {
9351
9398
  const session = await deps.runInTerminal(
9352
9399
  async () => deps.interactiveAi.run({
9353
- cwd: deps.cwd,
9400
+ cwd: input.cwd,
9354
9401
  promptFile: input.promptFile,
9355
9402
  outputFile: input.outputFile,
9356
9403
  model: deps.model
@@ -9407,9 +9454,18 @@ var refineTicketInteractiveLeaf = (deps, ticket) => leaf(`refine-ticket-${String
9407
9454
  message: `refine-ticket-${String(ticket.id)}: prompt/output paths missing \u2014 render-prompt-to-file must run first`
9408
9455
  });
9409
9456
  }
9457
+ if (ctx.currentUnitRoot === void 0) {
9458
+ throw new InvalidStateError({
9459
+ entity: "chain",
9460
+ currentState: "pre-refine",
9461
+ attemptedAction: `refine-ticket-${String(ticket.id)}`,
9462
+ message: `refine-ticket-${String(ticket.id)}: unit root missing \u2014 build-refine-unit must run first`
9463
+ });
9464
+ }
9410
9465
  return {
9411
9466
  sprint: ctx.sprint,
9412
9467
  ticket,
9468
+ cwd: ctx.currentUnitRoot,
9413
9469
  promptFile: ctx.currentPromptFile,
9414
9470
  outputFile: ctx.currentOutputFile
9415
9471
  };
@@ -9648,9 +9704,17 @@ var createRefineFlow = (deps, opts) => {
9648
9704
  {
9649
9705
  name: `install-skills-${String(ticket.id)}`,
9650
9706
  flowId: "refine",
9651
- // Skills land in the AI session's cwd (the repo) the provider-native conventions
9652
- // only auto-discover skills from cwd, not from `--add-dir` roots.
9653
- cwdPicker: () => opts.cwd
9707
+ // Skills land in the AI session's cwd for refine that's the per-ticket unit root
9708
+ // (`<sprintDir>/refinement/<ticket-slug>/`), not the user's repo. Refinement is
9709
+ // implementation-agnostic, so we keep the AI out of the repo's auto-discovered context.
9710
+ cwdPicker: (ctx) => {
9711
+ if (ctx.currentUnitRoot === void 0) {
9712
+ throw new Error(
9713
+ `install-skills-${String(ticket.id)}: currentUnitRoot missing \u2014 build-refine-unit must run first`
9714
+ );
9715
+ }
9716
+ return ctx.currentUnitRoot;
9717
+ }
9654
9718
  }
9655
9719
  ),
9656
9720
  refineTicketInteractiveLeaf(
@@ -9658,7 +9722,6 @@ var createRefineFlow = (deps, opts) => {
9658
9722
  interactiveAi: deps.interactiveAi,
9659
9723
  runInTerminal: deps.runInTerminal,
9660
9724
  logger: deps.logger,
9661
- cwd: opts.cwd,
9662
9725
  model: opts.model,
9663
9726
  ...deps.reviewBeforeApprove !== void 0 ? { reviewBeforeApprove: deps.reviewBeforeApprove } : {},
9664
9727
  ...deps.issuePusher !== void 0 ? { issuePusher: deps.issuePusher } : {},
@@ -9668,7 +9731,17 @@ var createRefineFlow = (deps, opts) => {
9668
9731
  ),
9669
9732
  uninstallSkillsLeaf(
9670
9733
  { skillsAdapter: deps.skillsAdapter },
9671
- { name: `uninstall-skills-${String(ticket.id)}`, cwdPicker: () => opts.cwd }
9734
+ {
9735
+ name: `uninstall-skills-${String(ticket.id)}`,
9736
+ cwdPicker: (ctx) => {
9737
+ if (ctx.currentUnitRoot === void 0) {
9738
+ throw new Error(
9739
+ `uninstall-skills-${String(ticket.id)}: currentUnitRoot missing \u2014 build-refine-unit must run first`
9740
+ );
9741
+ }
9742
+ return ctx.currentUnitRoot;
9743
+ }
9744
+ }
9672
9745
  ),
9673
9746
  saveSprintLeaf({ sprintRepo: deps.sprintRepo }, `save-after-${String(ticket.id)}`)
9674
9747
  ])
@@ -9691,9 +9764,8 @@ var deriveOriginFromGit = async (cwd, gitRunner) => {
9691
9764
  return parsed === null ? void 0 : parsed;
9692
9765
  };
9693
9766
  var launchRefine = async (ctx) => {
9694
- const { deps, snapshot, extras, settings, interactiveAi, skillsAdapter, skillSource, cwd, bridge, sessionId: sessionId2 } = ctx;
9767
+ const { deps, snapshot, extras, settings, interactiveAi, skillsAdapter, skillSource, bridge, sessionId: sessionId2 } = ctx;
9695
9768
  if (!snapshot.sprint) return { ok: false, reason: "No sprint selected." };
9696
- if (!cwd) return { ok: false, reason: "No repository path resolvable from the project." };
9697
9769
  const pending = snapshot.sprint.tickets.filter((t) => t.status === "pending");
9698
9770
  const refinementRoot = AbsolutePath.parse(
9699
9771
  join15(String(deps.storage.dataRoot), "sprints", String(snapshot.sprint.id), "refinement")
@@ -9756,7 +9828,6 @@ ${body.trim()}`;
9756
9828
  {
9757
9829
  sprintId: snapshot.sprint.id,
9758
9830
  pendingTickets: pending,
9759
- cwd,
9760
9831
  model: extras.modelOverride ?? settings.ai.models.refine,
9761
9832
  refinementRoot: refinementRoot.value
9762
9833
  }
@@ -10214,6 +10285,13 @@ var markTaskBlocked = (task, reason) => {
10214
10285
  if (!guard2.ok) return Result.error(guard2.error);
10215
10286
  return Result.ok({ ...guard2.value, status: "blocked", blockedReason: reason });
10216
10287
  };
10288
+ var unblockTask = (task) => {
10289
+ const guard2 = requireStatus("task", task, ["blocked"], "unblock");
10290
+ if (!guard2.ok) return Result.error(guard2.error);
10291
+ const { blockedReason: _ignored, ...rest } = guard2.value;
10292
+ void _ignored;
10293
+ return Result.ok({ ...rest, status: "todo" });
10294
+ };
10217
10295
  var latestCritique = (task) => {
10218
10296
  for (let i = task.attempts.length - 1; i >= 0; i--) {
10219
10297
  const att = task.attempts[i];
@@ -10394,7 +10472,7 @@ var callPlannerInteractiveLeaf = (deps) => leaf("call-planner-interactive", {
10394
10472
  const additionalRoots = deps.additionalRoots ?? [];
10395
10473
  const session = await deps.runInTerminal(
10396
10474
  async () => deps.interactiveAi.run({
10397
- cwd: deps.cwd,
10475
+ cwd: input.cwd,
10398
10476
  promptFile: input.promptFile,
10399
10477
  outputFile: input.outputFile,
10400
10478
  model: deps.model,
@@ -10471,10 +10549,19 @@ var callPlannerInteractiveLeaf = (deps) => leaf("call-planner-interactive", {
10471
10549
  message: "call-planner-interactive: prompt/output paths missing \u2014 render-prompt-to-file must run first"
10472
10550
  });
10473
10551
  }
10552
+ if (ctx.currentUnitRoot === void 0) {
10553
+ throw new InvalidStateError({
10554
+ entity: "chain",
10555
+ currentState: "pre-plan",
10556
+ attemptedAction: "call-planner-interactive",
10557
+ message: "call-planner-interactive: unit root missing \u2014 build-plan-unit must run first"
10558
+ });
10559
+ }
10474
10560
  return {
10475
10561
  sprint: ctx.sprint,
10476
10562
  project: ctx.project,
10477
10563
  existingTasks: ctx.tasks ?? [],
10564
+ cwd: ctx.currentUnitRoot,
10478
10565
  promptFile: ctx.currentPromptFile,
10479
10566
  outputFile: ctx.currentOutputFile
10480
10567
  };
@@ -10536,9 +10623,21 @@ var createPlanFlow = (deps, opts) => {
10536
10623
  { skillsAdapter: deps.skillsAdapter, skillSource: deps.skillSource },
10537
10624
  {
10538
10625
  flowId: "plan",
10539
- // Skills land in the AI session's cwd (the repo) the provider-native conventions
10540
- // only auto-discover skills from cwd, not from `--add-dir` roots.
10541
- cwdPicker: () => opts.cwd
10626
+ // Skills land in the AI session's cwd for plan that's the per-sprint plan unit root
10627
+ // (`<sprintDir>/plan/<run-slug>/`), not any project repo. Plan mounts every repo as an
10628
+ // equal `--add-dir` source; rooting the session in any one repo would auto-load that
10629
+ // repo's `CLAUDE.md` / agents / `.mcp.json` and bias the planner toward it.
10630
+ cwdPicker: (ctx) => {
10631
+ if (ctx.currentUnitRoot === void 0) {
10632
+ throw new InvalidStateError({
10633
+ entity: "chain",
10634
+ currentState: "pre-plan",
10635
+ attemptedAction: "install-skills",
10636
+ message: "install-skills: currentUnitRoot missing \u2014 build-plan-unit must run first"
10637
+ });
10638
+ }
10639
+ return ctx.currentUnitRoot;
10640
+ }
10542
10641
  }
10543
10642
  ),
10544
10643
  callPlannerInteractiveLeaf({
@@ -10546,12 +10645,26 @@ var createPlanFlow = (deps, opts) => {
10546
10645
  runInTerminal: deps.runInTerminal,
10547
10646
  logger: deps.logger,
10548
10647
  clock: deps.clock,
10549
- cwd: opts.cwd,
10550
10648
  model: opts.model,
10551
10649
  ...opts.additionalRoots !== void 0 && opts.additionalRoots.length > 0 ? { additionalRoots: opts.additionalRoots } : {},
10552
10650
  ...deps.reviewBeforeApprove !== void 0 ? { reviewBeforeApprove: deps.reviewBeforeApprove } : {}
10553
10651
  }),
10554
- uninstallSkillsLeaf({ skillsAdapter: deps.skillsAdapter }, { cwdPicker: () => opts.cwd }),
10652
+ uninstallSkillsLeaf(
10653
+ { skillsAdapter: deps.skillsAdapter },
10654
+ {
10655
+ cwdPicker: (ctx) => {
10656
+ if (ctx.currentUnitRoot === void 0) {
10657
+ throw new InvalidStateError({
10658
+ entity: "chain",
10659
+ currentState: "pre-plan",
10660
+ attemptedAction: "uninstall-skills",
10661
+ message: "uninstall-skills: currentUnitRoot missing \u2014 build-plan-unit must run first"
10662
+ });
10663
+ }
10664
+ return ctx.currentUnitRoot;
10665
+ }
10666
+ }
10667
+ ),
10555
10668
  saveTasksLeaf({ taskRepo: deps.taskRepo }),
10556
10669
  saveSprintLeaf({ sprintRepo: deps.sprintRepo })
10557
10670
  ]);
@@ -10559,10 +10672,9 @@ var createPlanFlow = (deps, opts) => {
10559
10672
 
10560
10673
  // src/application/ui/shared/launch/plan.ts
10561
10674
  var launchPlan = (ctx) => {
10562
- const { deps, snapshot, extras, settings, interactiveAi, skillsAdapter, skillSource, cwd, bridge, sessionId: sessionId2 } = ctx;
10675
+ const { deps, snapshot, extras, settings, interactiveAi, skillsAdapter, skillSource, bridge, sessionId: sessionId2 } = ctx;
10563
10676
  if (!snapshot.project) return { ok: false, reason: "No project loaded." };
10564
10677
  if (!snapshot.sprint) return { ok: false, reason: "No sprint selected." };
10565
- if (!cwd) return { ok: false, reason: "No repository path resolvable from the project." };
10566
10678
  const planRoot = AbsolutePath.parse(
10567
10679
  join17(String(deps.storage.dataRoot), "sprints", String(snapshot.sprint.id), "plan")
10568
10680
  );
@@ -10601,9 +10713,9 @@ ${summary}`;
10601
10713
  {
10602
10714
  sprintId: snapshot.sprint.id,
10603
10715
  projectId: snapshot.project.id,
10604
- cwd,
10605
- // Mount every repo on the project so the planner can navigate across them without per-file
10606
- // approval prompts. Duplicates with `cwd` are folded out by the adapter.
10716
+ // Mount every repo on the project as an equal `--add-dir` source so the planner can
10717
+ // navigate across them without per-file approval prompts. No repo enjoys cwd privilege
10718
+ // the session's cwd is the per-sprint plan unit root.
10607
10719
  additionalRoots: snapshot.project.repositories.map((r) => r.path),
10608
10720
  model: extras.modelOverride ?? settings.ai.models.plan,
10609
10721
  planRoot: planRoot.value
@@ -10675,7 +10787,7 @@ var loop = (name, body, opts = {}) => ({
10675
10787
 
10676
10788
  // src/integration/observability/sinks/progress-file-sink.ts
10677
10789
  import { promises as fs14 } from "fs";
10678
- import { dirname as dirname7 } from "path";
10790
+ import { dirname as dirname8 } from "path";
10679
10791
  var MAX_QUEUE_DEPTH = 1e4;
10680
10792
  var createProgressFileSink = (deps) => {
10681
10793
  const queue = [];
@@ -10795,7 +10907,7 @@ var TEMPLATE = `${TITLE}
10795
10907
  ${HEADINGS.map((h) => `## ${h}
10796
10908
  `).join("\n")}`;
10797
10909
  var mergeSection = async (path, rendered) => {
10798
- await fs14.mkdir(dirname7(path), { recursive: true });
10910
+ await fs14.mkdir(dirname8(path), { recursive: true });
10799
10911
  let current;
10800
10912
  try {
10801
10913
  current = await fs14.readFile(path, "utf8");
@@ -11017,6 +11129,45 @@ var gitCommitWithMessage = async (runner, cwd, message) => {
11017
11129
  if (!head.ok) return Result.error(head.error);
11018
11130
  return Result.ok({ committed: true, headSha: head.value });
11019
11131
  };
11132
+ var gitStashPush = async (runner, cwd, message) => {
11133
+ const dirty = await gitHasUncommittedChanges(runner, cwd);
11134
+ if (!dirty.ok) return Result.error(dirty.error);
11135
+ if (!dirty.value) return Result.ok({ stashed: false });
11136
+ const stash = await runner.run(cwd, ["stash", "push", "-u", "-m", message]);
11137
+ if (!stash.ok) return Result.error(stash.error);
11138
+ if (stash.value.exitCode !== 0) {
11139
+ return Result.error(
11140
+ new StorageError({
11141
+ subCode: "io",
11142
+ message: `git stash push failed: ${(stash.value.stderr || stash.value.stdout).trim()}`
11143
+ })
11144
+ );
11145
+ }
11146
+ return Result.ok({ stashed: true });
11147
+ };
11148
+ var gitResetHard = async (runner, cwd) => {
11149
+ const reset = await runner.run(cwd, ["reset", "--hard", "HEAD"]);
11150
+ if (!reset.ok) return Result.error(reset.error);
11151
+ if (reset.value.exitCode !== 0) {
11152
+ return Result.error(
11153
+ new StorageError({
11154
+ subCode: "io",
11155
+ message: `git reset --hard failed: ${(reset.value.stderr || reset.value.stdout).trim()}`
11156
+ })
11157
+ );
11158
+ }
11159
+ const clean = await runner.run(cwd, ["clean", "-fd"]);
11160
+ if (!clean.ok) return Result.error(clean.error);
11161
+ if (clean.value.exitCode !== 0) {
11162
+ return Result.error(
11163
+ new StorageError({
11164
+ subCode: "io",
11165
+ message: `git clean -fd failed: ${(clean.value.stderr || clean.value.stdout).trim()}`
11166
+ })
11167
+ );
11168
+ }
11169
+ return Result.ok(void 0);
11170
+ };
11020
11171
  var gitGetCurrentBranch = async (runner, cwd) => {
11021
11172
  const result = await runner.run(cwd, ["rev-parse", "--abbrev-ref", "HEAD"]);
11022
11173
  if (!result.ok) return Result.error(result.error);
@@ -12015,6 +12166,7 @@ var postTaskCheckLeaf = (deps, opts, taskId) => {
12015
12166
  };
12016
12167
 
12017
12168
  // src/business/task/preflight-task.ts
12169
+ var ELEMENT_NAME = "preflight-task";
12018
12170
  var preflightTaskUseCase = async (props) => {
12019
12171
  const log = props.logger.named("task.preflight");
12020
12172
  log.debug("checking working tree", { cwd: props.cwd });
@@ -12035,6 +12187,9 @@ var preflightTaskUseCase = async (props) => {
12035
12187
  });
12036
12188
  return Result.ok(void 0);
12037
12189
  }
12190
+ if (policy === "prompt") {
12191
+ return resolveViaPrompt(props, count.value);
12192
+ }
12038
12193
  log.warn("refusing to start a task on a dirty tree", { cwd: props.cwd, dirtyEntries: count.value });
12039
12194
  return Result.error(
12040
12195
  new InvalidStateError({
@@ -12046,24 +12201,105 @@ var preflightTaskUseCase = async (props) => {
12046
12201
  })
12047
12202
  );
12048
12203
  };
12204
+ var resolveViaPrompt = async (props, dirtyEntries) => {
12205
+ const log = props.logger.named("task.preflight");
12206
+ if (props.askDirtyTreeChoice === void 0 || props.gitStash === void 0 || props.gitReset === void 0 || props.clock === void 0) {
12207
+ throw new InvalidStateError({
12208
+ entity: ELEMENT_NAME,
12209
+ currentState: "prompt-without-deps",
12210
+ attemptedAction: "configure-prompt-deps",
12211
+ message: "preflight-task: dirtyTreePolicy='prompt' requires askDirtyTreeChoice, gitStash, gitReset, and clock dependencies"
12212
+ });
12213
+ }
12214
+ const choice = await props.askDirtyTreeChoice({ cwd: props.cwd, dirtyEntries });
12215
+ if (!choice.ok) {
12216
+ return Result.error(choice.error);
12217
+ }
12218
+ switch (choice.value) {
12219
+ case "keep":
12220
+ log.info(`working tree dirty (${String(dirtyEntries)} entries) \u2014 proceeding (user chose 'keep')`, {
12221
+ cwd: props.cwd,
12222
+ dirtyEntries
12223
+ });
12224
+ return Result.ok(void 0);
12225
+ case "stash": {
12226
+ const sprintLabel = props.sprintId !== void 0 && props.sprintId.length > 0 ? props.sprintId : "unknown";
12227
+ const message = `ralphctl preflight stash (sprint ${sprintLabel}, ${String(props.clock())})`;
12228
+ const stashed = await props.gitStash(props.cwd, message);
12229
+ if (!stashed.ok) return Result.error(stashed.error);
12230
+ if (!stashed.value.stashed) {
12231
+ log.warn("stash reported no changes despite dirty status \u2014 proceeding", { cwd: props.cwd });
12232
+ return Result.ok(void 0);
12233
+ }
12234
+ log.info(`stashed working tree \u2014 recoverable as: ${message}`, { cwd: props.cwd, stashMessage: message });
12235
+ return Result.ok(void 0);
12236
+ }
12237
+ case "reset": {
12238
+ const reset = await props.gitReset(props.cwd);
12239
+ if (!reset.ok) return Result.error(reset.error);
12240
+ log.info("reset working tree \u2014 discarded uncommitted + untracked changes", { cwd: props.cwd });
12241
+ return Result.ok(void 0);
12242
+ }
12243
+ case "cancel":
12244
+ return Result.error(
12245
+ new AbortError({ elementName: ELEMENT_NAME, reason: "user cancelled on dirty working tree" })
12246
+ );
12247
+ }
12248
+ };
12049
12249
 
12050
12250
  // src/application/flows/implement/leaves/preflight-task.ts
12251
+ var ELEMENT_NAME2 = "preflight-task";
12051
12252
  var preflightTaskLeaf = (deps, cwd, name = "preflight-task") => {
12052
12253
  const gitStatusEntryCount = async (path) => {
12053
12254
  const status = await gitStatusPorcelain(deps.gitRunner, path);
12054
12255
  if (!status.ok) return status;
12055
12256
  return { ok: true, value: status.value.length };
12056
12257
  };
12258
+ const gitStash = (path, message) => gitStashPush(deps.gitRunner, path, message);
12259
+ const gitReset = (path) => gitResetHard(deps.gitRunner, path);
12260
+ const askDirtyTreeChoice = async ({
12261
+ cwd: dirtyCwd,
12262
+ dirtyEntries
12263
+ }) => {
12264
+ const choice = await deps.interactive.askChoice(
12265
+ `Working tree at ${String(dirtyCwd)} has ${String(dirtyEntries)} uncommitted change(s). How do you want to handle it?`,
12266
+ [
12267
+ {
12268
+ label: "Keep changes \u2014 proceed on the dirty tree",
12269
+ value: "keep",
12270
+ description: "AI may build on / overwrite the pending diff"
12271
+ },
12272
+ { label: "Stash \u2014 save changes to a recoverable stash, then proceed", value: "stash" },
12273
+ {
12274
+ label: "Reset \u2014 discard all uncommitted + untracked changes, then proceed",
12275
+ value: "reset",
12276
+ description: "git reset --hard && git clean -fd"
12277
+ },
12278
+ { label: "Cancel \u2014 abort the implement run", value: "cancel" }
12279
+ ]
12280
+ );
12281
+ if (!choice.ok) {
12282
+ return Result.error(
12283
+ new AbortError({ elementName: ELEMENT_NAME2, reason: `dirty-tree prompt cancelled \u2014 ${choice.error.message}` })
12284
+ );
12285
+ }
12286
+ return Result.ok(choice.value);
12287
+ };
12057
12288
  return leaf(name, {
12058
12289
  useCase: {
12059
- execute: async () => preflightTaskUseCase({
12290
+ execute: async (input) => preflightTaskUseCase({
12060
12291
  cwd,
12061
12292
  gitStatusEntryCount,
12293
+ gitStash,
12294
+ gitReset,
12295
+ askDirtyTreeChoice,
12296
+ clock: deps.clock,
12297
+ sprintId: input.sprintId,
12062
12298
  logger: deps.logger,
12063
12299
  ...deps.dirtyTreePolicy !== void 0 ? { dirtyTreePolicy: deps.dirtyTreePolicy } : {}
12064
12300
  })
12065
12301
  },
12066
- input: () => void 0,
12302
+ input: (ctx) => ({ sprintId: String(ctx.sprintId) }),
12067
12303
  output: (ctx) => ctx
12068
12304
  });
12069
12305
  };
@@ -12837,12 +13073,15 @@ var createImplementFlow = (deps, opts) => {
12837
13073
  path: r.path,
12838
13074
  ...r.setupScript !== void 0 ? { setupScript: r.setupScript } : {}
12839
13075
  }));
13076
+ const dirtyTreePolicy = opts.dirtyTreePolicy ?? "prompt";
12840
13077
  const preflightLeaves = uniqueRepoCwds.map(
12841
13078
  (cwd, i) => preflightTaskLeaf(
12842
13079
  {
12843
13080
  gitRunner: deps.gitRunner,
13081
+ interactive: deps.interactive,
13082
+ clock: deps.clock,
12844
13083
  logger: deps.logger,
12845
- ...opts.dirtyTreePolicy !== void 0 ? { dirtyTreePolicy: opts.dirtyTreePolicy } : {}
13084
+ dirtyTreePolicy
12846
13085
  },
12847
13086
  cwd,
12848
13087
  `preflight-task-${String(i + 1)}-${String(cwd)}`
@@ -12903,7 +13142,7 @@ var createImplementFlow = (deps, opts) => {
12903
13142
 
12904
13143
  // src/integration/observability/sinks/file-log-sink.ts
12905
13144
  import { promises as fs17 } from "fs";
12906
- import { dirname as dirname8 } from "path";
13145
+ import { dirname as dirname9 } from "path";
12907
13146
  var startFileLogSink = (deps) => {
12908
13147
  const queue = [];
12909
13148
  let draining;
@@ -12915,7 +13154,7 @@ var startFileLogSink = (deps) => {
12915
13154
  if (next === void 0) continue;
12916
13155
  try {
12917
13156
  if (!dirEnsured) {
12918
- await fs17.mkdir(dirname8(String(deps.file)), { recursive: true });
13157
+ await fs17.mkdir(dirname9(String(deps.file)), { recursive: true });
12919
13158
  dirEnsured = true;
12920
13159
  }
12921
13160
  await fs17.appendFile(String(deps.file), `${JSON.stringify(next)}
@@ -16704,6 +16943,39 @@ var createTicketRemoveFlow = (deps) => leaf("ticket-remove", {
16704
16943
  output: (c, o) => ({ ...c, output: o })
16705
16944
  });
16706
16945
 
16946
+ // src/business/task/unblock-task.ts
16947
+ var unblockTaskUseCase = async (props) => {
16948
+ const log = props.logger.named("task.unblock");
16949
+ if (props.task.status === "todo") {
16950
+ log.debug("already todo, skipping", { taskId: props.task.id, sprintId: props.sprintId });
16951
+ return Result.ok(props.task);
16952
+ }
16953
+ log.debug("unblocking task", {
16954
+ taskId: props.task.id,
16955
+ sprintId: props.sprintId,
16956
+ from: props.task.status
16957
+ });
16958
+ const transitioned = unblockTask(props.task);
16959
+ if (!transitioned.ok) {
16960
+ log.warn("invalid state transition", {
16961
+ taskId: props.task.id,
16962
+ from: props.task.status,
16963
+ error: transitioned.error.message
16964
+ });
16965
+ return Result.error(transitioned.error);
16966
+ }
16967
+ const persisted = await props.taskRepo.update(props.sprintId, transitioned.value);
16968
+ if (!persisted.ok) {
16969
+ log.error("persist failed", { taskId: transitioned.value.id, error: persisted.error.message });
16970
+ return Result.error(persisted.error);
16971
+ }
16972
+ log.info(`unblocked task '${transitioned.value.name}'`, {
16973
+ taskId: transitioned.value.id,
16974
+ sprintId: props.sprintId
16975
+ });
16976
+ return Result.ok(transitioned.value);
16977
+ };
16978
+
16707
16979
  // src/application/ui/tui/views/sprint-detail-view.tsx
16708
16980
  import { jsx as jsx41, jsxs as jsxs30 } from "react/jsx-runtime";
16709
16981
  var buildFocusList = (sprint, tasks) => [
@@ -16772,12 +17044,15 @@ var SprintDetailView = () => {
16772
17044
  const [feedback, setFeedback] = useState25(void 0);
16773
17045
  const ticketsEditable = sprint?.status === "draft";
16774
17046
  const inDetail = openIdx !== void 0;
17047
+ const focusedNow = focusList[Math.min(cursorIdx, Math.max(0, focusList.length - 1))];
17048
+ const focusedBlockedTask = focusedNow?.kind === "task" && focusedNow.task.status === "blocked" ? focusedNow.task : void 0;
16775
17049
  useViewHints(
16776
17050
  inDetail ? [{ keys: "esc", label: "back to list" }] : [
16777
17051
  { keys: "n", label: "flows" },
16778
17052
  { keys: "\u21B5/o", label: "open" },
16779
17053
  { keys: "a", label: "add ticket" },
16780
- { keys: "d", label: "remove ticket" }
17054
+ { keys: "d", label: "remove ticket" },
17055
+ ...focusedBlockedTask !== void 0 ? [{ keys: "u", label: "unblock" }] : []
16781
17056
  ]
16782
17057
  );
16783
17058
  useInput14((input, key) => {
@@ -16805,6 +17080,10 @@ var SprintDetailView = () => {
16805
17080
  if (input === "d" && ticketsEditable) {
16806
17081
  const focused = focusList[Math.min(cursorIdx, focusList.length - 1)];
16807
17082
  if (focused?.kind === "ticket") setConfirmRemove(focused.ticket);
17083
+ return;
17084
+ }
17085
+ if (input === "u" && focusedBlockedTask !== void 0) {
17086
+ void handleUnblock(focusedBlockedTask);
16808
17087
  }
16809
17088
  });
16810
17089
  useEffect17(() => confirmRemove !== void 0 ? ui.claimPrompt() : void 0, [confirmRemove, ui.claimPrompt]);
@@ -16820,6 +17099,21 @@ var SprintDetailView = () => {
16820
17099
  setFeedback(`\u2713 removed "${target.title}"`);
16821
17100
  reload();
16822
17101
  };
17102
+ const handleUnblock = async (target) => {
17103
+ if (sprint === void 0) return;
17104
+ const r = await unblockTaskUseCase({
17105
+ task: target,
17106
+ sprintId: sprint.id,
17107
+ taskRepo: deps.taskRepo,
17108
+ logger: deps.logger
17109
+ });
17110
+ if (!r.ok) {
17111
+ setFeedback(`\u2717 ${r.error.message}`);
17112
+ return;
17113
+ }
17114
+ setFeedback(`\u2713 unblocked "${target.name}"`);
17115
+ reload();
17116
+ };
16823
17117
  return /* @__PURE__ */ jsx41(ViewShell, { title: "Sprint", subtitle: state.kind === "ok" ? state.value.sprint.name : "loading", children: ui.helpOpen ? /* @__PURE__ */ jsx41(HelpOverlay, {}) : state.kind === "loading" || state.kind === "idle" ? /* @__PURE__ */ jsx41(Box29, { paddingX: spacing.indent, children: /* @__PURE__ */ jsx41(Spinner, { label: "Loading\u2026" }) }) : state.kind === "error" ? /* @__PURE__ */ jsx41(Box29, { paddingX: spacing.indent, children: /* @__PURE__ */ jsx41(Text31, { children: "Failed to load sprint." }) }) : confirmRemove !== void 0 ? /* @__PURE__ */ jsxs30(Box29, { flexDirection: "column", paddingX: spacing.indent, children: [
16824
17118
  /* @__PURE__ */ jsxs30(Text31, { children: [
16825
17119
  "Remove ticket ",
@@ -19615,7 +19909,7 @@ import { basename as basename3, join as join33 } from "path";
19615
19909
  // src/application/ui/tui/prompts/path-picker-prompt.tsx
19616
19910
  import { useEffect as useEffect28, useState as useState35 } from "react";
19617
19911
  import { promises as fs24 } from "fs";
19618
- import { dirname as dirname9, join as join32 } from "path";
19912
+ import { dirname as dirname10, join as join32 } from "path";
19619
19913
  import { homedir } from "os";
19620
19914
  import { Box as Box43, Text as Text45, useInput as useInput23 } from "ink";
19621
19915
  import { jsx as jsx55, jsxs as jsxs44 } from "react/jsx-runtime";
@@ -19675,7 +19969,7 @@ var PathPickerPrompt = ({
19675
19969
  return;
19676
19970
  }
19677
19971
  if (key.backspace || key.delete) {
19678
- const parent = dirname9(cwd);
19972
+ const parent = dirname10(cwd);
19679
19973
  if (parent !== cwd) {
19680
19974
  setCwd(parent);
19681
19975
  setCursor(1);
@@ -19700,7 +19994,7 @@ var PathPickerPrompt = ({
19700
19994
  const row = rows[cursor];
19701
19995
  if (row === void 0) return;
19702
19996
  if (row.kind === "parent") {
19703
- const parent = dirname9(cwd);
19997
+ const parent = dirname10(cwd);
19704
19998
  if (parent !== cwd) {
19705
19999
  setCwd(parent);
19706
20000
  setCursor(1);
@@ -21734,6 +22028,44 @@ var registerTaskCommand = (program) => {
21734
22028
  return;
21735
22029
  }
21736
22030
  process.stdout.write(`${JSON.stringify(result.value, null, 2)}
22031
+ `);
22032
+ });
22033
+ task.command("unblock <taskId>").description("flip a blocked task back to todo so the implement loop picks it up again").requiredOption("-s, --sprint <id>", "sprint id").action(async (rawTaskId, opts) => {
22034
+ const sprintId = SprintId.parse(opts.sprint);
22035
+ if (!sprintId.ok) {
22036
+ process.stderr.write(`error: invalid sprint id: ${sprintId.error.message}
22037
+ `);
22038
+ process.exit(1);
22039
+ return;
22040
+ }
22041
+ const taskId = TaskId.parse(rawTaskId);
22042
+ if (!taskId.ok) {
22043
+ process.stderr.write(`error: invalid task id: ${taskId.error.message}
22044
+ `);
22045
+ process.exit(1);
22046
+ return;
22047
+ }
22048
+ const { deps } = await bootstrapCli();
22049
+ const loaded = await deps.taskRepo.findById(sprintId.value, taskId.value);
22050
+ if (!loaded.ok) {
22051
+ process.stderr.write(`error: ${loaded.error.message}
22052
+ `);
22053
+ process.exit(1);
22054
+ return;
22055
+ }
22056
+ const result = await unblockTaskUseCase({
22057
+ task: loaded.value,
22058
+ sprintId: sprintId.value,
22059
+ taskRepo: deps.taskRepo,
22060
+ logger: deps.logger
22061
+ });
22062
+ if (!result.ok) {
22063
+ process.stderr.write(`error: ${result.error.message}
22064
+ `);
22065
+ process.exit(1);
22066
+ return;
22067
+ }
22068
+ process.stdout.write(`unblocked task '${result.value.name}' (${String(result.value.id)})
21737
22069
  `);
21738
22070
  });
21739
22071
  };
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "version": 1,
3
- "generatedAt": "2026-05-18T19:45:23.510Z",
3
+ "generatedAt": "2026-05-19T05:25:59.916Z",
4
4
  "assets": [
5
5
  "prompts/_partials/harness-context.md",
6
6
  "prompts/_partials/signals-evaluation.md",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ralphctl",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "description": "Agent harness for long-running AI coding tasks — orchestrates Claude Code & GitHub Copilot across repositories",
5
5
  "homepage": "https://github.com/lukas-grigis/ralphctl",
6
6
  "type": "module",