@neriros/ralphy 3.2.0 → 3.3.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.
@@ -18928,8 +18928,8 @@ import { readFileSync } from "fs";
18928
18928
  import { resolve } from "path";
18929
18929
  function getVersion() {
18930
18930
  try {
18931
- if ("3.2.0")
18932
- return "3.2.0";
18931
+ if ("3.3.1")
18932
+ return "3.3.1";
18933
18933
  } catch {}
18934
18934
  const dirsToTry = [];
18935
18935
  try {
@@ -59219,11 +59219,10 @@ var init_use_app = __esm(() => {
59219
59219
  });
59220
59220
 
59221
59221
  // node_modules/.bun/ink@5.2.1+1f88f629f0141b18/node_modules/ink/build/hooks/use-stdout.js
59222
- var import_react18, useStdout = () => import_react18.useContext(StdoutContext_default), use_stdout_default;
59222
+ var import_react18;
59223
59223
  var init_use_stdout = __esm(() => {
59224
59224
  init_StdoutContext();
59225
59225
  import_react18 = __toESM(require_react(), 1);
59226
- use_stdout_default = useStdout;
59227
59226
  });
59228
59227
 
59229
59228
  // node_modules/.bun/ink@5.2.1+1f88f629f0141b18/node_modules/ink/build/hooks/use-stderr.js
@@ -59448,6 +59447,50 @@ function runOpenspec(args, options = {}) {
59448
59447
  stderr: proc.stderr ? decoder.decode(proc.stderr) : ""
59449
59448
  };
59450
59449
  }
59450
+ function appendSteeringTaskToTasksMd(existing, taskLine) {
59451
+ const SECTION = "## Steering";
59452
+ const trimmed = existing.replace(/\s+$/, "");
59453
+ if (trimmed.length === 0) {
59454
+ return `${SECTION}
59455
+
59456
+ ${taskLine}
59457
+ `;
59458
+ }
59459
+ const lines = trimmed.split(/\r?\n/);
59460
+ let sectionStart = -1;
59461
+ for (let i = 0;i < lines.length; i += 1) {
59462
+ if (/^##\s+Steering\s*$/i.test(lines[i])) {
59463
+ sectionStart = i;
59464
+ break;
59465
+ }
59466
+ }
59467
+ if (sectionStart === -1) {
59468
+ return `${trimmed}
59469
+
59470
+ ${SECTION}
59471
+
59472
+ ${taskLine}
59473
+ `;
59474
+ }
59475
+ let sectionEnd = lines.length;
59476
+ for (let i = sectionStart + 1;i < lines.length; i += 1) {
59477
+ if (/^##\s+/.test(lines[i])) {
59478
+ sectionEnd = i;
59479
+ break;
59480
+ }
59481
+ }
59482
+ let insertAt = sectionEnd;
59483
+ while (insertAt - 1 > sectionStart && (lines[insertAt - 1] ?? "").trim() === "") {
59484
+ insertAt -= 1;
59485
+ }
59486
+ const before2 = lines.slice(0, insertAt);
59487
+ const after2 = lines.slice(insertAt);
59488
+ const out = [...before2, taskLine, ...after2.length ? [""] : [], ...after2].join(`
59489
+ `);
59490
+ return out.endsWith(`
59491
+ `) ? out : `${out}
59492
+ `;
59493
+ }
59451
59494
 
59452
59495
  class OpenSpecChangeStore {
59453
59496
  async createChange(name, description) {
@@ -59504,6 +59547,16 @@ ${existing.trimStart()}` : `${message}
59504
59547
  `;
59505
59548
  await mkdir(dirname4(path), { recursive: true });
59506
59549
  await Bun.write(path, updated);
59550
+ const firstLine = message.split(/\r?\n/).map((l) => l.trim()).find((l) => l.length > 0) ?? message.trim();
59551
+ if (firstLine.length === 0)
59552
+ return;
59553
+ const tasksPath = join6("openspec", "changes", name, "tasks.md");
59554
+ const tasksFile = Bun.file(tasksPath);
59555
+ const existingTasks = await tasksFile.exists() ? await tasksFile.text() : "";
59556
+ const taskLine = `- [ ] Address steering: ${firstLine}`;
59557
+ const next = appendSteeringTaskToTasksMd(existingTasks, taskLine);
59558
+ await mkdir(dirname4(tasksPath), { recursive: true });
59559
+ await Bun.write(tasksPath, next);
59507
59560
  }
59508
59561
  async readSection(name, artifact, heading) {
59509
59562
  const file = Bun.file(join6("openspec", "changes", name, artifact));
@@ -63850,7 +63903,12 @@ var init_types2 = __esm(() => {
63850
63903
  createPr: exports_external.boolean().default(false),
63851
63904
  usage: UsageSchema.default({}),
63852
63905
  history: exports_external.array(HistoryEntrySchema).default([]),
63853
- metadata: exports_external.object({ branch: exports_external.string().optional() }).default({})
63906
+ metadata: exports_external.object({ branch: exports_external.string().optional() }).default({}),
63907
+ linearComments: exports_external.object({
63908
+ planCommentId: exports_external.string().nullable().default(null),
63909
+ tasksCommentId: exports_external.string().nullable().default(null),
63910
+ planPostedAt: exports_external.string().nullable().default(null)
63911
+ }).default({ planCommentId: null, tasksCommentId: null, planPostedAt: null })
63854
63912
  });
63855
63913
  PhaseFrontmatterSchema = exports_external.object({
63856
63914
  name: exports_external.string(),
@@ -63888,6 +63946,21 @@ function readState(changeDir) {
63888
63946
  throw new Error(".ralph-state.json not found");
63889
63947
  return StateSchema.parse(JSON.parse(raw));
63890
63948
  }
63949
+ function tryReadStateRaw(changeDir) {
63950
+ const filePath = join7(changeDir, STATE_FILE2);
63951
+ const text = getStorage().read(filePath);
63952
+ if (text === null)
63953
+ return { state: null, raw: null };
63954
+ let parsed;
63955
+ try {
63956
+ parsed = JSON.parse(text);
63957
+ } catch {
63958
+ return { state: null, raw: null };
63959
+ }
63960
+ const raw = parsed && typeof parsed === "object" ? parsed : {};
63961
+ const result2 = StateSchema.safeParse(parsed);
63962
+ return { state: result2.success ? result2.data : null, raw };
63963
+ }
63891
63964
  function writeState(changeDir, state) {
63892
63965
  const filePath = join7(changeDir, STATE_FILE2);
63893
63966
  getStorage().write(filePath, JSON.stringify(state, null, 2) + `
@@ -68802,21 +68875,26 @@ function readSize() {
68802
68875
  rows: process.stdout.rows ?? 24
68803
68876
  };
68804
68877
  }
68878
+ function clearScreenAndScrollback() {
68879
+ if (process.stdout.isTTY)
68880
+ process.stdout.write("\x1B[2J\x1B[3J\x1B[H");
68881
+ }
68805
68882
  function useTerminalSize() {
68806
- const [size2, setSize] = import_react53.useState(() => ({
68807
- ...readSize(),
68808
- resizeKey: 0
68809
- }));
68883
+ const initial2 = import_react53.useRef({ ...readSize(), resizeKey: 0 });
68884
+ const [size2, setSize] = import_react53.useState(initial2.current);
68885
+ const sizeRef = import_react53.useRef(initial2.current);
68810
68886
  import_react53.useEffect(() => {
68811
68887
  if (!process.stdout.isTTY)
68812
68888
  return;
68813
68889
  const onResize = () => {
68814
68890
  const { columns, rows } = readSize();
68815
- setSize((prev) => {
68816
- if (prev.columns === columns && prev.rows === rows)
68817
- return prev;
68818
- return { columns, rows, resizeKey: prev.resizeKey + 1 };
68819
- });
68891
+ const prev = sizeRef.current;
68892
+ if (prev.columns === columns && prev.rows === rows)
68893
+ return;
68894
+ clearScreenAndScrollback();
68895
+ const next = { columns, rows, resizeKey: prev.resizeKey + 1 };
68896
+ sizeRef.current = next;
68897
+ setSize(next);
68820
68898
  };
68821
68899
  process.stdout.on("resize", onResize);
68822
68900
  return () => {
@@ -70550,9 +70628,9 @@ function useLoop(opts) {
70550
70628
  const tasksDir = join11(opts.tasksDir, opts.name);
70551
70629
  const storage = getStorage();
70552
70630
  let currentState;
70553
- const existingStateRaw = storage.read(join11(stateDir, ".ralph-state.json"));
70554
- if (existingStateRaw !== null) {
70555
- currentState = readState(stateDir);
70631
+ const { state: parsedState, raw: rawState } = tryReadStateRaw(stateDir);
70632
+ if (parsedState !== null) {
70633
+ currentState = parsedState;
70556
70634
  if (currentState.engine !== opts.engine || currentState.model !== opts.model) {
70557
70635
  currentState = {
70558
70636
  ...currentState,
@@ -70562,6 +70640,9 @@ function useLoop(opts) {
70562
70640
  writeState(stateDir, currentState);
70563
70641
  }
70564
70642
  } else {
70643
+ if (rawState !== null) {
70644
+ addInfo(`.ralph-state.json was malformed \u2014 reinitialising. External fields (linearComments) preserved.`);
70645
+ }
70565
70646
  currentState = buildInitialState({
70566
70647
  name: opts.name,
70567
70648
  prompt: opts.prompt,
@@ -70570,6 +70651,9 @@ function useLoop(opts) {
70570
70651
  manualTest: opts.manualTest,
70571
70652
  createPr: opts.createPr ?? false
70572
70653
  });
70654
+ if (rawState !== null && rawState.linearComments) {
70655
+ currentState.linearComments = rawState.linearComments;
70656
+ }
70573
70657
  writeState(stateDir, currentState);
70574
70658
  }
70575
70659
  const isResume2 = currentState.iteration > 0;
@@ -70598,6 +70682,27 @@ function useLoop(opts) {
70598
70682
  }
70599
70683
  const tasksContent = storage.read(join11(tasksDir, MISSION_TASKS_FILENAME));
70600
70684
  const agentTasksContent = storage.read(join11(tasksDir, AGENT_TASKS_FILENAME));
70685
+ if (tasksContent === null && currentState.iteration > 0 && typeof opts.changeStore.listChanges === "function") {
70686
+ let stillActive = true;
70687
+ try {
70688
+ const active = await opts.changeStore.listChanges();
70689
+ stillActive = active.includes(opts.name);
70690
+ } catch {
70691
+ stillActive = true;
70692
+ }
70693
+ if (!stillActive) {
70694
+ addInfo(`tasks.md not found and change "${opts.name}" is no longer active \u2014 it was archived externally. Exiting.`);
70695
+ currentState = {
70696
+ ...currentState,
70697
+ status: "completed",
70698
+ lastModified: new Date().toISOString()
70699
+ };
70700
+ writeState(stateDir, currentState);
70701
+ setState(currentState);
70702
+ finalStopReason = "completed";
70703
+ break;
70704
+ }
70705
+ }
70601
70706
  if (tasksContent !== null) {
70602
70707
  const remaining = countUnchecked(tasksContent);
70603
70708
  const agentRemaining = agentTasksContent !== null ? countUnchecked(agentTasksContent) : 0;
@@ -70863,14 +70968,8 @@ function TaskLoop({ opts }) {
70863
70968
  const { exit } = use_app_default();
70864
70969
  const loop = useLoop(opts);
70865
70970
  const { isRawModeSupported } = use_stdin_default();
70866
- const { stdout } = use_stdout_default();
70867
70971
  const { resizeKey } = useTerminalSize();
70868
70972
  const bannerItem = import_react56.useRef({ id: "__banner__", kind: "banner" });
70869
- import_react56.useEffect(() => {
70870
- if (resizeKey === 0)
70871
- return;
70872
- stdout.write("\x1B[2J\x1B[3J\x1B[H");
70873
- }, [resizeKey, stdout]);
70874
70973
  const feedItems = import_react56.useMemo(() => [
70875
70974
  bannerItem.current,
70876
70975
  ...loop.logLines.map((e) => ({ id: e.id, kind: "entry", entry: e }))
@@ -92410,18 +92509,18 @@ var init_zod2 = __esm(() => {
92410
92509
  });
92411
92510
 
92412
92511
  // packages/workflow/src/schema.ts
92413
- var MarkerSchema, GetIndicatorSchema, SetIndicatorSchema, IndicatorsSchema, ProjectSchema, CommandsSchema, BoundariesSchema, WorkflowConfigSchema;
92512
+ var MarkerSchema, GetIndicatorSchema, SetIndicatorSchema, IndicatorsSchema, ProjectSchema, CommandsSchema, DEFAULT_META_ONLY_FILES, BoundariesSchema, WorkflowConfigSchema;
92414
92513
  var init_schema = __esm(() => {
92415
92514
  init_zod2();
92416
92515
  MarkerSchema = exports_external2.object({
92417
- type: exports_external2.enum(["label", "status", "attachment"]),
92516
+ type: exports_external2.enum(["label", "status", "attachment", "project"]),
92418
92517
  value: exports_external2.string().min(1)
92419
92518
  });
92420
92519
  GetIndicatorSchema = exports_external2.object({
92421
92520
  filter: exports_external2.array(MarkerSchema).default([])
92422
92521
  });
92423
92522
  SetIndicatorSchema = exports_external2.union([exports_external2.array(MarkerSchema).min(1), MarkerSchema]);
92424
- IndicatorsSchema = exports_external2.object({
92523
+ IndicatorsSchema = exports_external2.preprocess((v) => v == null ? {} : v, exports_external2.object({
92425
92524
  getTodo: GetIndicatorSchema.optional(),
92426
92525
  getInProgress: GetIndicatorSchema.optional(),
92427
92526
  getConflicted: GetIndicatorSchema.optional(),
@@ -92450,7 +92549,7 @@ var init_schema = __esm(() => {
92450
92549
  }
92451
92550
  }
92452
92551
  }
92453
- });
92552
+ }));
92454
92553
  ProjectSchema = exports_external2.object({
92455
92554
  name: exports_external2.string().optional(),
92456
92555
  language: exports_external2.string().optional(),
@@ -92462,9 +92561,17 @@ var init_schema = __esm(() => {
92462
92561
  build: exports_external2.string().optional(),
92463
92562
  typecheck: exports_external2.string().optional()
92464
92563
  }).catchall(exports_external2.string()).default({});
92564
+ DEFAULT_META_ONLY_FILES = [
92565
+ "openspec/**",
92566
+ ".ralph/**",
92567
+ "**/agent-tasks.md",
92568
+ "**/tasks.md",
92569
+ "**/MANUAL_TESTING*.md"
92570
+ ];
92465
92571
  BoundariesSchema = exports_external2.object({
92466
- never_touch: exports_external2.array(exports_external2.string()).default([])
92467
- }).strict().default({ never_touch: [] });
92572
+ never_touch: exports_external2.array(exports_external2.string()).default([]),
92573
+ meta_only_files: exports_external2.array(exports_external2.string()).default(DEFAULT_META_ONLY_FILES)
92574
+ }).strict().default({ never_touch: [], meta_only_files: DEFAULT_META_ONLY_FILES });
92468
92575
  WorkflowConfigSchema = exports_external2.object({
92469
92576
  project: ProjectSchema,
92470
92577
  commands: CommandsSchema,
@@ -92501,20 +92608,20 @@ var init_schema = __esm(() => {
92501
92608
  assignee: exports_external2.string().optional(),
92502
92609
  postComments: exports_external2.boolean().default(true),
92503
92610
  updateEveryIterations: exports_external2.number().int().nonnegative().default(10),
92504
- mentionTrigger: exports_external2.boolean().default(false),
92611
+ mentionTrigger: exports_external2.boolean().default(true),
92505
92612
  mentionHandle: exports_external2.string().default("@ralphy"),
92506
- codeReviewTrigger: exports_external2.boolean().default(false),
92613
+ codeReviewTrigger: exports_external2.boolean().default(true),
92507
92614
  codeReviewStaleHours: exports_external2.number().nonnegative().default(24),
92508
- syncTasksToDescription: exports_external2.boolean().default(false),
92615
+ syncTasksToComment: exports_external2.boolean().default(true),
92509
92616
  indicators: IndicatorsSchema.default({})
92510
92617
  }).strict().default({
92511
92618
  postComments: true,
92512
92619
  updateEveryIterations: 10,
92513
- mentionTrigger: false,
92620
+ mentionTrigger: true,
92514
92621
  mentionHandle: "@ralphy",
92515
- codeReviewTrigger: false,
92622
+ codeReviewTrigger: true,
92516
92623
  codeReviewStaleHours: 24,
92517
- syncTasksToDescription: false,
92624
+ syncTasksToComment: true,
92518
92625
  indicators: {}
92519
92626
  }),
92520
92627
  github: exports_external2.object({
@@ -92573,70 +92680,71 @@ boundaries:
92573
92680
  never_touch:
92574
92681
  - "dist/**"
92575
92682
  - ".claude/worktrees/**"
92576
-
92683
+ # Files that count as "meta only" for the pre-PR substantive-diff guard.
92684
+ # If every changed file matches one of these globs, the loop refuses to
92685
+ # open the PR and respawns the worker \u2014 the actual implementation was
92686
+ # lost (either deleted mid-loop or absorbed by a merge from base).
92687
+ meta_only_files:
92688
+ - "openspec/**"
92689
+ - ".ralph/**"
92690
+ - "**/agent-tasks.md"
92691
+ - "**/tasks.md"
92692
+ - "**/MANUAL_TESTING*.md"
92693
+
92694
+ # \u2500\u2500\u2500 Scheduling \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
92577
92695
  # How many tasks to run in parallel.
92578
92696
  concurrency: 1
92579
-
92580
92697
  # Seconds between polls for new Linear issues (agent mode).
92581
92698
  pollIntervalSeconds: 60
92699
+ # Seconds to wait between loop iterations (throttle).
92700
+ iterationDelaySeconds: 0
92582
92701
 
92583
- # Maximum iterations per task. 0 = unlimited.
92702
+ # \u2500\u2500\u2500 Per-task limits (0 = unlimited) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
92584
92703
  maxIterationsPerTask: 0
92585
-
92586
- # Maximum cost in USD per task. 0 = unlimited.
92587
92704
  maxCostUsdPerTask: 0
92588
-
92589
- # Maximum wall-clock minutes per task. 0 = unlimited.
92590
92705
  maxRuntimeMinutesPerTask: 0
92591
-
92592
92706
  # Stop a task after this many consecutive identical failures.
92593
92707
  maxConsecutiveFailuresPerTask: 5
92594
92708
 
92595
- # Seconds to wait between loop iterations (throttle).
92596
- iterationDelaySeconds: 0
92597
-
92709
+ # \u2500\u2500\u2500 Engine \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
92710
+ # Underlying engine: "claude" or "codex".
92711
+ engine: claude
92712
+ # Model tier: "haiku", "sonnet", or "opus".
92713
+ model: opus
92598
92714
  # Log the raw engine stream to stdout.
92599
92715
  logRawStream: false
92600
-
92601
92716
  # Pass --verbose to the ralph task sub-process.
92602
92717
  taskVerbose: false
92603
92718
 
92719
+ # \u2500\u2500\u2500 Worktree \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
92604
92720
  # Run each task in an isolated git worktree.
92605
92721
  useWorktree: false
92606
-
92607
92722
  # Delete the worktree after a successful task.
92608
92723
  cleanupWorktreeOnSuccess: false
92609
92724
 
92725
+ # \u2500\u2500\u2500 Pull requests \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
92610
92726
  # Open a pull request after a task succeeds.
92611
92727
  createPrOnSuccess: false
92612
-
92613
92728
  # Base branch for pull requests.
92614
92729
  prBaseBranch: main
92615
-
92616
92730
  # When true, stack dependent issues' PRs onto their blocker's open PR.
92617
92731
  stackPrsOnDependencies: false
92618
-
92619
92732
  # Strategy used when GitHub auto-merge is enabled.
92620
92733
  autoMergeStrategy: squash
92621
92734
 
92735
+ # \u2500\u2500\u2500 CI auto-fix \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
92622
92736
  # Let the agent attempt to fix CI failures after a PR is created.
92623
92737
  fixCiOnFailure: false
92624
-
92625
92738
  # Maximum number of CI-fix attempts per task.
92626
92739
  maxCiFixAttempts: 5
92627
-
92628
92740
  # Seconds between CI status polls.
92629
92741
  ciPollIntervalSeconds: 30
92630
92742
 
92631
- # Underlying engine: "claude" or "codex".
92632
- engine: claude
92633
-
92634
- # Model tier: "haiku", "sonnet", or "opus".
92635
- model: opus
92636
-
92637
- # Pre-existing error check: gate the agent when the base branch is already broken.
92638
- # When enabled, the agent runs these commands against the base branch HEAD before
92639
- # scheduling new work; failures open a Linear ticket and pause new pickups.
92743
+ # \u2500\u2500\u2500 Base-branch health gate \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
92744
+ # Pre-existing error check: gate the agent when the base branch is already
92745
+ # broken. When enabled, the agent runs these commands against the base
92746
+ # branch HEAD before scheduling new work; failures open a Linear ticket
92747
+ # and pause new pickups.
92640
92748
  preExistingErrorCheck:
92641
92749
  enabled: false
92642
92750
  # Commands to run against the base branch. When empty, falls back to commands.lint / commands.test.
@@ -92645,38 +92753,49 @@ preExistingErrorCheck:
92645
92753
  label: "ralph:pre-existing-error"
92646
92754
  outputCharLimit: 4000
92647
92755
 
92756
+ # \u2500\u2500\u2500 Linear integration \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
92648
92757
  linear:
92649
92758
  # Linear team key (e.g. "ENG"). Omit to match all teams.
92650
92759
  # team: ENG
92651
92760
 
92652
92761
  # Post progress comments on the Linear issue while a task is running.
92653
92762
  postComments: true
92654
-
92655
92763
  # Post a progress update every N iterations. 0 disables.
92656
92764
  updateEveryIterations: 10
92657
92765
 
92658
92766
  # Watch done-issue comments + linked GitHub PR comments for @ralphy mentions.
92659
- mentionTrigger: false
92767
+ mentionTrigger: true
92660
92768
  mentionHandle: "@ralphy"
92661
92769
 
92662
92770
  # Watch open tracked PRs for unresolved review-thread comments.
92663
- codeReviewTrigger: false
92771
+ codeReviewTrigger: true
92664
92772
  codeReviewStaleHours: 24
92665
92773
 
92666
- # Mirror the loop's tasks.md into the Linear issue description as a
92667
- # checklist between sentinel markers. Updates on worker launch, on the
92668
- # same cadence as updateEveryIterations, and on done-transition.
92669
- syncTasksToDescription: false
92774
+ # Mirror the loop's tasks.md into a sticky Linear comment (always the
92775
+ # last comment on the issue). Updates on worker launch, on the same
92776
+ # cadence as updateEveryIterations, and on done-transition.
92777
+ syncTasksToComment: true
92670
92778
 
92671
92779
  # Indicators map Ralph lifecycle events to Linear labels/statuses.
92672
- # Grouped by lifecycle: each get* is followed by the set*/clear* that
92673
- # mutates the same state, so the lifecycle reads top-to-bottom.
92674
- indicators: {}
92675
- # Todo -> In Progress
92780
+ #
92781
+ # Filter semantics (per indicator's \`filter:\` list):
92782
+ # \u2022 Entries of the SAME type (e.g. two \`status\` entries) are ORed
92783
+ # \u2014 the issue matches if any value matches.
92784
+ # \u2022 Entries of DIFFERENT types (one \`status\` + one \`label\`) are
92785
+ # ANDed \u2014 the issue must satisfy every type.
92786
+ # Example: a filter with two statuses + one label matches issues
92787
+ # where status \u2208 {A, B} AND label = L.
92788
+ #
92789
+ # Sections below group one state at a time; its get/set/clear sit
92790
+ # adjacent so the lifecycle reads top-to-bottom.
92791
+ indicators:
92792
+ # \u2500\u2500 Todo (pickup trigger) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
92676
92793
  # getTodo:
92677
92794
  # filter:
92678
92795
  # - type: status
92679
92796
  # value: Todo
92797
+ #
92798
+ # \u2500\u2500 In Progress \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
92680
92799
  # getInProgress:
92681
92800
  # filter:
92682
92801
  # - type: status
@@ -92685,7 +92804,7 @@ linear:
92685
92804
  # type: status
92686
92805
  # value: In Progress
92687
92806
  #
92688
- # # Done / review hand-off
92807
+ # \u2500\u2500 Done \u2192 Review hand-off \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
92689
92808
  # setDone:
92690
92809
  # type: status
92691
92810
  # value: In Review
@@ -92697,7 +92816,7 @@ linear:
92697
92816
  # type: label
92698
92817
  # value: "ralph:review"
92699
92818
  #
92700
- # # Conflict lifecycle
92819
+ # \u2500\u2500 Conflicted \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
92701
92820
  # getConflicted:
92702
92821
  # filter:
92703
92822
  # - type: label
@@ -92709,16 +92828,27 @@ linear:
92709
92828
  # type: label
92710
92829
  # value: "ralph:conflict"
92711
92830
  #
92712
- # # Auto-merge opt-in
92831
+ # \u2500\u2500 Auto-merge (opt-in) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
92713
92832
  # getAutoMerge:
92714
92833
  # filter:
92715
92834
  # - type: label
92716
92835
  # value: "ralph:auto-merge"
92717
92836
  #
92718
- # # Error quarantine
92837
+ # \u2500\u2500 Error quarantine \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
92719
92838
  # setError:
92720
92839
  # type: label
92721
92840
  # value: "ralph:error"
92841
+ #
92842
+ # # Project-based filter / assignment
92843
+ # # getTodo can filter by Linear project name, and setInProgress can
92844
+ # # reassign the issue into a different project.
92845
+ # getTodo:
92846
+ # filter:
92847
+ # - type: project
92848
+ # value: "Ralph Queue"
92849
+ # setInProgress:
92850
+ # type: project
92851
+ # value: "Ralph In Progress"
92722
92852
  ---
92723
92853
  You are working on {{ issue.identifier }}: {{ issue.title }}.
92724
92854
 
@@ -93526,15 +93656,18 @@ function partition2(markers) {
93526
93656
  const statuses = [];
93527
93657
  const labels = [];
93528
93658
  const attachmentSubtitles = [];
93659
+ const projects = [];
93529
93660
  for (const m of markers) {
93530
93661
  if (m.type === "status")
93531
93662
  statuses.push(m.value);
93532
93663
  else if (m.type === "label")
93533
93664
  labels.push(m.value);
93534
- else
93665
+ else if (m.type === "attachment")
93535
93666
  attachmentSubtitles.push(m.value);
93667
+ else if (m.type === "project")
93668
+ projects.push(m.value);
93536
93669
  }
93537
- return { statuses, labels, attachmentSubtitles };
93670
+ return { statuses, labels, attachmentSubtitles, projects };
93538
93671
  }
93539
93672
  function buildIssueFilter(spec) {
93540
93673
  const where = {};
@@ -93550,7 +93683,7 @@ function buildIssueFilter(spec) {
93550
93683
  }
93551
93684
  const inc = spec.include ?? [];
93552
93685
  if (inc.length > 0) {
93553
- const { statuses, labels, attachmentSubtitles } = partition2(inc);
93686
+ const { statuses, labels, attachmentSubtitles, projects } = partition2(inc);
93554
93687
  const branches = [];
93555
93688
  if (statuses.length > 0)
93556
93689
  branches.push({ state: { name: { in: statuses } } });
@@ -93566,6 +93699,8 @@ function buildIssueFilter(spec) {
93566
93699
  }
93567
93700
  });
93568
93701
  }
93702
+ if (projects.length > 0)
93703
+ branches.push({ project: { name: { in: projects } } });
93569
93704
  for (const b of branches)
93570
93705
  Object.assign(where, b);
93571
93706
  } else {
@@ -93573,7 +93708,23 @@ function buildIssueFilter(spec) {
93573
93708
  }
93574
93709
  const exc = spec.exclude ?? [];
93575
93710
  if (exc.length > 0) {
93576
- const { statuses, labels, attachmentSubtitles: excludedSubtitles } = partition2(exc);
93711
+ const {
93712
+ statuses,
93713
+ labels,
93714
+ attachmentSubtitles: excludedSubtitles,
93715
+ projects: excludedProjects
93716
+ } = partition2(exc);
93717
+ if (excludedProjects.length > 0) {
93718
+ const current = where.project;
93719
+ const noProject = { project: { name: { nin: excludedProjects } } };
93720
+ if (current === undefined)
93721
+ Object.assign(where, noProject);
93722
+ else {
93723
+ const existingAnd = where.and ?? [];
93724
+ where.and = [...existingAnd, { project: current }, noProject];
93725
+ delete where.project;
93726
+ }
93727
+ }
93577
93728
  if (excludedSubtitles.length > 0) {
93578
93729
  const existingAnd = where.and ?? [];
93579
93730
  where.and = [
@@ -93632,10 +93783,14 @@ async function fetchMentionScanIssues(apiKey, spec) {
93632
93783
  id identifier title description url priority createdAt
93633
93784
  state { name type }
93634
93785
  assignee { id email name }
93786
+ project { id name }
93635
93787
  labels { nodes { name } }
93636
93788
  relations(first: 50) {
93637
93789
  nodes { type relatedIssue { id state { type } } }
93638
93790
  }
93791
+ comments(first: 50) {
93792
+ nodes { id body createdAt user { name email } }
93793
+ }
93639
93794
  }
93640
93795
  }
93641
93796
  }`;
@@ -93651,10 +93806,12 @@ async function fetchMentionScanIssues(apiKey, spec) {
93651
93806
  url: n.url,
93652
93807
  state: n.state,
93653
93808
  assignee: n.assignee,
93809
+ project: n.project ?? null,
93654
93810
  labels: n.labels.nodes.map((l) => l.name),
93655
93811
  priority: n.priority,
93656
93812
  createdAt: n.createdAt ?? "",
93657
- blockedByIds: (n.relations?.nodes ?? []).filter((r) => r.type === "blocked_by" && !DONE_STATE_TYPES.has(r.relatedIssue.state.type)).map((r) => r.relatedIssue.id)
93813
+ blockedByIds: (n.relations?.nodes ?? []).filter((r) => r.type === "blocked_by" && !DONE_STATE_TYPES.has(r.relatedIssue.state.type)).map((r) => r.relatedIssue.id),
93814
+ comments: n.comments?.nodes ?? []
93658
93815
  }));
93659
93816
  }
93660
93817
  async function fetchOpenIssues(apiKey, spec) {
@@ -93665,6 +93822,7 @@ async function fetchOpenIssues(apiKey, spec) {
93665
93822
  id identifier title description url priority createdAt
93666
93823
  state { name type }
93667
93824
  assignee { id email name }
93825
+ project { id name }
93668
93826
  labels { nodes { name } }
93669
93827
  relations(first: 50) {
93670
93828
  nodes {
@@ -93687,34 +93845,109 @@ async function fetchOpenIssues(apiKey, spec) {
93687
93845
  url: n.url,
93688
93846
  state: n.state,
93689
93847
  assignee: n.assignee,
93848
+ project: n.project ?? null,
93690
93849
  labels: n.labels.nodes.map((l) => l.name),
93691
93850
  priority: n.priority,
93692
93851
  createdAt: n.createdAt ?? "",
93693
93852
  blockedByIds: (n.relations?.nodes ?? []).filter((r) => r.type === "blocked_by" && !DONE_STATE_TYPES.has(r.relatedIssue.state.type)).map((r) => r.relatedIssue.id)
93694
93853
  }));
93695
93854
  }
93855
+ function isRetryableStatus(status) {
93856
+ return status >= 500 && status <= 599;
93857
+ }
93858
+ function parseRetryAfter(header) {
93859
+ if (!header)
93860
+ return;
93861
+ const trimmed = header.trim();
93862
+ if (!trimmed)
93863
+ return;
93864
+ const asNum = Number(trimmed);
93865
+ if (Number.isFinite(asNum))
93866
+ return Math.max(0, asNum * 1000);
93867
+ const asDate = Date.parse(trimmed);
93868
+ if (Number.isFinite(asDate))
93869
+ return Math.max(0, asDate - Date.now());
93870
+ return;
93871
+ }
93872
+ function backoffMs(attempt2) {
93873
+ const base2 = 250 * 2 ** (attempt2 - 1);
93874
+ const jitter = Math.floor(Math.random() * 100);
93875
+ return base2 + jitter;
93876
+ }
93877
+ function isRateLimitedBody(body) {
93878
+ if (typeof body !== "string" || body.length === 0)
93879
+ return false;
93880
+ return body.toLowerCase().includes("rate limit exceeded");
93881
+ }
93882
+ function isRateLimitedError(err) {
93883
+ if (err === null || typeof err !== "object")
93884
+ return false;
93885
+ return err.rateLimited === true;
93886
+ }
93887
+ function formatLinearError(err) {
93888
+ if (err === null || err === undefined)
93889
+ return String(err);
93890
+ if (typeof err !== "object")
93891
+ return String(err);
93892
+ const e = err;
93893
+ const parts = [];
93894
+ if (e.rateLimited)
93895
+ parts.push("rate limited");
93896
+ if (typeof e.status === "number")
93897
+ parts.push(`HTTP ${e.status}`);
93898
+ if (Array.isArray(e.messages) && e.messages.length > 0) {
93899
+ parts.push(`graphql: ${e.messages.join("; ")}`);
93900
+ }
93901
+ if (typeof e.body === "string" && e.body.length > 0 && !e.rateLimited) {
93902
+ const truncated = e.body.length > 200 ? `${e.body.slice(0, 200)}\u2026` : e.body;
93903
+ parts.push(`body: ${truncated}`);
93904
+ }
93905
+ if (parts.length === 0) {
93906
+ if (typeof e.message === "string" && e.message)
93907
+ return e.message;
93908
+ return String(err);
93909
+ }
93910
+ if (typeof e.message === "string" && e.message && !e.rateLimited)
93911
+ parts.unshift(e.message);
93912
+ return parts.join(" \u2014 ");
93913
+ }
93696
93914
  async function linearRequest(apiKey, query, variables) {
93697
- const res = await fetch(LINEAR_API, {
93698
- method: "POST",
93699
- headers: { "Content-Type": "application/json", Authorization: apiKey },
93700
- body: JSON.stringify({ query, variables })
93701
- });
93702
- if (!res.ok) {
93703
- const err = new Error("Linear API request failed");
93704
- err.status = res.status;
93705
- err.body = await res.text();
93706
- throw err;
93707
- }
93708
- const json2 = await res.json();
93709
- if (json2.errors?.length) {
93710
- const err = new Error("Linear API returned errors");
93711
- err.messages = json2.errors.map((e) => e.message);
93712
- throw err;
93713
- }
93714
- if (!json2.data) {
93715
- throw new Error("Linear API returned no data");
93915
+ let lastHttpError;
93916
+ for (let attempt2 = 1;attempt2 <= MAX_LINEAR_ATTEMPTS; attempt2++) {
93917
+ const res = await fetch(LINEAR_API, {
93918
+ method: "POST",
93919
+ headers: { "Content-Type": "application/json", Authorization: apiKey },
93920
+ body: JSON.stringify({ query, variables })
93921
+ });
93922
+ if (!res.ok) {
93923
+ const err = new Error("Linear API request failed");
93924
+ err.status = res.status;
93925
+ err.body = await res.text();
93926
+ if (res.status === 429 || isRateLimitedBody(err.body)) {
93927
+ err.rateLimited = true;
93928
+ throw err;
93929
+ }
93930
+ lastHttpError = err;
93931
+ if (isRetryableStatus(res.status) && attempt2 < MAX_LINEAR_ATTEMPTS) {
93932
+ const ra = parseRetryAfter(res.headers.get("Retry-After"));
93933
+ const waitMs = Math.min(ra ?? backoffMs(attempt2), MAX_RETRY_AFTER_MS);
93934
+ await linearRequestInternals.sleep(waitMs);
93935
+ continue;
93936
+ }
93937
+ throw err;
93938
+ }
93939
+ const json2 = await res.json();
93940
+ if (json2.errors?.length) {
93941
+ const err = new Error("Linear API returned errors");
93942
+ err.messages = json2.errors.map((e) => e.message);
93943
+ throw err;
93944
+ }
93945
+ if (!json2.data) {
93946
+ throw new Error("Linear API returned no data");
93947
+ }
93948
+ return json2.data;
93716
93949
  }
93717
- return json2.data;
93950
+ throw lastHttpError ?? new Error("Linear API request failed");
93718
93951
  }
93719
93952
  async function addReactionToComment(apiKey, commentId, emoji3) {
93720
93953
  const mutation = `mutation Reaction($commentId: String!, $emoji: String!) {
@@ -93734,6 +93967,36 @@ async function addIssueComment(apiKey, issueId, body) {
93734
93967
  body
93735
93968
  });
93736
93969
  }
93970
+ async function createIssueComment(apiKey, issueId, body) {
93971
+ const mutation = `mutation Comment($issueId: String!, $body: String!) {
93972
+ commentCreate(input: { issueId: $issueId, body: $body }) {
93973
+ success
93974
+ comment { id }
93975
+ }
93976
+ }`;
93977
+ const data = await linearRequest(apiKey, mutation, { issueId, body });
93978
+ const id = data.commentCreate.comment?.id;
93979
+ if (!id)
93980
+ throw new Error("commentCreate returned no comment id");
93981
+ return id;
93982
+ }
93983
+ async function updateIssueComment(apiKey, commentId, body) {
93984
+ const mutation = `mutation UpdateComment($id: String!, $body: String!) {
93985
+ commentUpdate(id: $id, input: { body: $body }) { success }
93986
+ }`;
93987
+ await linearRequest(apiKey, mutation, {
93988
+ id: commentId,
93989
+ body
93990
+ });
93991
+ }
93992
+ async function deleteIssueComment(apiKey, commentId) {
93993
+ const mutation = `mutation DeleteComment($id: String!) {
93994
+ commentDelete(id: $id) { success }
93995
+ }`;
93996
+ await linearRequest(apiKey, mutation, {
93997
+ id: commentId
93998
+ });
93999
+ }
93737
94000
  async function fetchIssueComments(apiKey, issueId) {
93738
94001
  const query = `query Comments($id: String!) {
93739
94002
  issue(id: $id) {
@@ -93775,25 +94038,55 @@ async function updateAttachmentSubtitle(apiKey, attachmentId, subtitle) {
93775
94038
  });
93776
94039
  }
93777
94040
  async function upsertRalphyAttachment(apiKey, issueId, issueUrl, subtitle) {
93778
- const attachments = await fetchIssueAttachments(apiKey, issueId);
93779
- const existing = attachments.find((a) => a.title === RALPHY_ATTACHMENT_TITLE);
94041
+ const attachments = await fetchIssueAttachments(apiKey, issueId, {
94042
+ titleFilter: RALPHY_ATTACHMENT_TITLE
94043
+ });
94044
+ const existing = attachments[0];
93780
94045
  if (existing) {
93781
94046
  await updateAttachmentSubtitle(apiKey, existing.id, subtitle);
93782
94047
  } else {
93783
94048
  await createRalphyAttachment(apiKey, issueId, issueUrl, subtitle);
93784
94049
  }
93785
94050
  }
93786
- async function fetchIssueAttachments(apiKey, issueId) {
93787
- const query = `query IssueAttachments($id: String!) {
94051
+ async function fetchIssueAttachments(apiKey, issueId, options) {
94052
+ const titleFilter = options?.titleFilter;
94053
+ const query = titleFilter !== undefined ? `query IssueAttachments($id: String!, $titleFilter: String!) {
94054
+ issue(id: $id) {
94055
+ attachments(filter: { title: { eq: $titleFilter } }, first: 25) {
94056
+ nodes { id url sourceType title }
94057
+ }
94058
+ }
94059
+ }` : `query IssueAttachments($id: String!) {
93788
94060
  issue(id: $id) {
93789
94061
  attachments(first: 25) {
93790
94062
  nodes { id url sourceType title }
93791
94063
  }
93792
94064
  }
93793
94065
  }`;
93794
- const data = await linearRequest(apiKey, query, { id: issueId });
94066
+ const variables = titleFilter !== undefined ? { id: issueId, titleFilter } : { id: issueId };
94067
+ const data = await linearRequest(apiKey, query, variables);
93795
94068
  return data.issue?.attachments?.nodes ?? [];
93796
94069
  }
94070
+ async function fetchAttachmentsForIssues(apiKey, issueIds) {
94071
+ const out = new Map;
94072
+ if (issueIds.length === 0)
94073
+ return out;
94074
+ const query = `query IssuesAttachments($ids: [ID!]!) {
94075
+ issues(filter: { id: { in: $ids } }, first: 250) {
94076
+ nodes {
94077
+ id
94078
+ attachments(first: 25) {
94079
+ nodes { id url sourceType title }
94080
+ }
94081
+ }
94082
+ }
94083
+ }`;
94084
+ const data = await linearRequest(apiKey, query, { ids: issueIds });
94085
+ for (const node2 of data.issues.nodes) {
94086
+ out.set(node2.id, node2.attachments?.nodes ?? []);
94087
+ }
94088
+ return out;
94089
+ }
93797
94090
  async function fetchWorkflowStates(apiKey, teamKey) {
93798
94091
  const query = `query States($team: String!) {
93799
94092
  workflowStates(filter: { team: { key: { eq: $team } } }, first: 50) {
@@ -93892,14 +94185,40 @@ function issueMatchesGetIndicator(issue2, indicator) {
93892
94185
  return false;
93893
94186
  const labels = new Set(issue2.labels.map((l) => l.toLowerCase()));
93894
94187
  const stateName = issue2.state.name.toLowerCase();
94188
+ const projectName = issue2.project?.name.toLowerCase() ?? null;
93895
94189
  return indicator.filter.some((m) => {
93896
94190
  if (m.type === "label")
93897
94191
  return labels.has(m.value.toLowerCase());
93898
94192
  if (m.type === "status")
93899
94193
  return stateName === m.value.toLowerCase();
94194
+ if (m.type === "project") {
94195
+ if (projectName === null)
94196
+ return false;
94197
+ return projectName === m.value.toLowerCase();
94198
+ }
93900
94199
  return false;
93901
94200
  });
93902
94201
  }
94202
+ async function fetchProjectIdByName(apiKey, name) {
94203
+ const query = `query ProjectId($name: String!) {
94204
+ projects(filter: { name: { eq: $name } }, first: 1) {
94205
+ nodes { id }
94206
+ }
94207
+ }`;
94208
+ const data = await linearRequest(apiKey, query, {
94209
+ name
94210
+ });
94211
+ return data.projects.nodes[0]?.id ?? null;
94212
+ }
94213
+ async function setIssueProject(apiKey, issueId, projectId) {
94214
+ const mutation = `mutation SetProject($id: String!, $projectId: String!) {
94215
+ issueUpdate(id: $id, input: { projectId: $projectId }) { success }
94216
+ }`;
94217
+ await linearRequest(apiKey, mutation, {
94218
+ id: issueId,
94219
+ projectId
94220
+ });
94221
+ }
93903
94222
  async function createIssue(apiKey, input) {
93904
94223
  const mutation = `mutation CreateIssue($input: IssueCreateInput!) {
93905
94224
  issueCreate(input: $input) {
@@ -93956,7 +94275,12 @@ async function removeLabelFromIssue(apiKey, issueId, labelId) {
93956
94275
  labelId
93957
94276
  });
93958
94277
  }
93959
- var LINEAR_API = "https://api.linear.app/graphql", RALPHY_ATTACHMENT_TITLE_FILTER = "Ralphy", RALPHY_ATTACHMENT_TITLE = "Ralphy", BRANCH_LABEL_PREFIX = "ralph:branch:";
94278
+ var LINEAR_API = "https://api.linear.app/graphql", RALPHY_ATTACHMENT_TITLE_FILTER = "Ralphy", linearRequestInternals, MAX_LINEAR_ATTEMPTS = 3, MAX_RETRY_AFTER_MS = 2000, RALPHY_ATTACHMENT_TITLE = "Ralphy", BRANCH_LABEL_PREFIX = "ralph:branch:";
94279
+ var init_linear = __esm(() => {
94280
+ linearRequestInternals = {
94281
+ sleep: (ms) => Bun.sleep(ms)
94282
+ };
94283
+ });
93960
94284
 
93961
94285
  // apps/agent/src/sort/compare.ts
93962
94286
  function chain(...comparators) {
@@ -93987,6 +94311,7 @@ function compareQueueEntries(getAutoMerge) {
93987
94311
  }
93988
94312
  var MODE_RANK;
93989
94313
  var init_queue_order = __esm(() => {
94314
+ init_linear();
93990
94315
  MODE_RANK = {
93991
94316
  resume: 0,
93992
94317
  "conflict-fix": 1,
@@ -94428,6 +94753,15 @@ class AgentCoordinator {
94428
94753
  } catch {}
94429
94754
  return true;
94430
94755
  }
94756
+ async notifySteeringAppended(changeName, message) {
94757
+ if (!this.deps.onSteeringAppended)
94758
+ return;
94759
+ try {
94760
+ await this.deps.onSteeringAppended(changeName, message);
94761
+ } catch (err) {
94762
+ this.deps.onLog(`! onSteeringAppended failed for ${changeName}: ${err.message}`, "yellow");
94763
+ }
94764
+ }
94431
94765
  async notifyExited(issue2, changeName, code, mode) {
94432
94766
  const ok = code === 0;
94433
94767
  if (this.deps.syncTasks && ok) {
@@ -94537,6 +94871,7 @@ var emptyPrStatus = () => ({ mergeable: 0, conflicted: 0, ciFailed: 0 }), emptyP
94537
94871
  prStatus: emptyPrStatus()
94538
94872
  });
94539
94873
  var init_coordinator = __esm(() => {
94874
+ init_linear();
94540
94875
  init_queue_order();
94541
94876
  init_src();
94542
94877
  });
@@ -94720,6 +95055,64 @@ async function seedWorktreeMcpConfig(projectRoot, worktreeCwd) {
94720
95055
  }
94721
95056
  var init_worktree = () => {};
94722
95057
 
95058
+ // apps/agent/src/agent/pr-url/index.ts
95059
+ function escapeRegex2(s) {
95060
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
95061
+ }
95062
+ async function discoverPrUrlFromGitHub(identifier, runner, cwd2, onLog) {
95063
+ if (!identifier)
95064
+ return null;
95065
+ const slug = identifier.toLowerCase();
95066
+ let rows;
95067
+ try {
95068
+ const res = await runner.run([
95069
+ "gh",
95070
+ "pr",
95071
+ "list",
95072
+ "--search",
95073
+ `${identifier} in:title`,
95074
+ "--state",
95075
+ "all",
95076
+ "--json",
95077
+ "url,state,headRefName,title,updatedAt"
95078
+ ], cwd2);
95079
+ const text = res.stdout.trim();
95080
+ rows = text ? JSON.parse(text) : [];
95081
+ } catch (err) {
95082
+ onLog?.(`! gh pr list (${identifier}) failed: ${err.message}`, "yellow");
95083
+ return null;
95084
+ }
95085
+ const idRe = new RegExp(`\\b${escapeRegex2(identifier)}\\b`, "i");
95086
+ const matches2 = rows.filter((r) => Boolean(r.url) && (idRe.test(r.title ?? "") || (r.headRefName ?? "").toLowerCase().includes(slug)));
95087
+ if (matches2.length === 0)
95088
+ return null;
95089
+ const open = matches2.filter((r) => r.state === "OPEN");
95090
+ const pool = open.length > 0 ? open : matches2;
95091
+ pool.sort((a, b) => (b.updatedAt ?? "").localeCompare(a.updatedAt ?? ""));
95092
+ return pool[0]?.url ?? null;
95093
+ }
95094
+ function createPrUrlCache(ttlMs = 5 * 60 * 1000, now2 = Date.now) {
95095
+ const map3 = new Map;
95096
+ return {
95097
+ get(issueId) {
95098
+ const e = map3.get(issueId);
95099
+ if (!e)
95100
+ return;
95101
+ if (now2() - e.fetchedAt >= ttlMs) {
95102
+ map3.delete(issueId);
95103
+ return;
95104
+ }
95105
+ return e.url;
95106
+ },
95107
+ set(issueId, url2) {
95108
+ map3.set(issueId, { url: url2, fetchedAt: now2() });
95109
+ },
95110
+ invalidate(issueId) {
95111
+ map3.delete(issueId);
95112
+ }
95113
+ };
95114
+ }
95115
+
94723
95116
  // apps/agent/src/agent/ci.ts
94724
95117
  async function runGhWithRetry(cmd, runner, cwd2, onRetry, sleep2 = (ms) => new Promise((r) => setTimeout(r, ms))) {
94725
95118
  let lastErr;
@@ -94905,11 +95298,79 @@ ${issue2.description.trim()}` : ""
94905
95298
  ].filter(Boolean).join(`
94906
95299
  `);
94907
95300
  }
95301
+ async function diffFilesAgainstBase(runner, cwd2, base2) {
95302
+ let raw = "";
95303
+ try {
95304
+ const r = await runner.run(["git", "diff", "--name-only", `origin/${base2}...HEAD`], cwd2);
95305
+ raw = r.stdout;
95306
+ } catch {
95307
+ try {
95308
+ const r = await runner.run(["git", "diff", "--name-only", `${base2}...HEAD`], cwd2);
95309
+ raw = r.stdout;
95310
+ } catch {
95311
+ return [];
95312
+ }
95313
+ }
95314
+ return raw.split(`
95315
+ `).map((s) => s.trim()).filter(Boolean);
95316
+ }
95317
+ async function classifyDiffAgainstMeta(runner, cwd2, base2, metaOnlyFiles) {
95318
+ const files = await diffFilesAgainstBase(runner, cwd2, base2);
95319
+ if (files.length === 0 || metaOnlyFiles.length === 0) {
95320
+ return { files, onlyMeta: false };
95321
+ }
95322
+ const violations = findBoundaryViolations(files, metaOnlyFiles);
95323
+ const metaSet = new Set(violations.map((v) => v.file));
95324
+ const onlyMeta = files.every((f2) => metaSet.has(f2.replace(/\\/g, "/")));
95325
+ return { files, onlyMeta };
95326
+ }
95327
+ async function branchAlreadyMerged(runner, cwd2, branch, base2) {
95328
+ try {
95329
+ const r = await runner.run([
95330
+ "gh",
95331
+ "pr",
95332
+ "list",
95333
+ "--head",
95334
+ branch,
95335
+ "--state",
95336
+ "merged",
95337
+ "--json",
95338
+ "number",
95339
+ "--jq",
95340
+ ".[0].number // empty"
95341
+ ], cwd2);
95342
+ if (r.stdout.trim() !== "")
95343
+ return true;
95344
+ } catch {}
95345
+ try {
95346
+ const r = await runner.run(["git", "cherry", base2, "HEAD"], cwd2);
95347
+ const lines = r.stdout.split(`
95348
+ `).map((s) => s.trim()).filter(Boolean);
95349
+ if (lines.length > 0 && lines.every((l) => l.startsWith("-")))
95350
+ return true;
95351
+ } catch {}
95352
+ return false;
95353
+ }
94908
95354
  async function createPullRequest(input, runner) {
94909
95355
  const base2 = input.base ?? "main";
94910
95356
  const log2 = await runner.run(["git", "log", "--oneline", `${base2}..HEAD`, "--no-merges"], input.cwd);
94911
95357
  if (log2.stdout.trim() === "")
94912
95358
  return null;
95359
+ const metaOnlyFiles = input.metaOnlyFiles ?? [];
95360
+ if (metaOnlyFiles.length > 0) {
95361
+ const classification = await classifyDiffAgainstMeta(runner, input.cwd, base2, metaOnlyFiles);
95362
+ if (classification.onlyMeta && classification.files.length > 0) {
95363
+ if (await branchAlreadyMerged(runner, input.cwd, input.branch, base2)) {
95364
+ return null;
95365
+ }
95366
+ return {
95367
+ url: null,
95368
+ created: false,
95369
+ blocked: "only-meta",
95370
+ blockedFiles: classification.files
95371
+ };
95372
+ }
95373
+ }
94913
95374
  await runner.run(["git", "push", "-u", "origin", input.branch], input.cwd);
94914
95375
  const existing = await runner.run([
94915
95376
  "gh",
@@ -94934,6 +95395,7 @@ async function createPullRequest(input, runner) {
94934
95395
  `).pop() ?? "";
94935
95396
  return { url: url2, created: true };
94936
95397
  }
95398
+ var init_pr = () => {};
94937
95399
 
94938
95400
  // apps/agent/src/agent/post-task.ts
94939
95401
  import { join as join20 } from "path";
@@ -95046,7 +95508,13 @@ async function createPrWithRetry(ctx, issue2) {
95046
95508
  while (true) {
95047
95509
  try {
95048
95510
  ctx.emit("pr-create", "git push + gh pr create");
95049
- pr = await createPullRequest({ cwd: ctx.cwd, branch: ctx.branch, issue: issue2, base: base2 }, ctx.cmd);
95511
+ pr = await createPullRequest({
95512
+ cwd: ctx.cwd,
95513
+ branch: ctx.branch,
95514
+ issue: issue2,
95515
+ base: base2,
95516
+ metaOnlyFiles: ctx.cfg.metaOnlyFiles ?? []
95517
+ }, ctx.cmd);
95050
95518
  return { pr, gaveUp: false };
95051
95519
  } catch (err) {
95052
95520
  const e = err;
@@ -95312,49 +95780,97 @@ ${indented}${suffix}`, "yellow");
95312
95780
  }
95313
95781
  return PR_FAILED_EXIT;
95314
95782
  }
95315
- const { pr, gaveUp: prGaveUp } = await createPrWithRetry(ctx, issue2);
95316
- if (prGaveUp)
95317
- return PR_FAILED_EXIT;
95783
+ const maxOuterAttempts = cfg.maxCiFixAttempts;
95784
+ let onlyMetaAttempts = 0;
95785
+ let pr = null;
95786
+ while (true) {
95787
+ const attempt2 = await createPrWithRetry(ctx, issue2);
95788
+ if (attempt2.gaveUp)
95789
+ return PR_FAILED_EXIT;
95790
+ if (attempt2.pr?.blocked === "only-meta") {
95791
+ onlyMetaAttempts += 1;
95792
+ const files = attempt2.pr.blockedFiles ?? [];
95793
+ emit("pr-only-meta", `${files.length} meta file(s)`);
95794
+ log2(`! ${changeName}: branch diff against ${base2} contains only meta files \u2014 implementation appears lost. Refusing to open PR.`, "red");
95795
+ for (const f2 of files)
95796
+ log2(` ${f2}`, "red");
95797
+ if (onlyMetaAttempts > maxOuterAttempts) {
95798
+ log2(`! exceeded ${maxOuterAttempts} only-meta recovery attempts for ${changeName} \u2014 giving up`, "red");
95799
+ return PR_FAILED_EXIT;
95800
+ }
95801
+ const fileList = files.length > 0 ? files.map((f2) => `- ${f2}`).join(`
95802
+ `) : "(empty diff)";
95803
+ const retryCode = await runWorkerWithFixTask(ctx, "Reapply lost implementation files", [
95804
+ `The diff against \`${base2}\` contains only meta files`,
95805
+ `(openspec/tasks.md and similar). The substantive implementation`,
95806
+ `is missing from the branch \u2014 likely deleted by an earlier commit`,
95807
+ `or absorbed by a merge from origin/${base2}.`,
95808
+ "",
95809
+ `Files currently in the diff:`,
95810
+ fileList,
95811
+ "",
95812
+ `Re-apply the actual implementation work the change is supposed`,
95813
+ `to ship. Inspect git history (\`git log ${base2}..HEAD\`) to see`,
95814
+ `what was created earlier and lost, then restore those files`,
95815
+ `(or reproduce the work). Commit the restored files so the next`,
95816
+ `iteration's diff against \`${base2}\` contains real code, not`,
95817
+ `just meta files.`
95818
+ ].join(`
95819
+ `));
95820
+ if (retryCode !== 0) {
95821
+ log2(`! worker re-run after only-meta block exited code ${retryCode} \u2014 giving up`, "red");
95822
+ return PR_FAILED_EXIT;
95823
+ }
95824
+ continue;
95825
+ }
95826
+ pr = attempt2.pr;
95827
+ break;
95828
+ }
95318
95829
  if (!pr) {
95319
95830
  log2(` no commits ahead of ${base2} \u2014 skipping PR`, "gray");
95320
95831
  return 0;
95321
95832
  }
95322
- log2(` ${pr.created ? "opened" : "found existing"} PR: ${pr.url}`, "green");
95323
- registerPr?.(changeName, pr.url);
95833
+ const prUrl = pr.url;
95834
+ if (!prUrl) {
95835
+ log2(`! PR creation returned a null URL for ${changeName} \u2014 giving up`, "red");
95836
+ return PR_FAILED_EXIT;
95837
+ }
95838
+ log2(` ${pr.created ? "opened" : "found existing"} PR: ${prUrl}`, "green");
95839
+ registerPr?.(changeName, prUrl);
95324
95840
  let manualMergePending = false;
95325
95841
  if (wantAutoMerge) {
95326
95842
  const fallbackEnabled = cfg.manualMergeWhenAutoMergeDisabled !== false;
95327
- const repoAllowsAutoMerge = await detectRepoAutoMergeAllowed(pr.url, cmd, cwd2, log2);
95843
+ const repoAllowsAutoMerge = await detectRepoAutoMergeAllowed(prUrl, cmd, cwd2, log2);
95328
95844
  if (repoAllowsAutoMerge === false && fallbackEnabled) {
95329
- log2(` repo has auto-merge disabled \u2014 will poll ${pr.url} and merge via gh pr merge once checks pass`, "yellow");
95845
+ log2(` repo has auto-merge disabled \u2014 will poll ${prUrl} and merge via gh pr merge once checks pass`, "yellow");
95330
95846
  manualMergePending = true;
95331
95847
  } else {
95332
95848
  try {
95333
- await cmd.run(["gh", "pr", "merge", pr.url, "--auto", `--${cfg.autoMergeStrategy}`], cwd2);
95334
- log2(` enabled auto-merge (${cfg.autoMergeStrategy}) on ${pr.url}`, "green");
95849
+ await cmd.run(["gh", "pr", "merge", prUrl, "--auto", `--${cfg.autoMergeStrategy}`], cwd2);
95850
+ log2(` enabled auto-merge (${cfg.autoMergeStrategy}) on ${prUrl}`, "green");
95335
95851
  emit("auto-merge-enabled", cfg.autoMergeStrategy);
95336
95852
  } catch (err) {
95337
95853
  const e = err;
95338
95854
  const detail = e.stderr?.trim() || e.message;
95339
- log2(`! failed to enable auto-merge on ${pr.url}: ${detail}`, "yellow");
95855
+ log2(`! failed to enable auto-merge on ${prUrl}: ${detail}`, "yellow");
95340
95856
  if (fallbackEnabled && /auto[- ]merge/i.test(detail)) {
95341
- log2(` falling back to manual merge after CI passes for ${pr.url}`, "yellow");
95857
+ log2(` falling back to manual merge after CI passes for ${prUrl}`, "yellow");
95342
95858
  manualMergePending = true;
95343
95859
  }
95344
95860
  }
95345
95861
  }
95346
95862
  }
95347
- const ciResult = await fixConflictsAndCiLoop(ctx, pr.url, wantFixCi, checkPrConflict);
95863
+ const ciResult = await fixConflictsAndCiLoop(ctx, prUrl, wantFixCi, checkPrConflict);
95348
95864
  if (ciResult !== 0)
95349
95865
  return ciResult;
95350
95866
  if (manualMergePending) {
95351
95867
  try {
95352
- await cmd.run(["gh", "pr", "merge", pr.url, `--${cfg.autoMergeStrategy}`], cwd2);
95353
- log2(` manually merged (${cfg.autoMergeStrategy}) ${pr.url}`, "green");
95868
+ await cmd.run(["gh", "pr", "merge", prUrl, `--${cfg.autoMergeStrategy}`], cwd2);
95869
+ log2(` manually merged (${cfg.autoMergeStrategy}) ${prUrl}`, "green");
95354
95870
  emit("auto-merge-enabled", `manual:${cfg.autoMergeStrategy}`);
95355
95871
  } catch (err) {
95356
95872
  const e = err;
95357
- log2(`! manual merge failed for ${pr.url}: ${e.stderr?.trim() || e.message}`, "yellow");
95873
+ log2(`! manual merge failed for ${prUrl}: ${e.stderr?.trim() || e.message}`, "yellow");
95358
95874
  }
95359
95875
  }
95360
95876
  return 0;
@@ -95455,6 +95971,8 @@ async function runPostTask(input, deps) {
95455
95971
  var CI_FAILED_EXIT = 70, PR_FAILED_EXIT = 71, repoAutoMergeCache;
95456
95972
  var init_post_task = __esm(() => {
95457
95973
  init_tasks_md();
95974
+ init_linear();
95975
+ init_pr();
95458
95976
  init_ci();
95459
95977
  init_worktree();
95460
95978
  repoAutoMergeCache = new Map;
@@ -95721,64 +96239,235 @@ function renderTasksBlock(tasksMd, meta3) {
95721
96239
  return out.join(`
95722
96240
  `);
95723
96241
  }
95724
- function applyTasksBlock(existingDescription, block) {
95725
- const existing = existingDescription ?? "";
95726
- const startIdx = existing.indexOf(RALPHY_TASKS_START);
95727
- const endIdx = startIdx >= 0 ? existing.indexOf(RALPHY_TASKS_END, startIdx + RALPHY_TASKS_START.length) : -1;
95728
- if (startIdx >= 0 && endIdx >= 0) {
95729
- const before2 = existing.slice(0, startIdx);
95730
- const after2 = existing.slice(endIdx + RALPHY_TASKS_END.length);
95731
- return `${before2}${block}${after2}`;
95732
- }
95733
- if (existing.length === 0)
95734
- return block;
95735
- const trimmed = existing.replace(/\s+$/, "");
95736
- return `${trimmed}
96242
+ var RALPHY_TASKS_START = "<!-- ralphy:tasks:start -->", RALPHY_TASKS_END = "<!-- ralphy:tasks:end -->", MAX_CODE_BLOCK_BYTES;
96243
+ var init_linear_sync = __esm(() => {
96244
+ MAX_CODE_BLOCK_BYTES = 2 * 1024;
96245
+ });
95737
96246
 
95738
- ${block}`;
96247
+ // apps/agent/src/agent/linear-sync/comment-sync.ts
96248
+ import { dirname as dirname7, join as join21 } from "path";
96249
+ import { mkdir as mkdir6 } from "fs/promises";
96250
+ async function readStateJson(statePath) {
96251
+ const file2 = Bun.file(statePath);
96252
+ if (!await file2.exists())
96253
+ return null;
96254
+ try {
96255
+ return await file2.json();
96256
+ } catch {
96257
+ return null;
96258
+ }
95739
96259
  }
95740
- async function syncTasksToLinearDescription(deps) {
95741
- const file2 = Bun.file(deps.tasksPath);
96260
+ async function writeStateJson(statePath, state) {
96261
+ await mkdir6(dirname7(statePath), { recursive: true });
96262
+ await Bun.write(statePath, JSON.stringify(state, null, 2) + `
96263
+ `);
96264
+ }
96265
+ function readComments(state) {
96266
+ const raw = state?.linearComments ?? {};
96267
+ return {
96268
+ planCommentId: raw?.planCommentId ?? null,
96269
+ tasksCommentId: raw?.tasksCommentId ?? null,
96270
+ planPostedAt: raw?.planPostedAt ?? null
96271
+ };
96272
+ }
96273
+ async function patchComments(statePath, patch) {
96274
+ const existing = await readStateJson(statePath) ?? {};
96275
+ const current = readComments(existing);
96276
+ const next = { ...current, ...patch };
96277
+ await writeStateJson(statePath, { ...existing, linearComments: next });
96278
+ }
96279
+ function isCommentNotFoundError(err) {
96280
+ if (!err)
96281
+ return false;
96282
+ const candidates = [];
96283
+ const e = err;
96284
+ if (Array.isArray(e.messages))
96285
+ candidates.push(...e.messages);
96286
+ if (typeof e.message === "string")
96287
+ candidates.push(e.message);
96288
+ const text = candidates.join(" ").toLowerCase();
96289
+ return text.includes("not found") || text.includes("could not find") || text.includes("entity not found");
96290
+ }
96291
+ async function readTasksMd(changeDir, log2) {
96292
+ const file2 = Bun.file(join21(changeDir, "tasks.md"));
95742
96293
  if (!await file2.exists()) {
95743
- deps.log(` sync-tasks: tasks.md missing at ${deps.tasksPath}, skipping`, "gray");
96294
+ log2(` comment-sync: tasks.md missing in ${changeDir}, skipping`, "gray");
95744
96295
  return null;
95745
96296
  }
95746
- let tasksMd;
95747
96297
  try {
95748
- tasksMd = await file2.text();
96298
+ return await file2.text();
95749
96299
  } catch (err) {
95750
- deps.log(`! sync-tasks: read failed for ${deps.tasksPath}: ${err.message}`, "yellow");
96300
+ log2(`! comment-sync: read tasks.md failed: ${err.message}`, "yellow");
95751
96301
  return null;
95752
96302
  }
95753
- const block = renderTasksBlock(tasksMd, {
95754
- changeName: deps.changeName,
95755
- iteration: deps.iteration
95756
- });
95757
- if (block.length > MAX_BLOCK_BYTES) {
95758
- deps.log(`! sync-tasks: rendered block exceeds ${MAX_BLOCK_BYTES} bytes (${block.length}), skipping update`, "yellow");
96303
+ }
96304
+ function renderTasksCommentBody(tasksMd, changeName, iteration) {
96305
+ return renderTasksBlock(tasksMd, { changeName, iteration });
96306
+ }
96307
+ async function postOrUpdateTasksComment(deps) {
96308
+ const tasksMd = await readTasksMd(deps.changeDir, deps.log);
96309
+ if (!tasksMd)
95759
96310
  return null;
96311
+ const body = renderTasksCommentBody(tasksMd, deps.changeName, deps.iteration);
96312
+ const state = await readStateJson(deps.statePath);
96313
+ const comments = readComments(state);
96314
+ if (comments.tasksCommentId) {
96315
+ try {
96316
+ await deps.mutations.updateIssueComment(deps.apiKey, comments.tasksCommentId, body);
96317
+ deps.log(` comment-sync: updated tasks comment for ${deps.changeName}`, "gray");
96318
+ return comments.tasksCommentId;
96319
+ } catch (err) {
96320
+ if (!isCommentNotFoundError(err)) {
96321
+ deps.log(`! comment-sync: updateIssueComment failed: ${err.message}`, "yellow");
96322
+ return null;
96323
+ }
96324
+ deps.log(` comment-sync: tasks comment ${comments.tasksCommentId} not found \u2014 recreating`, "gray");
96325
+ }
96326
+ }
96327
+ let newId;
96328
+ try {
96329
+ newId = await deps.mutations.createIssueComment(deps.apiKey, deps.issueId, body);
96330
+ } catch (err) {
96331
+ deps.log(`! comment-sync: createIssueComment failed: ${err.message}`, "yellow");
96332
+ return null;
96333
+ }
96334
+ await patchComments(deps.statePath, { tasksCommentId: newId });
96335
+ deps.log(` comment-sync: created tasks comment for ${deps.changeName}`, "gray");
96336
+ return newId;
96337
+ }
96338
+ function planningComplete(tasksMd) {
96339
+ const lines = tasksMd.split(/\r?\n/);
96340
+ let inPlanning = false;
96341
+ let total = 0;
96342
+ let unchecked = 0;
96343
+ for (const line of lines) {
96344
+ const h = /^##\s+(.+?)\s*$/.exec(line);
96345
+ if (h) {
96346
+ inPlanning = h[1].trim().toLowerCase() === "planning";
96347
+ continue;
96348
+ }
96349
+ if (!inPlanning)
96350
+ continue;
96351
+ const m = /^\s*-\s+\[( |x|X)\]/.exec(line);
96352
+ if (!m)
96353
+ continue;
96354
+ total += 1;
96355
+ if (m[1] === " ")
96356
+ unchecked += 1;
95760
96357
  }
95761
- const next = applyTasksBlock(deps.currentDescription, block);
95762
- if (next === (deps.currentDescription ?? ""))
96358
+ return { allChecked: total > 0 && unchecked === 0, total };
96359
+ }
96360
+ async function readFirstParagraph(path) {
96361
+ const file2 = Bun.file(path);
96362
+ if (!await file2.exists())
96363
+ return null;
96364
+ const text = await file2.text();
96365
+ const blocks = text.split(/\r?\n\s*\r?\n/).map((b) => b.trim()).filter((b) => b.length > 0 && !/^#\s/.test(b));
96366
+ return blocks[0] ?? null;
96367
+ }
96368
+ async function readSection(path, heading) {
96369
+ const file2 = Bun.file(path);
96370
+ if (!await file2.exists())
96371
+ return null;
96372
+ const text = await file2.text();
96373
+ const headingRe = new RegExp(`(^|\\n)##\\s+${heading}\\s*\\n`);
96374
+ const m = headingRe.exec(text);
96375
+ if (!m)
96376
+ return null;
96377
+ const start = m.index + m[0].length;
96378
+ const rest2 = text.slice(start);
96379
+ const next = /\n##\s+/.exec(rest2);
96380
+ const body = next ? rest2.slice(0, next.index) : rest2;
96381
+ return body.trim() || null;
96382
+ }
96383
+ async function postPlanCommentOnce(deps) {
96384
+ const state = await readStateJson(deps.statePath);
96385
+ const comments = readComments(state);
96386
+ if (comments.planCommentId)
96387
+ return null;
96388
+ const tasksMd = await readTasksMd(deps.changeDir, deps.log);
96389
+ if (!tasksMd)
95763
96390
  return null;
96391
+ const check2 = planningComplete(tasksMd);
96392
+ if (!check2.allChecked)
96393
+ return null;
96394
+ const proposalPath = join21(deps.changeDir, "proposal.md");
96395
+ const why = await readSection(proposalPath, "Why");
96396
+ const whatChanges = await readSection(proposalPath, "What Changes");
96397
+ if (!why && !whatChanges) {
96398
+ deps.log(` comment-sync: proposal.md has no Why/What Changes, skipping plan comment`, "gray");
96399
+ return null;
96400
+ }
96401
+ const designSummary = await readFirstParagraph(join21(deps.changeDir, "design.md"));
96402
+ const parts = [`### ${PLAN_COMMENT_TITLE} \u2014 \`${deps.changeName}\``];
96403
+ if (why) {
96404
+ parts.push("", "**Why**", "", why);
96405
+ }
96406
+ if (whatChanges) {
96407
+ parts.push("", "**What Changes**", "", whatChanges);
96408
+ }
96409
+ if (designSummary) {
96410
+ parts.push("", "**Design**", "", designSummary);
96411
+ }
96412
+ const body = parts.join(`
96413
+ `);
96414
+ let id;
95764
96415
  try {
95765
- await deps.updateIssueDescription(deps.apiKey, deps.issueId, next);
95766
- deps.log(` sync-tasks: updated Linear description for ${deps.changeName}`, "gray");
95767
- return next;
96416
+ id = await deps.mutations.createIssueComment(deps.apiKey, deps.issueId, body);
95768
96417
  } catch (err) {
95769
- deps.log(`! sync-tasks: updateIssueDescription failed: ${err.message}`, "yellow");
96418
+ deps.log(`! comment-sync: plan comment create failed: ${err.message}`, "yellow");
95770
96419
  return null;
95771
96420
  }
96421
+ await patchComments(deps.statePath, {
96422
+ planCommentId: id,
96423
+ planPostedAt: new Date().toISOString()
96424
+ });
96425
+ deps.log(` comment-sync: posted plan comment for ${deps.changeName}`, "gray");
96426
+ return id;
95772
96427
  }
95773
- var RALPHY_TASKS_START = "<!-- ralphy:tasks:start -->", RALPHY_TASKS_END = "<!-- ralphy:tasks:end -->", MAX_BLOCK_BYTES, MAX_CODE_BLOCK_BYTES;
95774
- var init_linear_sync = __esm(() => {
95775
- MAX_BLOCK_BYTES = 60 * 1024;
95776
- MAX_CODE_BLOCK_BYTES = 2 * 1024;
96428
+ async function postSteeringAndRefreshTasks(deps) {
96429
+ const firstLine = deps.message.split(/\r?\n/, 1)[0].trim() || deps.message.trim();
96430
+ const steeringBody = `### ${STEERING_COMMENT_TITLE}
96431
+
96432
+ ${deps.message.trim()}`;
96433
+ try {
96434
+ await deps.mutations.createIssueComment(deps.apiKey, deps.issueId, steeringBody);
96435
+ deps.log(` comment-sync: posted steering comment (${firstLine})`, "gray");
96436
+ } catch (err) {
96437
+ deps.log(`! comment-sync: steering comment create failed: ${err.message}`, "yellow");
96438
+ }
96439
+ const state = await readStateJson(deps.statePath);
96440
+ const comments = readComments(state);
96441
+ if (comments.tasksCommentId) {
96442
+ try {
96443
+ await deps.mutations.deleteIssueComment(deps.apiKey, comments.tasksCommentId);
96444
+ deps.log(` comment-sync: deleted old tasks comment`, "gray");
96445
+ } catch (err) {
96446
+ if (!isCommentNotFoundError(err)) {
96447
+ deps.log(`! comment-sync: deleteIssueComment failed: ${err.message}`, "yellow");
96448
+ }
96449
+ }
96450
+ await patchComments(deps.statePath, { tasksCommentId: null });
96451
+ }
96452
+ await postOrUpdateTasksComment({
96453
+ apiKey: deps.apiKey,
96454
+ issueId: deps.issueId,
96455
+ statePath: deps.statePath,
96456
+ changeDir: deps.changeDir,
96457
+ changeName: deps.changeName,
96458
+ log: deps.log,
96459
+ mutations: deps.mutations,
96460
+ iteration: deps.iteration
96461
+ });
96462
+ }
96463
+ var PLAN_COMMENT_TITLE = "\uD83D\uDCCB Ralph plan", STEERING_COMMENT_TITLE = "\uD83E\uDDED Ralph steering";
96464
+ var init_comment_sync = __esm(() => {
96465
+ init_linear_sync();
95777
96466
  });
95778
96467
 
95779
96468
  // apps/agent/src/agent/wire.ts
95780
- import { join as join21 } from "path";
95781
- import { mkdir as mkdir6 } from "fs/promises";
96469
+ import { join as join22 } from "path";
96470
+ import { mkdir as mkdir7 } from "fs/promises";
95782
96471
  async function pickOpenPrUrlFromAttachments(urls, issueIdent, cmd, cwd2, onLog) {
95783
96472
  const candidates = urls.filter((url2) => GITHUB_PR_URL_RE.test(url2));
95784
96473
  let sawNonOpenPr = false;
@@ -95796,6 +96485,46 @@ async function pickOpenPrUrlFromAttachments(urls, issueIdent, cmd, cwd2, onLog)
95796
96485
  }
95797
96486
  return { url: null, sawNonOpenPr };
95798
96487
  }
96488
+ async function resolveDependencyBaseBranchImpl(issue2, runner, runnerCwd, deps) {
96489
+ const blockerIds = issue2.blockedByIds;
96490
+ if (blockerIds.length === 0)
96491
+ return null;
96492
+ let attachmentsByBlocker;
96493
+ try {
96494
+ attachmentsByBlocker = await fetchAttachmentsForIssues(deps.apiKey, blockerIds);
96495
+ } catch (err) {
96496
+ deps.onLog(`! could not fetch attachments for blockers of ${issue2.identifier}: ${err.message}`, "yellow");
96497
+ return null;
96498
+ }
96499
+ const candidates = [];
96500
+ for (const blockerId of blockerIds) {
96501
+ const attachments = attachmentsByBlocker.get(blockerId) ?? [];
96502
+ const prUrls = attachments.map((a) => a.url).filter((url2) => /^https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/\d+/.test(url2));
96503
+ const openHeads = [];
96504
+ for (const url2 of prUrls) {
96505
+ try {
96506
+ const res = await runner.run(["gh", "pr", "view", url2, "--json", "state,headRefName", "--jq", "."], runnerCwd);
96507
+ const parsed = JSON.parse(res.stdout.trim());
96508
+ if (parsed.state === "OPEN" && parsed.headRefName) {
96509
+ openHeads.push(parsed.headRefName);
96510
+ }
96511
+ } catch (err) {
96512
+ deps.onLog(`! gh pr view failed for ${url2} (blocker of ${issue2.identifier}): ${err.message}`, "yellow");
96513
+ }
96514
+ }
96515
+ if (openHeads.length === 1) {
96516
+ candidates.push(openHeads[0]);
96517
+ } else if (openHeads.length > 1) {
96518
+ deps.onLog(` ${issue2.identifier}: blocker ${blockerId} has ${openHeads.length} open PRs \u2014 skipping dependency base resolution`, "gray");
96519
+ }
96520
+ }
96521
+ if (candidates.length === 1)
96522
+ return candidates[0];
96523
+ if (candidates.length > 1) {
96524
+ deps.onLog(` ${issue2.identifier}: ${candidates.length} blockers have open PRs \u2014 falling back to default base`, "gray");
96525
+ }
96526
+ return null;
96527
+ }
95799
96528
  function githubReactionSlug(emoji3) {
95800
96529
  switch (emoji3) {
95801
96530
  case "\uD83D\uDC40":
@@ -95870,7 +96599,7 @@ ${c.body.trim()}`;
95870
96599
  ].join(`
95871
96600
  `);
95872
96601
  }
95873
- function escapeRegex2(s) {
96602
+ function escapeRegex3(s) {
95874
96603
  return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
95875
96604
  }
95876
96605
  function buildMentionTaskBody(trigger, issueUrl) {
@@ -95942,7 +96671,7 @@ function buildAgentCoordinator(input) {
95942
96671
  onWorkerOutput,
95943
96672
  onWorkerCmd
95944
96673
  } = input;
95945
- const logsDir = join21(projectRoot, ".ralph", "logs");
96674
+ const logsDir = join22(projectRoot, ".ralph", "logs");
95946
96675
  const concurrency = args.concurrency || cfg.concurrency;
95947
96676
  const pollInterval = args.pollInterval || cfg.pollIntervalSeconds;
95948
96677
  const indicators = mergeIndicators(cfg.linear.indicators, args.indicators);
@@ -96014,6 +96743,16 @@ function buildAgentCoordinator(input) {
96014
96743
  } else if (m.type === "attachment") {
96015
96744
  await upsertRalphyAttachment(apiKey, issue2.id, issue2.url, m.value);
96016
96745
  onLog(` \u2192 ${issue2.identifier} attachment='${m.value}'`, "gray");
96746
+ } else if (m.type === "project") {
96747
+ const projectId = await fetchProjectIdByName(apiKey, m.value);
96748
+ if (!projectId) {
96749
+ const err = new Error("Linear project not found");
96750
+ err.project = m.value;
96751
+ err.issue = issue2.identifier;
96752
+ throw err;
96753
+ }
96754
+ await setIssueProject(apiKey, issue2.id, projectId);
96755
+ onLog(` \u2192 ${issue2.identifier} project='${m.value}'`, "gray");
96017
96756
  } else {
96018
96757
  const id = await resolveLabelId(issue2, m.value);
96019
96758
  if (!id) {
@@ -96064,7 +96803,9 @@ function buildAgentCoordinator(input) {
96064
96803
  const prByChange = new Map;
96065
96804
  const prUnavailable = new Map;
96066
96805
  const PR_UNAVAILABLE_TTL_MS = 10 * 60 * 1000;
96806
+ const prUrlByIssue = createPrUrlCache(5 * 60 * 1000);
96067
96807
  const stalePingedAt = new Map;
96808
+ const lastHandledReviewActivity = new Map;
96068
96809
  const useWorktree = args.worktree || cfg.useWorktree;
96069
96810
  const scriptRunner = input.runners?.runScript ?? (async (cmd, cwd2) => {
96070
96811
  const proc = Bun.spawn({
@@ -96154,8 +96895,8 @@ function buildAgentCoordinator(input) {
96154
96895
  } else {
96155
96896
  changeName = changeNameForIssue(issue2);
96156
96897
  const wtLayout = projectLayout(workerCwd);
96157
- await mkdir6(wtLayout.changeDir(changeName), { recursive: true });
96158
- await mkdir6(wtLayout.taskStateDir(changeName), { recursive: true });
96898
+ await mkdir7(wtLayout.changeDir(changeName), { recursive: true });
96899
+ await mkdir7(wtLayout.taskStateDir(changeName), { recursive: true });
96159
96900
  }
96160
96901
  cwdByChange.set(changeName, workerCwd);
96161
96902
  statesDirByChange.set(changeName, scaffoldStatesDir);
@@ -96164,7 +96905,7 @@ function buildAgentCoordinator(input) {
96164
96905
  branchByChange.set(changeName, branch);
96165
96906
  if (mode === "review") {
96166
96907
  const wtLayout = projectLayout(workerCwd);
96167
- const tasksFile = join21(wtLayout.changeDir(changeName), AGENT_TASKS_FILENAME);
96908
+ const tasksFile = join22(wtLayout.changeDir(changeName), AGENT_TASKS_FILENAME);
96168
96909
  let body;
96169
96910
  let heading;
96170
96911
  if (trigger) {
@@ -96189,7 +96930,7 @@ function buildAgentCoordinator(input) {
96189
96930
  await reactivateState2(wtLayout.stateFile(changeName), changeName);
96190
96931
  } else if (mode === "conflict-fix") {
96191
96932
  const wtLayout = projectLayout(workerCwd);
96192
- const tasksFile = join21(wtLayout.changeDir(changeName), AGENT_TASKS_FILENAME);
96933
+ const tasksFile = join22(wtLayout.changeDir(changeName), AGENT_TASKS_FILENAME);
96193
96934
  const prUrl = prByChange.get(changeName);
96194
96935
  const body = [
96195
96936
  `The PR for this change has merge conflicts with \`${cfg.prBaseBranch}\`.`,
@@ -96269,7 +97010,7 @@ PR: ${prUrl}` : ""
96269
97010
  return c;
96270
97011
  }
96271
97012
  function defaultSpawn(changeName, cmd, cwd2, note) {
96272
- const logFilePath = join21(logsDir, `${changeName}.log`);
97013
+ const logFilePath = join22(logsDir, `${changeName}.log`);
96273
97014
  const ANSI_RE2 = /\x1b(?:\[[0-9;]*[A-Za-z]|\][^\x07\x1b]*(?:\x07|\x1b\\)|.)/g;
96274
97015
  const BOX_ONLY_RE = /^[\s\u2500\u2502\u256D\u256E\u2570\u256F\u254C\u2504\u2501\u2503]+$/;
96275
97016
  const STATUS_BAR_LINE_RE = /^[\u280B\u2819\u2839\u2838\u283C\u2834\u2826\u2827\u2807\u280F\u2713\u2717]\s+iter\s+\d+/;
@@ -96328,7 +97069,7 @@ PR: ${prUrl}` : ""
96328
97069
  function spawnWorker(changeName) {
96329
97070
  const cwd2 = cwdByChange.get(changeName) ?? projectRoot;
96330
97071
  const injected = input.runners?.spawnWorker;
96331
- const missionTasksPath = join21(projectLayout(cwd2).changeDir(changeName), MISSION_TASKS_FILENAME);
97072
+ const missionTasksPath = join22(projectLayout(cwd2).changeDir(changeName), MISSION_TASKS_FILENAME);
96332
97073
  const prevTasksPromise = (async () => {
96333
97074
  const f2 = Bun.file(missionTasksPath);
96334
97075
  return await f2.exists() ? await f2.text() : "";
@@ -96336,7 +97077,7 @@ PR: ${prUrl}` : ""
96336
97077
  let logFilePath;
96337
97078
  let handle;
96338
97079
  if (injected) {
96339
- logFilePath = join21(logsDir, `${changeName}.log`);
97080
+ logFilePath = join22(logsDir, `${changeName}.log`);
96340
97081
  handle = injected(buildTaskCmdFor(changeName), cwd2);
96341
97082
  } else {
96342
97083
  const r = defaultSpawn(changeName, buildTaskCmdFor(changeName), cwd2, `spawn at ${new Date().toISOString()}`);
@@ -96396,6 +97137,7 @@ PR: ${prUrl}` : ""
96396
97137
  ignoreCiChecks: cfg.ignoreCiChecks,
96397
97138
  stackPrsOnDependencies: args.stackPrs || cfg.stackPrsOnDependencies,
96398
97139
  neverTouch: cfg.boundaries.never_touch,
97140
+ metaOnlyFiles: cfg.boundaries.meta_only_files,
96399
97141
  manualMergeWhenAutoMergeDisabled: cfg.manualMergeWhenAutoMergeDisabled
96400
97142
  },
96401
97143
  respawnWorker: respawn
@@ -96407,6 +97149,9 @@ PR: ${prUrl}` : ""
96407
97149
  registerPr: (cn, url2) => {
96408
97150
  prByChange.set(cn, url2);
96409
97151
  prUnavailable.delete(cn);
97152
+ const issue2 = issueByChange.get(cn);
97153
+ if (issue2)
97154
+ prUrlByIssue.invalidate(issue2.id);
96410
97155
  input.onWorkerPr?.(cn, url2);
96411
97156
  },
96412
97157
  ...onWorkerPhase && {
@@ -96464,6 +97209,7 @@ PR: ${prUrl}` : ""
96464
97209
  }
96465
97210
  if (state && state !== "OPEN") {
96466
97211
  markPrUnavailable(changeName);
97212
+ prUrlByIssue.invalidate(issue2.id);
96467
97213
  return null;
96468
97214
  }
96469
97215
  if (m && m !== "UNKNOWN") {
@@ -96501,32 +97247,9 @@ PR: ${prUrl}` : ""
96501
97247
  prUnavailable.set(changeName, Date.now() + PR_UNAVAILABLE_TTL_MS);
96502
97248
  }
96503
97249
  async function discoverPrUrl(issue2, changeName) {
96504
- const branch = branchForChange(changeName);
96505
- const tryGh = async (args2) => {
96506
- try {
96507
- const res = await cmdRunner.run(args2, projectRoot);
96508
- const found = res.stdout.trim();
96509
- return found || null;
96510
- } catch (err) {
96511
- onLog(`! gh ${args2[1] ?? ""} failed for ${issue2.identifier}: ${err.message}`, "yellow");
96512
- return null;
96513
- }
96514
- };
96515
- const byBranch = await tryGh([
96516
- "gh",
96517
- "pr",
96518
- "list",
96519
- "--head",
96520
- branch,
96521
- "--state",
96522
- "open",
96523
- "--json",
96524
- "url",
96525
- "--jq",
96526
- ".[0].url // empty"
96527
- ]);
96528
- if (byBranch)
96529
- return byBranch;
97250
+ const fromGitHub = await discoverPrUrlFromGitHub(issue2.identifier, cmdRunner, projectRoot, onLog);
97251
+ if (fromGitHub)
97252
+ return fromGitHub;
96530
97253
  const fromLinear = await discoverPrUrlFromLinear(issue2);
96531
97254
  if (fromLinear.url) {
96532
97255
  onLog(` ${issue2.identifier}: PR discovered via Linear attachment (${fromLinear.url})`, "gray");
@@ -96536,48 +97259,12 @@ PR: ${prUrl}` : ""
96536
97259
  markPrUnavailable(changeName);
96537
97260
  return null;
96538
97261
  }
96539
- onLog(` ${issue2.identifier}: no open PR found on head=${branch} or Linear attachments; conflict scan skipped for ${PR_UNAVAILABLE_TTL_MS / 60000}m`, "gray");
97262
+ onLog(` ${issue2.identifier}: no PR found via GitHub search or Linear attachments; conflict scan skipped for ${PR_UNAVAILABLE_TTL_MS / 60000}m`, "gray");
96540
97263
  markPrUnavailable(changeName);
96541
97264
  return null;
96542
97265
  }
96543
97266
  async function resolveDependencyBaseBranch(issue2, runner, runnerCwd) {
96544
- const blockerIds = issue2.blockedByIds;
96545
- if (blockerIds.length === 0)
96546
- return null;
96547
- const candidates = [];
96548
- for (const blockerId of blockerIds) {
96549
- let attachments;
96550
- try {
96551
- attachments = await fetchIssueAttachments(apiKey, blockerId);
96552
- } catch (err) {
96553
- onLog(`! could not fetch attachments for blocker ${blockerId} of ${issue2.identifier}: ${err.message}`, "yellow");
96554
- continue;
96555
- }
96556
- const prUrls = attachments.map((a) => a.url).filter((url2) => /^https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/\d+/.test(url2));
96557
- const openHeads = [];
96558
- for (const url2 of prUrls) {
96559
- try {
96560
- const res = await runner.run(["gh", "pr", "view", url2, "--json", "state,headRefName", "--jq", "."], runnerCwd);
96561
- const parsed = JSON.parse(res.stdout.trim());
96562
- if (parsed.state === "OPEN" && parsed.headRefName) {
96563
- openHeads.push(parsed.headRefName);
96564
- }
96565
- } catch (err) {
96566
- onLog(`! gh pr view failed for ${url2} (blocker of ${issue2.identifier}): ${err.message}`, "yellow");
96567
- }
96568
- }
96569
- if (openHeads.length === 1) {
96570
- candidates.push(openHeads[0]);
96571
- } else if (openHeads.length > 1) {
96572
- onLog(` ${issue2.identifier}: blocker ${blockerId} has ${openHeads.length} open PRs \u2014 skipping dependency base resolution`, "gray");
96573
- }
96574
- }
96575
- if (candidates.length === 1)
96576
- return candidates[0];
96577
- if (candidates.length > 1) {
96578
- onLog(` ${issue2.identifier}: ${candidates.length} blockers have open PRs \u2014 falling back to default base`, "gray");
96579
- }
96580
- return null;
97267
+ return resolveDependencyBaseBranchImpl(issue2, runner, runnerCwd, { apiKey, onLog });
96581
97268
  }
96582
97269
  async function discoverPrUrlFromLinear(issue2) {
96583
97270
  let attachments;
@@ -96608,19 +97295,24 @@ PR: ${prUrl}` : ""
96608
97295
  try {
96609
97296
  candidates = await fetchMentionScanIssues(apiKey, { team, assignee });
96610
97297
  } catch (err) {
96611
- onLog(`! mention scan: fetchMentionScanIssues failed: ${err.message}`, "yellow");
97298
+ if (isRateLimitedError(err)) {
97299
+ onLog(`! mention scan: rate limited, deferring rest of scan to next poll`, "yellow");
97300
+ return [];
97301
+ }
97302
+ onLog(`! mention scan: fetchMentionScanIssues failed: ${formatLinearError(err)}`, "yellow");
96612
97303
  return [];
96613
97304
  }
96614
97305
  const out = [];
96615
97306
  const queued = new Set;
97307
+ let rateLimitedLogged = false;
97308
+ const logRateLimited = () => {
97309
+ if (rateLimitedLogged)
97310
+ return;
97311
+ rateLimitedLogged = true;
97312
+ onLog(`! mention scan: rate limited, deferring rest of scan to next poll`, "yellow");
97313
+ };
96616
97314
  for (const issue2 of candidates) {
96617
- let comments = [];
96618
- try {
96619
- comments = await fetchIssueComments(apiKey, issue2.id);
96620
- } catch (err) {
96621
- onLog(`! mention scan: Linear comments failed for ${issue2.identifier}: ${err.message}`, "yellow");
96622
- continue;
96623
- }
97315
+ const comments = issue2.comments ?? [];
96624
97316
  const lastRalphPickup = findLastRalphPickupISO(comments);
96625
97317
  if (wantMention) {
96626
97318
  for (const c of comments) {
@@ -96643,11 +97335,18 @@ PR: ${prUrl}` : ""
96643
97335
  try {
96644
97336
  await addReactionToComment(apiKey, c.id, "\uD83D\uDC40");
96645
97337
  } catch (err) {
96646
- onLog(`! mention scan: Linear reaction failed for ${issue2.identifier}: ${err.message}`, "yellow");
97338
+ if (isRateLimitedError(err)) {
97339
+ logRateLimited();
97340
+ queued.add(issue2.id);
97341
+ break;
97342
+ }
97343
+ onLog(`! mention scan: Linear reaction failed for ${issue2.identifier}: ${formatLinearError(err)}`, "yellow");
96647
97344
  }
96648
97345
  queued.add(issue2.id);
96649
97346
  break;
96650
97347
  }
97348
+ if (rateLimitedLogged)
97349
+ break;
96651
97350
  if (queued.has(issue2.id))
96652
97351
  continue;
96653
97352
  }
@@ -96677,7 +97376,7 @@ PR: ${prUrl}` : ""
96677
97376
  try {
96678
97377
  await addGithubReactionToComment({ owner, repo, kind: "issue" }, c.id, "\uD83D\uDC40");
96679
97378
  } catch (err) {
96680
- onLog(`! mention scan: GitHub reaction failed for ${prUrl}: ${err.message}`, "yellow");
97379
+ onLog(`! mention scan: GitHub reaction failed for ${prUrl}: ${formatLinearError(err)}`, "yellow");
96681
97380
  }
96682
97381
  }
96683
97382
  queued.add(issue2.id);
@@ -96707,7 +97406,9 @@ PR: ${prUrl}` : ""
96707
97406
  const last2 = t.comments[t.comments.length - 1].createdAt;
96708
97407
  return last2 > acc ? last2 : acc;
96709
97408
  }, "");
96710
- if (!lastRalphPickup || newestReviewerActivity > lastRalphPickup) {
97409
+ const lastHandled = lastHandledReviewActivity.get(prUrl) ?? null;
97410
+ const effectiveLastHandled = lastRalphPickup && lastHandled ? lastRalphPickup > lastHandled ? lastRalphPickup : lastHandled : lastRalphPickup ?? lastHandled;
97411
+ if (!effectiveLastHandled || newestReviewerActivity > effectiveLastHandled) {
96711
97412
  const body = unresolved.map((t) => {
96712
97413
  const head3 = t.path ? `_${t.path}${t.line ? `:${t.line}` : ""}_` : "_(general)_";
96713
97414
  const lines = t.comments.map((c) => `> **${c.author ?? "reviewer"}** (${c.createdAt})
@@ -96721,6 +97422,7 @@ PR: ${prUrl}` : ""
96721
97422
  ---
96722
97423
 
96723
97424
  `);
97425
+ lastHandledReviewActivity.set(prUrl, newestReviewerActivity);
96724
97426
  return {
96725
97427
  source: "github-review",
96726
97428
  body,
@@ -96832,17 +97534,21 @@ PR: ${prUrl}` : ""
96832
97534
  return latest;
96833
97535
  }
96834
97536
  function containsHandle(body, handle) {
96835
- const re = new RegExp(`(^|\\s|[^A-Za-z0-9_])${escapeRegex2(handle)}\\b`, "i");
97537
+ const re = new RegExp(`(^|\\s|[^A-Za-z0-9_])${escapeRegex3(handle)}\\b`, "i");
96836
97538
  return re.test(body);
96837
97539
  }
96838
97540
  async function resolvePrUrlForIssue(issue2) {
96839
97541
  const changeName = changeNameForIssue(issue2);
96840
97542
  if (isPrUnavailable(changeName))
96841
97543
  return null;
96842
- const cached2 = prByChange.get(changeName);
96843
- if (cached2)
97544
+ const inflight = prByChange.get(changeName);
97545
+ if (inflight)
97546
+ return inflight;
97547
+ const cached2 = prUrlByIssue.get(issue2.id);
97548
+ if (cached2 !== undefined)
96844
97549
  return cached2;
96845
97550
  const found = await discoverPrUrl(issue2, changeName);
97551
+ prUrlByIssue.set(issue2.id, found);
96846
97552
  if (found)
96847
97553
  prByChange.set(changeName, found);
96848
97554
  return found;
@@ -96868,10 +97574,16 @@ PR: ${prUrl}` : ""
96868
97574
  const parsed = JSON.parse(res.stdout || "[]");
96869
97575
  return parsed;
96870
97576
  } catch (err) {
96871
- onLog(`! mention scan: gh comments failed for ${prUrl}: ${err.message}`, "yellow");
97577
+ onLog(`! mention scan: gh comments failed for ${prUrl}: ${formatLinearError(err)}`, "yellow");
96872
97578
  return [];
96873
97579
  }
96874
97580
  }
97581
+ const commentSyncEnabled = Boolean(cfg.linear.syncTasksToComment && apiKey);
97582
+ const commentMutations = {
97583
+ createIssueComment,
97584
+ updateIssueComment,
97585
+ deleteIssueComment
97586
+ };
96875
97587
  const coord = new AgentCoordinator({
96876
97588
  fetchTodo: () => fetchByGet(indicators.getTodo, excludeFromTodo),
96877
97589
  fetchInProgress: () => fetchByGet(indicators.getInProgress, []),
@@ -96900,26 +97612,62 @@ PR: ${prUrl}` : ""
96900
97612
  const json2 = await file2.json();
96901
97613
  return json2.iteration ?? 0;
96902
97614
  },
96903
- ...cfg.linear.syncTasksToDescription && apiKey ? {
97615
+ ...commentSyncEnabled ? {
96904
97616
  syncTasks: async (worker, iteration) => {
96905
97617
  const root = cwdByChange.get(worker.changeName) ?? projectRoot;
96906
- const tasksPath = join21(projectLayout(root).changeDir(worker.changeName), "tasks.md");
96907
- const cachedIssue = issueByChange.get(worker.changeName) ?? worker.issue;
96908
- const next = await syncTasksToLinearDescription({
97618
+ const layout = projectLayout(root);
97619
+ const changeDir = layout.changeDir(worker.changeName);
97620
+ const statePath = layout.stateFile(worker.changeName);
97621
+ await postPlanCommentOnce({
96909
97622
  apiKey,
96910
97623
  issueId: worker.issueId,
96911
- currentDescription: cachedIssue.description,
96912
- tasksPath,
97624
+ statePath,
97625
+ changeDir,
97626
+ changeName: worker.changeName,
97627
+ log: onLog,
97628
+ mutations: commentMutations
97629
+ });
97630
+ await postOrUpdateTasksComment({
97631
+ apiKey,
97632
+ issueId: worker.issueId,
97633
+ statePath,
97634
+ changeDir,
96913
97635
  changeName: worker.changeName,
96914
97636
  iteration,
96915
97637
  log: onLog,
96916
- updateIssueDescription
97638
+ mutations: commentMutations
96917
97639
  });
96918
- if (next !== null) {
96919
- const updated = { ...cachedIssue, description: next };
96920
- issueByChange.set(worker.changeName, updated);
96921
- worker.issue = updated;
97640
+ },
97641
+ onSteeringAppended: async (changeName, message) => {
97642
+ const root = cwdByChange.get(changeName) ?? projectRoot;
97643
+ const layout = projectLayout(root);
97644
+ const changeDir = layout.changeDir(changeName);
97645
+ const statePath = layout.stateFile(changeName);
97646
+ const issue2 = issueByChange.get(changeName) ?? null;
97647
+ const issueId = issue2?.id ?? null;
97648
+ if (!issueId) {
97649
+ onLog(` comment-sync: no Linear issue cached for ${changeName}; skipping steering refresh`, "gray");
97650
+ return;
96922
97651
  }
97652
+ let iteration = 0;
97653
+ try {
97654
+ const f2 = Bun.file(statePath);
97655
+ if (await f2.exists()) {
97656
+ const json2 = await f2.json();
97657
+ iteration = json2.iteration ?? 0;
97658
+ }
97659
+ } catch {}
97660
+ await postSteeringAndRefreshTasks({
97661
+ apiKey,
97662
+ issueId,
97663
+ statePath,
97664
+ changeDir,
97665
+ changeName,
97666
+ iteration,
97667
+ message,
97668
+ log: onLog,
97669
+ mutations: commentMutations
97670
+ });
96923
97671
  }
96924
97672
  } : {}
96925
97673
  }, {
@@ -96988,7 +97736,7 @@ PR: ${prUrl}` : ""
96988
97736
  concurrency,
96989
97737
  pollInterval,
96990
97738
  getWorkerCwd: (changeName) => cwdByChange.get(changeName),
96991
- syncTasksEnabled: Boolean(cfg.linear.syncTasksToDescription && apiKey),
97739
+ syncTasksEnabled: commentSyncEnabled,
96992
97740
  runBaselineGate: runBaselineGateOnce
96993
97741
  };
96994
97742
  }
@@ -97017,6 +97765,7 @@ var init_wire = __esm(() => {
97017
97765
  init_tasks_md();
97018
97766
  init_workflow();
97019
97767
  init_types2();
97768
+ init_linear();
97020
97769
  init_coordinator();
97021
97770
  init_scaffold();
97022
97771
  init_worktree();
@@ -97024,7 +97773,7 @@ var init_wire = __esm(() => {
97024
97773
  init_post_task();
97025
97774
  init_gate();
97026
97775
  init_workflow();
97027
- init_linear_sync();
97776
+ init_comment_sync();
97028
97777
  GITHUB_PR_URL_RE = /^https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/\d+/;
97029
97778
  bunGitRunner = {
97030
97779
  run: async (args, cwd2) => {
@@ -97143,21 +97892,26 @@ function readSize2() {
97143
97892
  rows: process.stdout.rows ?? 24
97144
97893
  };
97145
97894
  }
97895
+ function clearScreenAndScrollback2() {
97896
+ if (process.stdout.isTTY)
97897
+ process.stdout.write("\x1B[2J\x1B[3J\x1B[H");
97898
+ }
97146
97899
  function useTerminalSize2() {
97147
- const [size2, setSize] = import_react59.useState(() => ({
97148
- ...readSize2(),
97149
- resizeKey: 0
97150
- }));
97900
+ const initial2 = import_react59.useRef({ ...readSize2(), resizeKey: 0 });
97901
+ const [size2, setSize] = import_react59.useState(initial2.current);
97902
+ const sizeRef = import_react59.useRef(initial2.current);
97151
97903
  import_react59.useEffect(() => {
97152
97904
  if (!process.stdout.isTTY)
97153
97905
  return;
97154
97906
  const onResize = () => {
97155
97907
  const { columns, rows } = readSize2();
97156
- setSize((prev) => {
97157
- if (prev.columns === columns && prev.rows === rows)
97158
- return prev;
97159
- return { columns, rows, resizeKey: prev.resizeKey + 1 };
97160
- });
97908
+ const prev = sizeRef.current;
97909
+ if (prev.columns === columns && prev.rows === rows)
97910
+ return;
97911
+ clearScreenAndScrollback2();
97912
+ const next = { columns, rows, resizeKey: prev.resizeKey + 1 };
97913
+ sizeRef.current = next;
97914
+ setSize(next);
97161
97915
  };
97162
97916
  process.stdout.on("resize", onResize);
97163
97917
  return () => {
@@ -97347,7 +98101,7 @@ var init_SteeringField = __esm(async () => {
97347
98101
  });
97348
98102
 
97349
98103
  // apps/agent/src/components/AgentMode.tsx
97350
- import { join as join22 } from "path";
98104
+ import { join as join23 } from "path";
97351
98105
  async function appendSteeringImpl(changeDir, message) {
97352
98106
  await runWithContext(createDefaultContext(), async () => {
97353
98107
  appendSteeringMessage(changeDir, message);
@@ -97598,19 +98352,13 @@ function AgentMode({
97598
98352
  loadConfig = loadRalphyConfig
97599
98353
  }) {
97600
98354
  const { exit } = use_app_default();
97601
- const { stdout } = use_stdout_default();
97602
98355
  const { isRawModeSupported } = use_stdin_default();
97603
98356
  const { columns, rows, resizeKey } = useTerminalSize2();
97604
- import_react61.useEffect(() => {
97605
- if (resizeKey === 0)
97606
- return;
97607
- stdout.write("\x1B[2J\x1B[3J\x1B[H");
97608
- }, [resizeKey, stdout]);
97609
98357
  const [logs, setLogs] = import_react61.useState([]);
97610
98358
  const [, setTick] = import_react61.useState(0);
97611
98359
  const [clock, setClock] = import_react61.useState(0);
97612
98360
  const [focusedIdx, setFocusedIdx] = import_react61.useState(0);
97613
- const [showPendingTasks, setShowPendingTasks] = import_react61.useState(true);
98361
+ const [showPendingTasks, setShowPendingTasks] = import_react61.useState(false);
97614
98362
  const [showAllSubtasks, setShowAllSubtasks] = import_react61.useState(false);
97615
98363
  const coordRef = import_react61.useRef(null);
97616
98364
  const workerMetaRef = import_react61.useRef(new Map);
@@ -97808,7 +98556,7 @@ function AgentMode({
97808
98556
  (async () => {
97809
98557
  for (const [changeName, meta3] of workerMetaRef.current) {
97810
98558
  try {
97811
- const file2 = Bun.file(join22(meta3.statesDir, changeName, ".ralph-state.json"));
98559
+ const file2 = Bun.file(join23(meta3.statesDir, changeName, ".ralph-state.json"));
97812
98560
  if (await file2.exists()) {
97813
98561
  const json2 = await file2.json();
97814
98562
  meta3.iter = json2.iteration ?? meta3.iter;
@@ -97818,9 +98566,9 @@ function AgentMode({
97818
98566
  }
97819
98567
  if (meta3.changeDir) {
97820
98568
  try {
97821
- const tasksFile = Bun.file(join22(meta3.changeDir, "tasks.md"));
97822
- const proposalFile = Bun.file(join22(meta3.changeDir, "proposal.md"));
97823
- const designFile = Bun.file(join22(meta3.changeDir, "design.md"));
98569
+ const tasksFile = Bun.file(join23(meta3.changeDir, "tasks.md"));
98570
+ const proposalFile = Bun.file(join23(meta3.changeDir, "proposal.md"));
98571
+ const designFile = Bun.file(join23(meta3.changeDir, "design.md"));
97824
98572
  const [tasksText, proposalText, designText] = await Promise.all([
97825
98573
  tasksFile.exists().then((ok) => ok ? tasksFile.text() : null),
97826
98574
  proposalFile.exists().then((ok) => ok ? proposalFile.text() : null),
@@ -98651,11 +99399,14 @@ function AgentMode({
98651
99399
  },
98652
99400
  onSubmit: async (message) => {
98653
99401
  try {
98654
- await appendSteering(join22(tasksDir, w.changeName), message);
99402
+ await appendSteering(join23(tasksDir, w.changeName), message);
98655
99403
  } catch (err) {
98656
99404
  appendLog(`! steering append failed for ${w.changeName}: ${err.message}`, "red");
98657
99405
  throw err;
98658
99406
  }
99407
+ try {
99408
+ await coordRef.current?.notifySteeringAppended?.(w.changeName, message);
99409
+ } catch {}
98659
99410
  const restarted = await coordRef.current?.restartWorker(w.changeName);
98660
99411
  if (restarted) {
98661
99412
  appendLog(` ${w.changeName}: steering applied, restarting worker`, "cyan");
@@ -98774,7 +99525,7 @@ function bucketChecks(rollup, prState) {
98774
99525
  return "fail";
98775
99526
  return "pass";
98776
99527
  }
98777
- async function fetchPrStatus(url2, runner, cwd2) {
99528
+ async function fetchPrStatus(url2, runner, cwd2, transition) {
98778
99529
  let stdout;
98779
99530
  try {
98780
99531
  const out = await runner.run(["gh", "pr", "view", url2, "--json", PR_VIEW_FIELDS], cwd2);
@@ -98795,6 +99546,9 @@ async function fetchPrStatus(url2, runner, cwd2) {
98795
99546
  const state = stateUpper === "OPEN" || stateUpper === "CLOSED" || stateUpper === "MERGED" ? stateUpper : "OPEN";
98796
99547
  const mergeableUpper = (raw.mergeable ?? "UNKNOWN").toUpperCase();
98797
99548
  const mergeable = mergeableUpper === "MERGEABLE" || mergeableUpper === "CONFLICTING" ? mergeableUpper : "UNKNOWN";
99549
+ if (transition && transition.priorState !== state) {
99550
+ transition.onTransition(state);
99551
+ }
98798
99552
  return {
98799
99553
  kind: "ok",
98800
99554
  state,
@@ -98855,7 +99609,7 @@ var exports_list = {};
98855
99609
  __export(exports_list, {
98856
99610
  runList: () => runList
98857
99611
  });
98858
- import { join as join23 } from "path";
99612
+ import { join as join24 } from "path";
98859
99613
  function countTaskItems(content) {
98860
99614
  const checked = (content.match(/^- \[x\]/gm) ?? []).length;
98861
99615
  const unchecked = (content.match(/^- \[ \]/gm) ?? []).length;
@@ -98868,13 +99622,13 @@ function buildLocalRows(statesDir, projectRoot) {
98868
99622
  const sources = [{ dir: statesDir, label: "main" }];
98869
99623
  const worktreesRoot = worktreesDir2(projectRoot);
98870
99624
  for (const wt of storage.list(worktreesRoot)) {
98871
- sources.push({ dir: join23(worktreesRoot, wt, ".ralph", "tasks"), label: `wt:${wt}` });
99625
+ sources.push({ dir: join24(worktreesRoot, wt, ".ralph", "tasks"), label: `wt:${wt}` });
98872
99626
  }
98873
99627
  for (const { dir, label } of sources) {
98874
99628
  for (const entry of storage.list(dir)) {
98875
99629
  if (seen.has(entry))
98876
99630
  continue;
98877
- const raw = storage.read(join23(dir, entry, ".ralph-state.json"));
99631
+ const raw = storage.read(join24(dir, entry, ".ralph-state.json"));
98878
99632
  if (raw === null)
98879
99633
  continue;
98880
99634
  let state;
@@ -98889,7 +99643,7 @@ function buildLocalRows(statesDir, projectRoot) {
98889
99643
  const firstLine = promptRaw.split(`
98890
99644
  `).find((l) => l.trim() !== "") ?? "";
98891
99645
  let progress = "\u2014";
98892
- const tasksContent = storage.read(join23(dir, entry, "tasks.md"));
99646
+ const tasksContent = storage.read(join24(dir, entry, "tasks.md"));
98893
99647
  if (tasksContent !== null) {
98894
99648
  const { checked, unchecked } = countTaskItems(tasksContent);
98895
99649
  const total = checked + unchecked;
@@ -99048,10 +99802,20 @@ ${bucket.label}: error fetching from Linear \u2014 ${error48}
99048
99802
  }
99049
99803
  }
99050
99804
  const rows = [...seen.values()];
99805
+ try {
99806
+ const attachmentsByIssue = await fetchAttachmentsForIssues(apiKey, rows.map((r) => r.issueId));
99807
+ for (const row of rows) {
99808
+ const attachments = attachmentsByIssue.get(row.issueId) ?? [];
99809
+ row.prUrl = findPullRequestUrl(attachments);
99810
+ }
99811
+ } catch {}
99051
99812
  await Promise.all(rows.map(async (row) => {
99813
+ if (row.prUrl)
99814
+ return;
99052
99815
  try {
99053
- const attachments = await fetchIssueAttachments(apiKey, row.issueId);
99054
- row.prUrl = findPullRequestUrl(attachments);
99816
+ const fromGitHub = await discoverPrUrlFromGitHub(row.identifier, runner, cwd2);
99817
+ if (fromGitHub)
99818
+ row.prUrl = fromGitHub;
99055
99819
  } catch {}
99056
99820
  }));
99057
99821
  await Promise.all(rows.map(async (row) => {
@@ -99283,6 +100047,7 @@ var init_list = __esm(() => {
99283
100047
  init_types2();
99284
100048
  init_worktree();
99285
100049
  init_config();
100050
+ init_linear();
99286
100051
  init_list_sort();
99287
100052
  localCmdRunner = {
99288
100053
  run: async (cmd, cwd2) => {
@@ -99305,8 +100070,8 @@ var exports_json_runner = {};
99305
100070
  __export(exports_json_runner, {
99306
100071
  runAgentJson: () => runAgentJson
99307
100072
  });
99308
- import { join as join24 } from "path";
99309
- import { mkdir as mkdir7 } from "fs/promises";
100073
+ import { join as join25 } from "path";
100074
+ import { mkdir as mkdir8 } from "fs/promises";
99310
100075
  import { homedir as homedir5 } from "os";
99311
100076
  function cleanOutputLine2(raw) {
99312
100077
  const clean = raw.replace(ANSI_STRIP_RE2, "").trim();
@@ -99330,7 +100095,7 @@ async function runAgentJson({
99330
100095
  statesDir,
99331
100096
  tasksDir
99332
100097
  }) {
99333
- await mkdir7(join24(homedir5(), ".ralph"), { recursive: true }).catch(() => {
100098
+ await mkdir8(join25(homedir5(), ".ralph"), { recursive: true }).catch(() => {
99334
100099
  return;
99335
100100
  });
99336
100101
  const cfgPath = await ensureRalphyConfig(projectRoot);
@@ -99484,8 +100249,8 @@ var exports_src2 = {};
99484
100249
  __export(exports_src2, {
99485
100250
  main: () => main2
99486
100251
  });
99487
- import { mkdir as mkdir8 } from "fs/promises";
99488
- import { join as join25 } from "path";
100252
+ import { mkdir as mkdir9 } from "fs/promises";
100253
+ import { join as join26 } from "path";
99489
100254
  async function main2(argv) {
99490
100255
  if (argv.includes("--help") || argv.includes("-h")) {
99491
100256
  printHelp2();
@@ -99519,9 +100284,9 @@ async function main2(argv) {
99519
100284
  });
99520
100285
  return typeof process.exitCode === "number" ? process.exitCode : 0;
99521
100286
  }
99522
- await mkdir8(statesDir, { recursive: true });
99523
- await mkdir8(tasksDir, { recursive: true });
99524
- await mkdir8(join25(projectRoot, ".ralph"), { recursive: true });
100287
+ await mkdir9(statesDir, { recursive: true });
100288
+ await mkdir9(tasksDir, { recursive: true });
100289
+ await mkdir9(join26(projectRoot, ".ralph"), { recursive: true });
99525
100290
  if (args.jsonOutput) {
99526
100291
  const { runAgentJson: runAgentJson2 } = await Promise.resolve().then(() => (init_json_runner(), exports_json_runner));
99527
100292
  await runAgentJson2({ args, projectRoot, statesDir, tasksDir });