@neriros/ralphy 3.2.0 → 3.3.0

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.0")
18932
+ return "3.3.0";
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(),
@@ -68802,21 +68860,26 @@ function readSize() {
68802
68860
  rows: process.stdout.rows ?? 24
68803
68861
  };
68804
68862
  }
68863
+ function clearScreenAndScrollback() {
68864
+ if (process.stdout.isTTY)
68865
+ process.stdout.write("\x1B[2J\x1B[3J\x1B[H");
68866
+ }
68805
68867
  function useTerminalSize() {
68806
- const [size2, setSize] = import_react53.useState(() => ({
68807
- ...readSize(),
68808
- resizeKey: 0
68809
- }));
68868
+ const initial2 = import_react53.useRef({ ...readSize(), resizeKey: 0 });
68869
+ const [size2, setSize] = import_react53.useState(initial2.current);
68870
+ const sizeRef = import_react53.useRef(initial2.current);
68810
68871
  import_react53.useEffect(() => {
68811
68872
  if (!process.stdout.isTTY)
68812
68873
  return;
68813
68874
  const onResize = () => {
68814
68875
  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
- });
68876
+ const prev = sizeRef.current;
68877
+ if (prev.columns === columns && prev.rows === rows)
68878
+ return;
68879
+ clearScreenAndScrollback();
68880
+ const next = { columns, rows, resizeKey: prev.resizeKey + 1 };
68881
+ sizeRef.current = next;
68882
+ setSize(next);
68820
68883
  };
68821
68884
  process.stdout.on("resize", onResize);
68822
68885
  return () => {
@@ -70863,14 +70926,8 @@ function TaskLoop({ opts }) {
70863
70926
  const { exit } = use_app_default();
70864
70927
  const loop = useLoop(opts);
70865
70928
  const { isRawModeSupported } = use_stdin_default();
70866
- const { stdout } = use_stdout_default();
70867
70929
  const { resizeKey } = useTerminalSize();
70868
70930
  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
70931
  const feedItems = import_react56.useMemo(() => [
70875
70932
  bannerItem.current,
70876
70933
  ...loop.logLines.map((e) => ({ id: e.id, kind: "entry", entry: e }))
@@ -92410,7 +92467,7 @@ var init_zod2 = __esm(() => {
92410
92467
  });
92411
92468
 
92412
92469
  // packages/workflow/src/schema.ts
92413
- var MarkerSchema, GetIndicatorSchema, SetIndicatorSchema, IndicatorsSchema, ProjectSchema, CommandsSchema, BoundariesSchema, WorkflowConfigSchema;
92470
+ var MarkerSchema, GetIndicatorSchema, SetIndicatorSchema, IndicatorsSchema, ProjectSchema, CommandsSchema, DEFAULT_META_ONLY_FILES, BoundariesSchema, WorkflowConfigSchema;
92414
92471
  var init_schema = __esm(() => {
92415
92472
  init_zod2();
92416
92473
  MarkerSchema = exports_external2.object({
@@ -92421,7 +92478,7 @@ var init_schema = __esm(() => {
92421
92478
  filter: exports_external2.array(MarkerSchema).default([])
92422
92479
  });
92423
92480
  SetIndicatorSchema = exports_external2.union([exports_external2.array(MarkerSchema).min(1), MarkerSchema]);
92424
- IndicatorsSchema = exports_external2.object({
92481
+ IndicatorsSchema = exports_external2.preprocess((v) => v == null ? {} : v, exports_external2.object({
92425
92482
  getTodo: GetIndicatorSchema.optional(),
92426
92483
  getInProgress: GetIndicatorSchema.optional(),
92427
92484
  getConflicted: GetIndicatorSchema.optional(),
@@ -92450,7 +92507,7 @@ var init_schema = __esm(() => {
92450
92507
  }
92451
92508
  }
92452
92509
  }
92453
- });
92510
+ }));
92454
92511
  ProjectSchema = exports_external2.object({
92455
92512
  name: exports_external2.string().optional(),
92456
92513
  language: exports_external2.string().optional(),
@@ -92462,9 +92519,17 @@ var init_schema = __esm(() => {
92462
92519
  build: exports_external2.string().optional(),
92463
92520
  typecheck: exports_external2.string().optional()
92464
92521
  }).catchall(exports_external2.string()).default({});
92522
+ DEFAULT_META_ONLY_FILES = [
92523
+ "openspec/**",
92524
+ ".ralph/**",
92525
+ "**/agent-tasks.md",
92526
+ "**/tasks.md",
92527
+ "**/MANUAL_TESTING*.md"
92528
+ ];
92465
92529
  BoundariesSchema = exports_external2.object({
92466
- never_touch: exports_external2.array(exports_external2.string()).default([])
92467
- }).strict().default({ never_touch: [] });
92530
+ never_touch: exports_external2.array(exports_external2.string()).default([]),
92531
+ meta_only_files: exports_external2.array(exports_external2.string()).default(DEFAULT_META_ONLY_FILES)
92532
+ }).strict().default({ never_touch: [], meta_only_files: DEFAULT_META_ONLY_FILES });
92468
92533
  WorkflowConfigSchema = exports_external2.object({
92469
92534
  project: ProjectSchema,
92470
92535
  commands: CommandsSchema,
@@ -92501,20 +92566,20 @@ var init_schema = __esm(() => {
92501
92566
  assignee: exports_external2.string().optional(),
92502
92567
  postComments: exports_external2.boolean().default(true),
92503
92568
  updateEveryIterations: exports_external2.number().int().nonnegative().default(10),
92504
- mentionTrigger: exports_external2.boolean().default(false),
92569
+ mentionTrigger: exports_external2.boolean().default(true),
92505
92570
  mentionHandle: exports_external2.string().default("@ralphy"),
92506
- codeReviewTrigger: exports_external2.boolean().default(false),
92571
+ codeReviewTrigger: exports_external2.boolean().default(true),
92507
92572
  codeReviewStaleHours: exports_external2.number().nonnegative().default(24),
92508
- syncTasksToDescription: exports_external2.boolean().default(false),
92573
+ syncTasksToComment: exports_external2.boolean().default(true),
92509
92574
  indicators: IndicatorsSchema.default({})
92510
92575
  }).strict().default({
92511
92576
  postComments: true,
92512
92577
  updateEveryIterations: 10,
92513
- mentionTrigger: false,
92578
+ mentionTrigger: true,
92514
92579
  mentionHandle: "@ralphy",
92515
- codeReviewTrigger: false,
92580
+ codeReviewTrigger: true,
92516
92581
  codeReviewStaleHours: 24,
92517
- syncTasksToDescription: false,
92582
+ syncTasksToComment: true,
92518
92583
  indicators: {}
92519
92584
  }),
92520
92585
  github: exports_external2.object({
@@ -92573,70 +92638,71 @@ boundaries:
92573
92638
  never_touch:
92574
92639
  - "dist/**"
92575
92640
  - ".claude/worktrees/**"
92576
-
92641
+ # Files that count as "meta only" for the pre-PR substantive-diff guard.
92642
+ # If every changed file matches one of these globs, the loop refuses to
92643
+ # open the PR and respawns the worker \u2014 the actual implementation was
92644
+ # lost (either deleted mid-loop or absorbed by a merge from base).
92645
+ meta_only_files:
92646
+ - "openspec/**"
92647
+ - ".ralph/**"
92648
+ - "**/agent-tasks.md"
92649
+ - "**/tasks.md"
92650
+ - "**/MANUAL_TESTING*.md"
92651
+
92652
+ # \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
92653
  # How many tasks to run in parallel.
92578
92654
  concurrency: 1
92579
-
92580
92655
  # Seconds between polls for new Linear issues (agent mode).
92581
92656
  pollIntervalSeconds: 60
92657
+ # Seconds to wait between loop iterations (throttle).
92658
+ iterationDelaySeconds: 0
92582
92659
 
92583
- # Maximum iterations per task. 0 = unlimited.
92660
+ # \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
92661
  maxIterationsPerTask: 0
92585
-
92586
- # Maximum cost in USD per task. 0 = unlimited.
92587
92662
  maxCostUsdPerTask: 0
92588
-
92589
- # Maximum wall-clock minutes per task. 0 = unlimited.
92590
92663
  maxRuntimeMinutesPerTask: 0
92591
-
92592
92664
  # Stop a task after this many consecutive identical failures.
92593
92665
  maxConsecutiveFailuresPerTask: 5
92594
92666
 
92595
- # Seconds to wait between loop iterations (throttle).
92596
- iterationDelaySeconds: 0
92597
-
92667
+ # \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
92668
+ # Underlying engine: "claude" or "codex".
92669
+ engine: claude
92670
+ # Model tier: "haiku", "sonnet", or "opus".
92671
+ model: opus
92598
92672
  # Log the raw engine stream to stdout.
92599
92673
  logRawStream: false
92600
-
92601
92674
  # Pass --verbose to the ralph task sub-process.
92602
92675
  taskVerbose: false
92603
92676
 
92677
+ # \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
92678
  # Run each task in an isolated git worktree.
92605
92679
  useWorktree: false
92606
-
92607
92680
  # Delete the worktree after a successful task.
92608
92681
  cleanupWorktreeOnSuccess: false
92609
92682
 
92683
+ # \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
92684
  # Open a pull request after a task succeeds.
92611
92685
  createPrOnSuccess: false
92612
-
92613
92686
  # Base branch for pull requests.
92614
92687
  prBaseBranch: main
92615
-
92616
92688
  # When true, stack dependent issues' PRs onto their blocker's open PR.
92617
92689
  stackPrsOnDependencies: false
92618
-
92619
92690
  # Strategy used when GitHub auto-merge is enabled.
92620
92691
  autoMergeStrategy: squash
92621
92692
 
92693
+ # \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
92694
  # Let the agent attempt to fix CI failures after a PR is created.
92623
92695
  fixCiOnFailure: false
92624
-
92625
92696
  # Maximum number of CI-fix attempts per task.
92626
92697
  maxCiFixAttempts: 5
92627
-
92628
92698
  # Seconds between CI status polls.
92629
92699
  ciPollIntervalSeconds: 30
92630
92700
 
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.
92701
+ # \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
92702
+ # Pre-existing error check: gate the agent when the base branch is already
92703
+ # broken. When enabled, the agent runs these commands against the base
92704
+ # branch HEAD before scheduling new work; failures open a Linear ticket
92705
+ # and pause new pickups.
92640
92706
  preExistingErrorCheck:
92641
92707
  enabled: false
92642
92708
  # Commands to run against the base branch. When empty, falls back to commands.lint / commands.test.
@@ -92645,38 +92711,49 @@ preExistingErrorCheck:
92645
92711
  label: "ralph:pre-existing-error"
92646
92712
  outputCharLimit: 4000
92647
92713
 
92714
+ # \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
92715
  linear:
92649
92716
  # Linear team key (e.g. "ENG"). Omit to match all teams.
92650
92717
  # team: ENG
92651
92718
 
92652
92719
  # Post progress comments on the Linear issue while a task is running.
92653
92720
  postComments: true
92654
-
92655
92721
  # Post a progress update every N iterations. 0 disables.
92656
92722
  updateEveryIterations: 10
92657
92723
 
92658
92724
  # Watch done-issue comments + linked GitHub PR comments for @ralphy mentions.
92659
- mentionTrigger: false
92725
+ mentionTrigger: true
92660
92726
  mentionHandle: "@ralphy"
92661
92727
 
92662
92728
  # Watch open tracked PRs for unresolved review-thread comments.
92663
- codeReviewTrigger: false
92729
+ codeReviewTrigger: true
92664
92730
  codeReviewStaleHours: 24
92665
92731
 
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
92732
+ # Mirror the loop's tasks.md into a sticky Linear comment (always the
92733
+ # last comment on the issue). Updates on worker launch, on the same
92734
+ # cadence as updateEveryIterations, and on done-transition.
92735
+ syncTasksToComment: true
92670
92736
 
92671
92737
  # 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
92738
+ #
92739
+ # Filter semantics (per indicator's \`filter:\` list):
92740
+ # \u2022 Entries of the SAME type (e.g. two \`status\` entries) are ORed
92741
+ # \u2014 the issue matches if any value matches.
92742
+ # \u2022 Entries of DIFFERENT types (one \`status\` + one \`label\`) are
92743
+ # ANDed \u2014 the issue must satisfy every type.
92744
+ # Example: a filter with two statuses + one label matches issues
92745
+ # where status \u2208 {A, B} AND label = L.
92746
+ #
92747
+ # Sections below group one state at a time; its get/set/clear sit
92748
+ # adjacent so the lifecycle reads top-to-bottom.
92749
+ indicators:
92750
+ # \u2500\u2500 Todo (pickup trigger) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
92676
92751
  # getTodo:
92677
92752
  # filter:
92678
92753
  # - type: status
92679
92754
  # value: Todo
92755
+ #
92756
+ # \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
92757
  # getInProgress:
92681
92758
  # filter:
92682
92759
  # - type: status
@@ -92685,7 +92762,7 @@ linear:
92685
92762
  # type: status
92686
92763
  # value: In Progress
92687
92764
  #
92688
- # # Done / review hand-off
92765
+ # \u2500\u2500 Done \u2192 Review hand-off \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
92689
92766
  # setDone:
92690
92767
  # type: status
92691
92768
  # value: In Review
@@ -92697,7 +92774,7 @@ linear:
92697
92774
  # type: label
92698
92775
  # value: "ralph:review"
92699
92776
  #
92700
- # # Conflict lifecycle
92777
+ # \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
92778
  # getConflicted:
92702
92779
  # filter:
92703
92780
  # - type: label
@@ -92709,13 +92786,13 @@ linear:
92709
92786
  # type: label
92710
92787
  # value: "ralph:conflict"
92711
92788
  #
92712
- # # Auto-merge opt-in
92789
+ # \u2500\u2500 Auto-merge (opt-in) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
92713
92790
  # getAutoMerge:
92714
92791
  # filter:
92715
92792
  # - type: label
92716
92793
  # value: "ralph:auto-merge"
92717
92794
  #
92718
- # # Error quarantine
92795
+ # \u2500\u2500 Error quarantine \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
92719
92796
  # setError:
92720
92797
  # type: label
92721
92798
  # value: "ralph:error"
@@ -93693,28 +93770,102 @@ async function fetchOpenIssues(apiKey, spec) {
93693
93770
  blockedByIds: (n.relations?.nodes ?? []).filter((r) => r.type === "blocked_by" && !DONE_STATE_TYPES.has(r.relatedIssue.state.type)).map((r) => r.relatedIssue.id)
93694
93771
  }));
93695
93772
  }
93773
+ function isRetryableStatus(status) {
93774
+ return status >= 500 && status <= 599;
93775
+ }
93776
+ function parseRetryAfter(header) {
93777
+ if (!header)
93778
+ return;
93779
+ const trimmed = header.trim();
93780
+ if (!trimmed)
93781
+ return;
93782
+ const asNum = Number(trimmed);
93783
+ if (Number.isFinite(asNum))
93784
+ return Math.max(0, asNum * 1000);
93785
+ const asDate = Date.parse(trimmed);
93786
+ if (Number.isFinite(asDate))
93787
+ return Math.max(0, asDate - Date.now());
93788
+ return;
93789
+ }
93790
+ function backoffMs(attempt2) {
93791
+ const base2 = 250 * 2 ** (attempt2 - 1);
93792
+ const jitter = Math.floor(Math.random() * 100);
93793
+ return base2 + jitter;
93794
+ }
93795
+ function isRateLimitedBody(body) {
93796
+ if (typeof body !== "string" || body.length === 0)
93797
+ return false;
93798
+ return body.toLowerCase().includes("rate limit exceeded");
93799
+ }
93800
+ function isRateLimitedError(err) {
93801
+ if (err === null || typeof err !== "object")
93802
+ return false;
93803
+ return err.rateLimited === true;
93804
+ }
93805
+ function formatLinearError(err) {
93806
+ if (err === null || err === undefined)
93807
+ return String(err);
93808
+ if (typeof err !== "object")
93809
+ return String(err);
93810
+ const e = err;
93811
+ const parts = [];
93812
+ if (e.rateLimited)
93813
+ parts.push("rate limited");
93814
+ if (typeof e.status === "number")
93815
+ parts.push(`HTTP ${e.status}`);
93816
+ if (Array.isArray(e.messages) && e.messages.length > 0) {
93817
+ parts.push(`graphql: ${e.messages.join("; ")}`);
93818
+ }
93819
+ if (typeof e.body === "string" && e.body.length > 0 && !e.rateLimited) {
93820
+ const truncated = e.body.length > 200 ? `${e.body.slice(0, 200)}\u2026` : e.body;
93821
+ parts.push(`body: ${truncated}`);
93822
+ }
93823
+ if (parts.length === 0) {
93824
+ if (typeof e.message === "string" && e.message)
93825
+ return e.message;
93826
+ return String(err);
93827
+ }
93828
+ if (typeof e.message === "string" && e.message && !e.rateLimited)
93829
+ parts.unshift(e.message);
93830
+ return parts.join(" \u2014 ");
93831
+ }
93696
93832
  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");
93833
+ let lastHttpError;
93834
+ for (let attempt2 = 1;attempt2 <= MAX_LINEAR_ATTEMPTS; attempt2++) {
93835
+ const res = await fetch(LINEAR_API, {
93836
+ method: "POST",
93837
+ headers: { "Content-Type": "application/json", Authorization: apiKey },
93838
+ body: JSON.stringify({ query, variables })
93839
+ });
93840
+ if (!res.ok) {
93841
+ const err = new Error("Linear API request failed");
93842
+ err.status = res.status;
93843
+ err.body = await res.text();
93844
+ if (res.status === 429 || isRateLimitedBody(err.body)) {
93845
+ err.rateLimited = true;
93846
+ throw err;
93847
+ }
93848
+ lastHttpError = err;
93849
+ if (isRetryableStatus(res.status) && attempt2 < MAX_LINEAR_ATTEMPTS) {
93850
+ const ra = parseRetryAfter(res.headers.get("Retry-After"));
93851
+ const waitMs = Math.min(ra ?? backoffMs(attempt2), MAX_RETRY_AFTER_MS);
93852
+ await linearRequestInternals.sleep(waitMs);
93853
+ continue;
93854
+ }
93855
+ throw err;
93856
+ }
93857
+ const json2 = await res.json();
93858
+ if (json2.errors?.length) {
93859
+ const err = new Error("Linear API returned errors");
93860
+ err.messages = json2.errors.map((e) => e.message);
93861
+ throw err;
93862
+ }
93863
+ if (!json2.data) {
93864
+ throw new Error("Linear API returned no data");
93865
+ }
93866
+ return json2.data;
93716
93867
  }
93717
- return json2.data;
93868
+ throw lastHttpError ?? new Error("Linear API request failed");
93718
93869
  }
93719
93870
  async function addReactionToComment(apiKey, commentId, emoji3) {
93720
93871
  const mutation = `mutation Reaction($commentId: String!, $emoji: String!) {
@@ -93734,6 +93885,36 @@ async function addIssueComment(apiKey, issueId, body) {
93734
93885
  body
93735
93886
  });
93736
93887
  }
93888
+ async function createIssueComment(apiKey, issueId, body) {
93889
+ const mutation = `mutation Comment($issueId: String!, $body: String!) {
93890
+ commentCreate(input: { issueId: $issueId, body: $body }) {
93891
+ success
93892
+ comment { id }
93893
+ }
93894
+ }`;
93895
+ const data = await linearRequest(apiKey, mutation, { issueId, body });
93896
+ const id = data.commentCreate.comment?.id;
93897
+ if (!id)
93898
+ throw new Error("commentCreate returned no comment id");
93899
+ return id;
93900
+ }
93901
+ async function updateIssueComment(apiKey, commentId, body) {
93902
+ const mutation = `mutation UpdateComment($id: String!, $body: String!) {
93903
+ commentUpdate(id: $id, input: { body: $body }) { success }
93904
+ }`;
93905
+ await linearRequest(apiKey, mutation, {
93906
+ id: commentId,
93907
+ body
93908
+ });
93909
+ }
93910
+ async function deleteIssueComment(apiKey, commentId) {
93911
+ const mutation = `mutation DeleteComment($id: String!) {
93912
+ commentDelete(id: $id) { success }
93913
+ }`;
93914
+ await linearRequest(apiKey, mutation, {
93915
+ id: commentId
93916
+ });
93917
+ }
93737
93918
  async function fetchIssueComments(apiKey, issueId) {
93738
93919
  const query = `query Comments($id: String!) {
93739
93920
  issue(id: $id) {
@@ -93956,7 +94137,12 @@ async function removeLabelFromIssue(apiKey, issueId, labelId) {
93956
94137
  labelId
93957
94138
  });
93958
94139
  }
93959
- var LINEAR_API = "https://api.linear.app/graphql", RALPHY_ATTACHMENT_TITLE_FILTER = "Ralphy", RALPHY_ATTACHMENT_TITLE = "Ralphy", BRANCH_LABEL_PREFIX = "ralph:branch:";
94140
+ 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:";
94141
+ var init_linear = __esm(() => {
94142
+ linearRequestInternals = {
94143
+ sleep: (ms) => Bun.sleep(ms)
94144
+ };
94145
+ });
93960
94146
 
93961
94147
  // apps/agent/src/sort/compare.ts
93962
94148
  function chain(...comparators) {
@@ -93987,6 +94173,7 @@ function compareQueueEntries(getAutoMerge) {
93987
94173
  }
93988
94174
  var MODE_RANK;
93989
94175
  var init_queue_order = __esm(() => {
94176
+ init_linear();
93990
94177
  MODE_RANK = {
93991
94178
  resume: 0,
93992
94179
  "conflict-fix": 1,
@@ -94428,6 +94615,15 @@ class AgentCoordinator {
94428
94615
  } catch {}
94429
94616
  return true;
94430
94617
  }
94618
+ async notifySteeringAppended(changeName, message) {
94619
+ if (!this.deps.onSteeringAppended)
94620
+ return;
94621
+ try {
94622
+ await this.deps.onSteeringAppended(changeName, message);
94623
+ } catch (err) {
94624
+ this.deps.onLog(`! onSteeringAppended failed for ${changeName}: ${err.message}`, "yellow");
94625
+ }
94626
+ }
94431
94627
  async notifyExited(issue2, changeName, code, mode) {
94432
94628
  const ok = code === 0;
94433
94629
  if (this.deps.syncTasks && ok) {
@@ -94537,6 +94733,7 @@ var emptyPrStatus = () => ({ mergeable: 0, conflicted: 0, ciFailed: 0 }), emptyP
94537
94733
  prStatus: emptyPrStatus()
94538
94734
  });
94539
94735
  var init_coordinator = __esm(() => {
94736
+ init_linear();
94540
94737
  init_queue_order();
94541
94738
  init_src();
94542
94739
  });
@@ -94905,11 +95102,49 @@ ${issue2.description.trim()}` : ""
94905
95102
  ].filter(Boolean).join(`
94906
95103
  `);
94907
95104
  }
95105
+ async function diffFilesAgainstBase(runner, cwd2, base2) {
95106
+ let raw = "";
95107
+ try {
95108
+ const r = await runner.run(["git", "diff", "--name-only", `origin/${base2}...HEAD`], cwd2);
95109
+ raw = r.stdout;
95110
+ } catch {
95111
+ try {
95112
+ const r = await runner.run(["git", "diff", "--name-only", `${base2}...HEAD`], cwd2);
95113
+ raw = r.stdout;
95114
+ } catch {
95115
+ return [];
95116
+ }
95117
+ }
95118
+ return raw.split(`
95119
+ `).map((s) => s.trim()).filter(Boolean);
95120
+ }
95121
+ async function classifyDiffAgainstMeta(runner, cwd2, base2, metaOnlyFiles) {
95122
+ const files = await diffFilesAgainstBase(runner, cwd2, base2);
95123
+ if (files.length === 0 || metaOnlyFiles.length === 0) {
95124
+ return { files, onlyMeta: false };
95125
+ }
95126
+ const violations = findBoundaryViolations(files, metaOnlyFiles);
95127
+ const metaSet = new Set(violations.map((v) => v.file));
95128
+ const onlyMeta = files.every((f2) => metaSet.has(f2.replace(/\\/g, "/")));
95129
+ return { files, onlyMeta };
95130
+ }
94908
95131
  async function createPullRequest(input, runner) {
94909
95132
  const base2 = input.base ?? "main";
94910
95133
  const log2 = await runner.run(["git", "log", "--oneline", `${base2}..HEAD`, "--no-merges"], input.cwd);
94911
95134
  if (log2.stdout.trim() === "")
94912
95135
  return null;
95136
+ const metaOnlyFiles = input.metaOnlyFiles ?? [];
95137
+ if (metaOnlyFiles.length > 0) {
95138
+ const classification = await classifyDiffAgainstMeta(runner, input.cwd, base2, metaOnlyFiles);
95139
+ if (classification.onlyMeta && classification.files.length > 0) {
95140
+ return {
95141
+ url: null,
95142
+ created: false,
95143
+ blocked: "only-meta",
95144
+ blockedFiles: classification.files
95145
+ };
95146
+ }
95147
+ }
94913
95148
  await runner.run(["git", "push", "-u", "origin", input.branch], input.cwd);
94914
95149
  const existing = await runner.run([
94915
95150
  "gh",
@@ -94934,6 +95169,7 @@ async function createPullRequest(input, runner) {
94934
95169
  `).pop() ?? "";
94935
95170
  return { url: url2, created: true };
94936
95171
  }
95172
+ var init_pr = () => {};
94937
95173
 
94938
95174
  // apps/agent/src/agent/post-task.ts
94939
95175
  import { join as join20 } from "path";
@@ -95046,7 +95282,13 @@ async function createPrWithRetry(ctx, issue2) {
95046
95282
  while (true) {
95047
95283
  try {
95048
95284
  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);
95285
+ pr = await createPullRequest({
95286
+ cwd: ctx.cwd,
95287
+ branch: ctx.branch,
95288
+ issue: issue2,
95289
+ base: base2,
95290
+ metaOnlyFiles: ctx.cfg.metaOnlyFiles ?? []
95291
+ }, ctx.cmd);
95050
95292
  return { pr, gaveUp: false };
95051
95293
  } catch (err) {
95052
95294
  const e = err;
@@ -95312,49 +95554,97 @@ ${indented}${suffix}`, "yellow");
95312
95554
  }
95313
95555
  return PR_FAILED_EXIT;
95314
95556
  }
95315
- const { pr, gaveUp: prGaveUp } = await createPrWithRetry(ctx, issue2);
95316
- if (prGaveUp)
95317
- return PR_FAILED_EXIT;
95557
+ const maxOuterAttempts = cfg.maxCiFixAttempts;
95558
+ let onlyMetaAttempts = 0;
95559
+ let pr = null;
95560
+ while (true) {
95561
+ const attempt2 = await createPrWithRetry(ctx, issue2);
95562
+ if (attempt2.gaveUp)
95563
+ return PR_FAILED_EXIT;
95564
+ if (attempt2.pr?.blocked === "only-meta") {
95565
+ onlyMetaAttempts += 1;
95566
+ const files = attempt2.pr.blockedFiles ?? [];
95567
+ emit("pr-only-meta", `${files.length} meta file(s)`);
95568
+ log2(`! ${changeName}: branch diff against ${base2} contains only meta files \u2014 implementation appears lost. Refusing to open PR.`, "red");
95569
+ for (const f2 of files)
95570
+ log2(` ${f2}`, "red");
95571
+ if (onlyMetaAttempts > maxOuterAttempts) {
95572
+ log2(`! exceeded ${maxOuterAttempts} only-meta recovery attempts for ${changeName} \u2014 giving up`, "red");
95573
+ return PR_FAILED_EXIT;
95574
+ }
95575
+ const fileList = files.length > 0 ? files.map((f2) => `- ${f2}`).join(`
95576
+ `) : "(empty diff)";
95577
+ const retryCode = await runWorkerWithFixTask(ctx, "Reapply lost implementation files", [
95578
+ `The diff against \`${base2}\` contains only meta files`,
95579
+ `(openspec/tasks.md and similar). The substantive implementation`,
95580
+ `is missing from the branch \u2014 likely deleted by an earlier commit`,
95581
+ `or absorbed by a merge from origin/${base2}.`,
95582
+ "",
95583
+ `Files currently in the diff:`,
95584
+ fileList,
95585
+ "",
95586
+ `Re-apply the actual implementation work the change is supposed`,
95587
+ `to ship. Inspect git history (\`git log ${base2}..HEAD\`) to see`,
95588
+ `what was created earlier and lost, then restore those files`,
95589
+ `(or reproduce the work). Commit the restored files so the next`,
95590
+ `iteration's diff against \`${base2}\` contains real code, not`,
95591
+ `just meta files.`
95592
+ ].join(`
95593
+ `));
95594
+ if (retryCode !== 0) {
95595
+ log2(`! worker re-run after only-meta block exited code ${retryCode} \u2014 giving up`, "red");
95596
+ return PR_FAILED_EXIT;
95597
+ }
95598
+ continue;
95599
+ }
95600
+ pr = attempt2.pr;
95601
+ break;
95602
+ }
95318
95603
  if (!pr) {
95319
95604
  log2(` no commits ahead of ${base2} \u2014 skipping PR`, "gray");
95320
95605
  return 0;
95321
95606
  }
95322
- log2(` ${pr.created ? "opened" : "found existing"} PR: ${pr.url}`, "green");
95323
- registerPr?.(changeName, pr.url);
95607
+ const prUrl = pr.url;
95608
+ if (!prUrl) {
95609
+ log2(`! PR creation returned a null URL for ${changeName} \u2014 giving up`, "red");
95610
+ return PR_FAILED_EXIT;
95611
+ }
95612
+ log2(` ${pr.created ? "opened" : "found existing"} PR: ${prUrl}`, "green");
95613
+ registerPr?.(changeName, prUrl);
95324
95614
  let manualMergePending = false;
95325
95615
  if (wantAutoMerge) {
95326
95616
  const fallbackEnabled = cfg.manualMergeWhenAutoMergeDisabled !== false;
95327
- const repoAllowsAutoMerge = await detectRepoAutoMergeAllowed(pr.url, cmd, cwd2, log2);
95617
+ const repoAllowsAutoMerge = await detectRepoAutoMergeAllowed(prUrl, cmd, cwd2, log2);
95328
95618
  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");
95619
+ log2(` repo has auto-merge disabled \u2014 will poll ${prUrl} and merge via gh pr merge once checks pass`, "yellow");
95330
95620
  manualMergePending = true;
95331
95621
  } else {
95332
95622
  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");
95623
+ await cmd.run(["gh", "pr", "merge", prUrl, "--auto", `--${cfg.autoMergeStrategy}`], cwd2);
95624
+ log2(` enabled auto-merge (${cfg.autoMergeStrategy}) on ${prUrl}`, "green");
95335
95625
  emit("auto-merge-enabled", cfg.autoMergeStrategy);
95336
95626
  } catch (err) {
95337
95627
  const e = err;
95338
95628
  const detail = e.stderr?.trim() || e.message;
95339
- log2(`! failed to enable auto-merge on ${pr.url}: ${detail}`, "yellow");
95629
+ log2(`! failed to enable auto-merge on ${prUrl}: ${detail}`, "yellow");
95340
95630
  if (fallbackEnabled && /auto[- ]merge/i.test(detail)) {
95341
- log2(` falling back to manual merge after CI passes for ${pr.url}`, "yellow");
95631
+ log2(` falling back to manual merge after CI passes for ${prUrl}`, "yellow");
95342
95632
  manualMergePending = true;
95343
95633
  }
95344
95634
  }
95345
95635
  }
95346
95636
  }
95347
- const ciResult = await fixConflictsAndCiLoop(ctx, pr.url, wantFixCi, checkPrConflict);
95637
+ const ciResult = await fixConflictsAndCiLoop(ctx, prUrl, wantFixCi, checkPrConflict);
95348
95638
  if (ciResult !== 0)
95349
95639
  return ciResult;
95350
95640
  if (manualMergePending) {
95351
95641
  try {
95352
- await cmd.run(["gh", "pr", "merge", pr.url, `--${cfg.autoMergeStrategy}`], cwd2);
95353
- log2(` manually merged (${cfg.autoMergeStrategy}) ${pr.url}`, "green");
95642
+ await cmd.run(["gh", "pr", "merge", prUrl, `--${cfg.autoMergeStrategy}`], cwd2);
95643
+ log2(` manually merged (${cfg.autoMergeStrategy}) ${prUrl}`, "green");
95354
95644
  emit("auto-merge-enabled", `manual:${cfg.autoMergeStrategy}`);
95355
95645
  } catch (err) {
95356
95646
  const e = err;
95357
- log2(`! manual merge failed for ${pr.url}: ${e.stderr?.trim() || e.message}`, "yellow");
95647
+ log2(`! manual merge failed for ${prUrl}: ${e.stderr?.trim() || e.message}`, "yellow");
95358
95648
  }
95359
95649
  }
95360
95650
  return 0;
@@ -95455,6 +95745,8 @@ async function runPostTask(input, deps) {
95455
95745
  var CI_FAILED_EXIT = 70, PR_FAILED_EXIT = 71, repoAutoMergeCache;
95456
95746
  var init_post_task = __esm(() => {
95457
95747
  init_tasks_md();
95748
+ init_linear();
95749
+ init_pr();
95458
95750
  init_ci();
95459
95751
  init_worktree();
95460
95752
  repoAutoMergeCache = new Map;
@@ -95721,64 +96013,235 @@ function renderTasksBlock(tasksMd, meta3) {
95721
96013
  return out.join(`
95722
96014
  `);
95723
96015
  }
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}
96016
+ var RALPHY_TASKS_START = "<!-- ralphy:tasks:start -->", RALPHY_TASKS_END = "<!-- ralphy:tasks:end -->", MAX_CODE_BLOCK_BYTES;
96017
+ var init_linear_sync = __esm(() => {
96018
+ MAX_CODE_BLOCK_BYTES = 2 * 1024;
96019
+ });
95737
96020
 
95738
- ${block}`;
96021
+ // apps/agent/src/agent/linear-sync/comment-sync.ts
96022
+ import { dirname as dirname7, join as join21 } from "path";
96023
+ import { mkdir as mkdir6 } from "fs/promises";
96024
+ async function readStateJson(statePath) {
96025
+ const file2 = Bun.file(statePath);
96026
+ if (!await file2.exists())
96027
+ return null;
96028
+ try {
96029
+ return await file2.json();
96030
+ } catch {
96031
+ return null;
96032
+ }
96033
+ }
96034
+ async function writeStateJson(statePath, state) {
96035
+ await mkdir6(dirname7(statePath), { recursive: true });
96036
+ await Bun.write(statePath, JSON.stringify(state, null, 2) + `
96037
+ `);
96038
+ }
96039
+ function readComments(state) {
96040
+ const raw = state?.linearComments ?? {};
96041
+ return {
96042
+ planCommentId: raw?.planCommentId ?? null,
96043
+ tasksCommentId: raw?.tasksCommentId ?? null,
96044
+ planPostedAt: raw?.planPostedAt ?? null
96045
+ };
95739
96046
  }
95740
- async function syncTasksToLinearDescription(deps) {
95741
- const file2 = Bun.file(deps.tasksPath);
96047
+ async function patchComments(statePath, patch) {
96048
+ const existing = await readStateJson(statePath) ?? {};
96049
+ const current = readComments(existing);
96050
+ const next = { ...current, ...patch };
96051
+ await writeStateJson(statePath, { ...existing, linearComments: next });
96052
+ }
96053
+ function isCommentNotFoundError(err) {
96054
+ if (!err)
96055
+ return false;
96056
+ const candidates = [];
96057
+ const e = err;
96058
+ if (Array.isArray(e.messages))
96059
+ candidates.push(...e.messages);
96060
+ if (typeof e.message === "string")
96061
+ candidates.push(e.message);
96062
+ const text = candidates.join(" ").toLowerCase();
96063
+ return text.includes("not found") || text.includes("could not find") || text.includes("entity not found");
96064
+ }
96065
+ async function readTasksMd(changeDir, log2) {
96066
+ const file2 = Bun.file(join21(changeDir, "tasks.md"));
95742
96067
  if (!await file2.exists()) {
95743
- deps.log(` sync-tasks: tasks.md missing at ${deps.tasksPath}, skipping`, "gray");
96068
+ log2(` comment-sync: tasks.md missing in ${changeDir}, skipping`, "gray");
95744
96069
  return null;
95745
96070
  }
95746
- let tasksMd;
95747
96071
  try {
95748
- tasksMd = await file2.text();
96072
+ return await file2.text();
95749
96073
  } catch (err) {
95750
- deps.log(`! sync-tasks: read failed for ${deps.tasksPath}: ${err.message}`, "yellow");
96074
+ log2(`! comment-sync: read tasks.md failed: ${err.message}`, "yellow");
95751
96075
  return null;
95752
96076
  }
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");
96077
+ }
96078
+ function renderTasksCommentBody(tasksMd, changeName, iteration) {
96079
+ return renderTasksBlock(tasksMd, { changeName, iteration });
96080
+ }
96081
+ async function postOrUpdateTasksComment(deps) {
96082
+ const tasksMd = await readTasksMd(deps.changeDir, deps.log);
96083
+ if (!tasksMd)
96084
+ return null;
96085
+ const body = renderTasksCommentBody(tasksMd, deps.changeName, deps.iteration);
96086
+ const state = await readStateJson(deps.statePath);
96087
+ const comments = readComments(state);
96088
+ if (comments.tasksCommentId) {
96089
+ try {
96090
+ await deps.mutations.updateIssueComment(deps.apiKey, comments.tasksCommentId, body);
96091
+ deps.log(` comment-sync: updated tasks comment for ${deps.changeName}`, "gray");
96092
+ return comments.tasksCommentId;
96093
+ } catch (err) {
96094
+ if (!isCommentNotFoundError(err)) {
96095
+ deps.log(`! comment-sync: updateIssueComment failed: ${err.message}`, "yellow");
96096
+ return null;
96097
+ }
96098
+ deps.log(` comment-sync: tasks comment ${comments.tasksCommentId} not found \u2014 recreating`, "gray");
96099
+ }
96100
+ }
96101
+ let newId;
96102
+ try {
96103
+ newId = await deps.mutations.createIssueComment(deps.apiKey, deps.issueId, body);
96104
+ } catch (err) {
96105
+ deps.log(`! comment-sync: createIssueComment failed: ${err.message}`, "yellow");
95759
96106
  return null;
95760
96107
  }
95761
- const next = applyTasksBlock(deps.currentDescription, block);
95762
- if (next === (deps.currentDescription ?? ""))
96108
+ await patchComments(deps.statePath, { tasksCommentId: newId });
96109
+ deps.log(` comment-sync: created tasks comment for ${deps.changeName}`, "gray");
96110
+ return newId;
96111
+ }
96112
+ function planningComplete(tasksMd) {
96113
+ const lines = tasksMd.split(/\r?\n/);
96114
+ let inPlanning = false;
96115
+ let total = 0;
96116
+ let unchecked = 0;
96117
+ for (const line of lines) {
96118
+ const h = /^##\s+(.+?)\s*$/.exec(line);
96119
+ if (h) {
96120
+ inPlanning = h[1].trim().toLowerCase() === "planning";
96121
+ continue;
96122
+ }
96123
+ if (!inPlanning)
96124
+ continue;
96125
+ const m = /^\s*-\s+\[( |x|X)\]/.exec(line);
96126
+ if (!m)
96127
+ continue;
96128
+ total += 1;
96129
+ if (m[1] === " ")
96130
+ unchecked += 1;
96131
+ }
96132
+ return { allChecked: total > 0 && unchecked === 0, total };
96133
+ }
96134
+ async function readFirstParagraph(path) {
96135
+ const file2 = Bun.file(path);
96136
+ if (!await file2.exists())
95763
96137
  return null;
96138
+ const text = await file2.text();
96139
+ const blocks = text.split(/\r?\n\s*\r?\n/).map((b) => b.trim()).filter((b) => b.length > 0 && !/^#\s/.test(b));
96140
+ return blocks[0] ?? null;
96141
+ }
96142
+ async function readSection(path, heading) {
96143
+ const file2 = Bun.file(path);
96144
+ if (!await file2.exists())
96145
+ return null;
96146
+ const text = await file2.text();
96147
+ const headingRe = new RegExp(`(^|\\n)##\\s+${heading}\\s*\\n`);
96148
+ const m = headingRe.exec(text);
96149
+ if (!m)
96150
+ return null;
96151
+ const start = m.index + m[0].length;
96152
+ const rest2 = text.slice(start);
96153
+ const next = /\n##\s+/.exec(rest2);
96154
+ const body = next ? rest2.slice(0, next.index) : rest2;
96155
+ return body.trim() || null;
96156
+ }
96157
+ async function postPlanCommentOnce(deps) {
96158
+ const state = await readStateJson(deps.statePath);
96159
+ const comments = readComments(state);
96160
+ if (comments.planCommentId)
96161
+ return null;
96162
+ const tasksMd = await readTasksMd(deps.changeDir, deps.log);
96163
+ if (!tasksMd)
96164
+ return null;
96165
+ const check2 = planningComplete(tasksMd);
96166
+ if (!check2.allChecked)
96167
+ return null;
96168
+ const proposalPath = join21(deps.changeDir, "proposal.md");
96169
+ const why = await readSection(proposalPath, "Why");
96170
+ const whatChanges = await readSection(proposalPath, "What Changes");
96171
+ if (!why && !whatChanges) {
96172
+ deps.log(` comment-sync: proposal.md has no Why/What Changes, skipping plan comment`, "gray");
96173
+ return null;
96174
+ }
96175
+ const designSummary = await readFirstParagraph(join21(deps.changeDir, "design.md"));
96176
+ const parts = [`### ${PLAN_COMMENT_TITLE} \u2014 \`${deps.changeName}\``];
96177
+ if (why) {
96178
+ parts.push("", "**Why**", "", why);
96179
+ }
96180
+ if (whatChanges) {
96181
+ parts.push("", "**What Changes**", "", whatChanges);
96182
+ }
96183
+ if (designSummary) {
96184
+ parts.push("", "**Design**", "", designSummary);
96185
+ }
96186
+ const body = parts.join(`
96187
+ `);
96188
+ let id;
95764
96189
  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;
96190
+ id = await deps.mutations.createIssueComment(deps.apiKey, deps.issueId, body);
95768
96191
  } catch (err) {
95769
- deps.log(`! sync-tasks: updateIssueDescription failed: ${err.message}`, "yellow");
96192
+ deps.log(`! comment-sync: plan comment create failed: ${err.message}`, "yellow");
95770
96193
  return null;
95771
96194
  }
96195
+ await patchComments(deps.statePath, {
96196
+ planCommentId: id,
96197
+ planPostedAt: new Date().toISOString()
96198
+ });
96199
+ deps.log(` comment-sync: posted plan comment for ${deps.changeName}`, "gray");
96200
+ return id;
95772
96201
  }
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;
96202
+ async function postSteeringAndRefreshTasks(deps) {
96203
+ const firstLine = deps.message.split(/\r?\n/, 1)[0].trim() || deps.message.trim();
96204
+ const steeringBody = `### ${STEERING_COMMENT_TITLE}
96205
+
96206
+ ${deps.message.trim()}`;
96207
+ try {
96208
+ await deps.mutations.createIssueComment(deps.apiKey, deps.issueId, steeringBody);
96209
+ deps.log(` comment-sync: posted steering comment (${firstLine})`, "gray");
96210
+ } catch (err) {
96211
+ deps.log(`! comment-sync: steering comment create failed: ${err.message}`, "yellow");
96212
+ }
96213
+ const state = await readStateJson(deps.statePath);
96214
+ const comments = readComments(state);
96215
+ if (comments.tasksCommentId) {
96216
+ try {
96217
+ await deps.mutations.deleteIssueComment(deps.apiKey, comments.tasksCommentId);
96218
+ deps.log(` comment-sync: deleted old tasks comment`, "gray");
96219
+ } catch (err) {
96220
+ if (!isCommentNotFoundError(err)) {
96221
+ deps.log(`! comment-sync: deleteIssueComment failed: ${err.message}`, "yellow");
96222
+ }
96223
+ }
96224
+ await patchComments(deps.statePath, { tasksCommentId: null });
96225
+ }
96226
+ await postOrUpdateTasksComment({
96227
+ apiKey: deps.apiKey,
96228
+ issueId: deps.issueId,
96229
+ statePath: deps.statePath,
96230
+ changeDir: deps.changeDir,
96231
+ changeName: deps.changeName,
96232
+ log: deps.log,
96233
+ mutations: deps.mutations,
96234
+ iteration: deps.iteration
96235
+ });
96236
+ }
96237
+ var PLAN_COMMENT_TITLE = "\uD83D\uDCCB Ralph plan", STEERING_COMMENT_TITLE = "\uD83E\uDDED Ralph steering";
96238
+ var init_comment_sync = __esm(() => {
96239
+ init_linear_sync();
95777
96240
  });
95778
96241
 
95779
96242
  // apps/agent/src/agent/wire.ts
95780
- import { join as join21 } from "path";
95781
- import { mkdir as mkdir6 } from "fs/promises";
96243
+ import { join as join22 } from "path";
96244
+ import { mkdir as mkdir7 } from "fs/promises";
95782
96245
  async function pickOpenPrUrlFromAttachments(urls, issueIdent, cmd, cwd2, onLog) {
95783
96246
  const candidates = urls.filter((url2) => GITHUB_PR_URL_RE.test(url2));
95784
96247
  let sawNonOpenPr = false;
@@ -95942,7 +96405,7 @@ function buildAgentCoordinator(input) {
95942
96405
  onWorkerOutput,
95943
96406
  onWorkerCmd
95944
96407
  } = input;
95945
- const logsDir = join21(projectRoot, ".ralph", "logs");
96408
+ const logsDir = join22(projectRoot, ".ralph", "logs");
95946
96409
  const concurrency = args.concurrency || cfg.concurrency;
95947
96410
  const pollInterval = args.pollInterval || cfg.pollIntervalSeconds;
95948
96411
  const indicators = mergeIndicators(cfg.linear.indicators, args.indicators);
@@ -96065,6 +96528,7 @@ function buildAgentCoordinator(input) {
96065
96528
  const prUnavailable = new Map;
96066
96529
  const PR_UNAVAILABLE_TTL_MS = 10 * 60 * 1000;
96067
96530
  const stalePingedAt = new Map;
96531
+ const lastHandledReviewActivity = new Map;
96068
96532
  const useWorktree = args.worktree || cfg.useWorktree;
96069
96533
  const scriptRunner = input.runners?.runScript ?? (async (cmd, cwd2) => {
96070
96534
  const proc = Bun.spawn({
@@ -96154,8 +96618,8 @@ function buildAgentCoordinator(input) {
96154
96618
  } else {
96155
96619
  changeName = changeNameForIssue(issue2);
96156
96620
  const wtLayout = projectLayout(workerCwd);
96157
- await mkdir6(wtLayout.changeDir(changeName), { recursive: true });
96158
- await mkdir6(wtLayout.taskStateDir(changeName), { recursive: true });
96621
+ await mkdir7(wtLayout.changeDir(changeName), { recursive: true });
96622
+ await mkdir7(wtLayout.taskStateDir(changeName), { recursive: true });
96159
96623
  }
96160
96624
  cwdByChange.set(changeName, workerCwd);
96161
96625
  statesDirByChange.set(changeName, scaffoldStatesDir);
@@ -96164,7 +96628,7 @@ function buildAgentCoordinator(input) {
96164
96628
  branchByChange.set(changeName, branch);
96165
96629
  if (mode === "review") {
96166
96630
  const wtLayout = projectLayout(workerCwd);
96167
- const tasksFile = join21(wtLayout.changeDir(changeName), AGENT_TASKS_FILENAME);
96631
+ const tasksFile = join22(wtLayout.changeDir(changeName), AGENT_TASKS_FILENAME);
96168
96632
  let body;
96169
96633
  let heading;
96170
96634
  if (trigger) {
@@ -96189,7 +96653,7 @@ function buildAgentCoordinator(input) {
96189
96653
  await reactivateState2(wtLayout.stateFile(changeName), changeName);
96190
96654
  } else if (mode === "conflict-fix") {
96191
96655
  const wtLayout = projectLayout(workerCwd);
96192
- const tasksFile = join21(wtLayout.changeDir(changeName), AGENT_TASKS_FILENAME);
96656
+ const tasksFile = join22(wtLayout.changeDir(changeName), AGENT_TASKS_FILENAME);
96193
96657
  const prUrl = prByChange.get(changeName);
96194
96658
  const body = [
96195
96659
  `The PR for this change has merge conflicts with \`${cfg.prBaseBranch}\`.`,
@@ -96269,7 +96733,7 @@ PR: ${prUrl}` : ""
96269
96733
  return c;
96270
96734
  }
96271
96735
  function defaultSpawn(changeName, cmd, cwd2, note) {
96272
- const logFilePath = join21(logsDir, `${changeName}.log`);
96736
+ const logFilePath = join22(logsDir, `${changeName}.log`);
96273
96737
  const ANSI_RE2 = /\x1b(?:\[[0-9;]*[A-Za-z]|\][^\x07\x1b]*(?:\x07|\x1b\\)|.)/g;
96274
96738
  const BOX_ONLY_RE = /^[\s\u2500\u2502\u256D\u256E\u2570\u256F\u254C\u2504\u2501\u2503]+$/;
96275
96739
  const STATUS_BAR_LINE_RE = /^[\u280B\u2819\u2839\u2838\u283C\u2834\u2826\u2827\u2807\u280F\u2713\u2717]\s+iter\s+\d+/;
@@ -96328,7 +96792,7 @@ PR: ${prUrl}` : ""
96328
96792
  function spawnWorker(changeName) {
96329
96793
  const cwd2 = cwdByChange.get(changeName) ?? projectRoot;
96330
96794
  const injected = input.runners?.spawnWorker;
96331
- const missionTasksPath = join21(projectLayout(cwd2).changeDir(changeName), MISSION_TASKS_FILENAME);
96795
+ const missionTasksPath = join22(projectLayout(cwd2).changeDir(changeName), MISSION_TASKS_FILENAME);
96332
96796
  const prevTasksPromise = (async () => {
96333
96797
  const f2 = Bun.file(missionTasksPath);
96334
96798
  return await f2.exists() ? await f2.text() : "";
@@ -96336,7 +96800,7 @@ PR: ${prUrl}` : ""
96336
96800
  let logFilePath;
96337
96801
  let handle;
96338
96802
  if (injected) {
96339
- logFilePath = join21(logsDir, `${changeName}.log`);
96803
+ logFilePath = join22(logsDir, `${changeName}.log`);
96340
96804
  handle = injected(buildTaskCmdFor(changeName), cwd2);
96341
96805
  } else {
96342
96806
  const r = defaultSpawn(changeName, buildTaskCmdFor(changeName), cwd2, `spawn at ${new Date().toISOString()}`);
@@ -96396,6 +96860,7 @@ PR: ${prUrl}` : ""
96396
96860
  ignoreCiChecks: cfg.ignoreCiChecks,
96397
96861
  stackPrsOnDependencies: args.stackPrs || cfg.stackPrsOnDependencies,
96398
96862
  neverTouch: cfg.boundaries.never_touch,
96863
+ metaOnlyFiles: cfg.boundaries.meta_only_files,
96399
96864
  manualMergeWhenAutoMergeDisabled: cfg.manualMergeWhenAutoMergeDisabled
96400
96865
  },
96401
96866
  respawnWorker: respawn
@@ -96608,17 +97073,32 @@ PR: ${prUrl}` : ""
96608
97073
  try {
96609
97074
  candidates = await fetchMentionScanIssues(apiKey, { team, assignee });
96610
97075
  } catch (err) {
96611
- onLog(`! mention scan: fetchMentionScanIssues failed: ${err.message}`, "yellow");
97076
+ if (isRateLimitedError(err)) {
97077
+ onLog(`! mention scan: rate limited, deferring rest of scan to next poll`, "yellow");
97078
+ return [];
97079
+ }
97080
+ onLog(`! mention scan: fetchMentionScanIssues failed: ${formatLinearError(err)}`, "yellow");
96612
97081
  return [];
96613
97082
  }
96614
97083
  const out = [];
96615
97084
  const queued = new Set;
97085
+ let rateLimitedLogged = false;
97086
+ const logRateLimited = () => {
97087
+ if (rateLimitedLogged)
97088
+ return;
97089
+ rateLimitedLogged = true;
97090
+ onLog(`! mention scan: rate limited, deferring rest of scan to next poll`, "yellow");
97091
+ };
96616
97092
  for (const issue2 of candidates) {
96617
97093
  let comments = [];
96618
97094
  try {
96619
97095
  comments = await fetchIssueComments(apiKey, issue2.id);
96620
97096
  } catch (err) {
96621
- onLog(`! mention scan: Linear comments failed for ${issue2.identifier}: ${err.message}`, "yellow");
97097
+ if (isRateLimitedError(err)) {
97098
+ logRateLimited();
97099
+ break;
97100
+ }
97101
+ onLog(`! mention scan: Linear comments failed for ${issue2.identifier}: ${formatLinearError(err)}`, "yellow");
96622
97102
  continue;
96623
97103
  }
96624
97104
  const lastRalphPickup = findLastRalphPickupISO(comments);
@@ -96643,11 +97123,18 @@ PR: ${prUrl}` : ""
96643
97123
  try {
96644
97124
  await addReactionToComment(apiKey, c.id, "\uD83D\uDC40");
96645
97125
  } catch (err) {
96646
- onLog(`! mention scan: Linear reaction failed for ${issue2.identifier}: ${err.message}`, "yellow");
97126
+ if (isRateLimitedError(err)) {
97127
+ logRateLimited();
97128
+ queued.add(issue2.id);
97129
+ break;
97130
+ }
97131
+ onLog(`! mention scan: Linear reaction failed for ${issue2.identifier}: ${formatLinearError(err)}`, "yellow");
96647
97132
  }
96648
97133
  queued.add(issue2.id);
96649
97134
  break;
96650
97135
  }
97136
+ if (rateLimitedLogged)
97137
+ break;
96651
97138
  if (queued.has(issue2.id))
96652
97139
  continue;
96653
97140
  }
@@ -96677,7 +97164,7 @@ PR: ${prUrl}` : ""
96677
97164
  try {
96678
97165
  await addGithubReactionToComment({ owner, repo, kind: "issue" }, c.id, "\uD83D\uDC40");
96679
97166
  } catch (err) {
96680
- onLog(`! mention scan: GitHub reaction failed for ${prUrl}: ${err.message}`, "yellow");
97167
+ onLog(`! mention scan: GitHub reaction failed for ${prUrl}: ${formatLinearError(err)}`, "yellow");
96681
97168
  }
96682
97169
  }
96683
97170
  queued.add(issue2.id);
@@ -96707,7 +97194,9 @@ PR: ${prUrl}` : ""
96707
97194
  const last2 = t.comments[t.comments.length - 1].createdAt;
96708
97195
  return last2 > acc ? last2 : acc;
96709
97196
  }, "");
96710
- if (!lastRalphPickup || newestReviewerActivity > lastRalphPickup) {
97197
+ const lastHandled = lastHandledReviewActivity.get(prUrl) ?? null;
97198
+ const effectiveLastHandled = lastRalphPickup && lastHandled ? lastRalphPickup > lastHandled ? lastRalphPickup : lastHandled : lastRalphPickup ?? lastHandled;
97199
+ if (!effectiveLastHandled || newestReviewerActivity > effectiveLastHandled) {
96711
97200
  const body = unresolved.map((t) => {
96712
97201
  const head3 = t.path ? `_${t.path}${t.line ? `:${t.line}` : ""}_` : "_(general)_";
96713
97202
  const lines = t.comments.map((c) => `> **${c.author ?? "reviewer"}** (${c.createdAt})
@@ -96721,6 +97210,7 @@ PR: ${prUrl}` : ""
96721
97210
  ---
96722
97211
 
96723
97212
  `);
97213
+ lastHandledReviewActivity.set(prUrl, newestReviewerActivity);
96724
97214
  return {
96725
97215
  source: "github-review",
96726
97216
  body,
@@ -96868,10 +97358,16 @@ PR: ${prUrl}` : ""
96868
97358
  const parsed = JSON.parse(res.stdout || "[]");
96869
97359
  return parsed;
96870
97360
  } catch (err) {
96871
- onLog(`! mention scan: gh comments failed for ${prUrl}: ${err.message}`, "yellow");
97361
+ onLog(`! mention scan: gh comments failed for ${prUrl}: ${formatLinearError(err)}`, "yellow");
96872
97362
  return [];
96873
97363
  }
96874
97364
  }
97365
+ const commentSyncEnabled = Boolean(cfg.linear.syncTasksToComment && apiKey);
97366
+ const commentMutations = {
97367
+ createIssueComment,
97368
+ updateIssueComment,
97369
+ deleteIssueComment
97370
+ };
96875
97371
  const coord = new AgentCoordinator({
96876
97372
  fetchTodo: () => fetchByGet(indicators.getTodo, excludeFromTodo),
96877
97373
  fetchInProgress: () => fetchByGet(indicators.getInProgress, []),
@@ -96900,26 +97396,62 @@ PR: ${prUrl}` : ""
96900
97396
  const json2 = await file2.json();
96901
97397
  return json2.iteration ?? 0;
96902
97398
  },
96903
- ...cfg.linear.syncTasksToDescription && apiKey ? {
97399
+ ...commentSyncEnabled ? {
96904
97400
  syncTasks: async (worker, iteration) => {
96905
97401
  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({
97402
+ const layout = projectLayout(root);
97403
+ const changeDir = layout.changeDir(worker.changeName);
97404
+ const statePath = layout.stateFile(worker.changeName);
97405
+ await postPlanCommentOnce({
96909
97406
  apiKey,
96910
97407
  issueId: worker.issueId,
96911
- currentDescription: cachedIssue.description,
96912
- tasksPath,
97408
+ statePath,
97409
+ changeDir,
97410
+ changeName: worker.changeName,
97411
+ log: onLog,
97412
+ mutations: commentMutations
97413
+ });
97414
+ await postOrUpdateTasksComment({
97415
+ apiKey,
97416
+ issueId: worker.issueId,
97417
+ statePath,
97418
+ changeDir,
96913
97419
  changeName: worker.changeName,
96914
97420
  iteration,
96915
97421
  log: onLog,
96916
- updateIssueDescription
97422
+ mutations: commentMutations
96917
97423
  });
96918
- if (next !== null) {
96919
- const updated = { ...cachedIssue, description: next };
96920
- issueByChange.set(worker.changeName, updated);
96921
- worker.issue = updated;
97424
+ },
97425
+ onSteeringAppended: async (changeName, message) => {
97426
+ const root = cwdByChange.get(changeName) ?? projectRoot;
97427
+ const layout = projectLayout(root);
97428
+ const changeDir = layout.changeDir(changeName);
97429
+ const statePath = layout.stateFile(changeName);
97430
+ const issue2 = issueByChange.get(changeName) ?? null;
97431
+ const issueId = issue2?.id ?? null;
97432
+ if (!issueId) {
97433
+ onLog(` comment-sync: no Linear issue cached for ${changeName}; skipping steering refresh`, "gray");
97434
+ return;
96922
97435
  }
97436
+ let iteration = 0;
97437
+ try {
97438
+ const f2 = Bun.file(statePath);
97439
+ if (await f2.exists()) {
97440
+ const json2 = await f2.json();
97441
+ iteration = json2.iteration ?? 0;
97442
+ }
97443
+ } catch {}
97444
+ await postSteeringAndRefreshTasks({
97445
+ apiKey,
97446
+ issueId,
97447
+ statePath,
97448
+ changeDir,
97449
+ changeName,
97450
+ iteration,
97451
+ message,
97452
+ log: onLog,
97453
+ mutations: commentMutations
97454
+ });
96923
97455
  }
96924
97456
  } : {}
96925
97457
  }, {
@@ -96988,7 +97520,7 @@ PR: ${prUrl}` : ""
96988
97520
  concurrency,
96989
97521
  pollInterval,
96990
97522
  getWorkerCwd: (changeName) => cwdByChange.get(changeName),
96991
- syncTasksEnabled: Boolean(cfg.linear.syncTasksToDescription && apiKey),
97523
+ syncTasksEnabled: commentSyncEnabled,
96992
97524
  runBaselineGate: runBaselineGateOnce
96993
97525
  };
96994
97526
  }
@@ -97017,6 +97549,7 @@ var init_wire = __esm(() => {
97017
97549
  init_tasks_md();
97018
97550
  init_workflow();
97019
97551
  init_types2();
97552
+ init_linear();
97020
97553
  init_coordinator();
97021
97554
  init_scaffold();
97022
97555
  init_worktree();
@@ -97024,7 +97557,7 @@ var init_wire = __esm(() => {
97024
97557
  init_post_task();
97025
97558
  init_gate();
97026
97559
  init_workflow();
97027
- init_linear_sync();
97560
+ init_comment_sync();
97028
97561
  GITHUB_PR_URL_RE = /^https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/\d+/;
97029
97562
  bunGitRunner = {
97030
97563
  run: async (args, cwd2) => {
@@ -97143,21 +97676,26 @@ function readSize2() {
97143
97676
  rows: process.stdout.rows ?? 24
97144
97677
  };
97145
97678
  }
97679
+ function clearScreenAndScrollback2() {
97680
+ if (process.stdout.isTTY)
97681
+ process.stdout.write("\x1B[2J\x1B[3J\x1B[H");
97682
+ }
97146
97683
  function useTerminalSize2() {
97147
- const [size2, setSize] = import_react59.useState(() => ({
97148
- ...readSize2(),
97149
- resizeKey: 0
97150
- }));
97684
+ const initial2 = import_react59.useRef({ ...readSize2(), resizeKey: 0 });
97685
+ const [size2, setSize] = import_react59.useState(initial2.current);
97686
+ const sizeRef = import_react59.useRef(initial2.current);
97151
97687
  import_react59.useEffect(() => {
97152
97688
  if (!process.stdout.isTTY)
97153
97689
  return;
97154
97690
  const onResize = () => {
97155
97691
  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
- });
97692
+ const prev = sizeRef.current;
97693
+ if (prev.columns === columns && prev.rows === rows)
97694
+ return;
97695
+ clearScreenAndScrollback2();
97696
+ const next = { columns, rows, resizeKey: prev.resizeKey + 1 };
97697
+ sizeRef.current = next;
97698
+ setSize(next);
97161
97699
  };
97162
97700
  process.stdout.on("resize", onResize);
97163
97701
  return () => {
@@ -97347,7 +97885,7 @@ var init_SteeringField = __esm(async () => {
97347
97885
  });
97348
97886
 
97349
97887
  // apps/agent/src/components/AgentMode.tsx
97350
- import { join as join22 } from "path";
97888
+ import { join as join23 } from "path";
97351
97889
  async function appendSteeringImpl(changeDir, message) {
97352
97890
  await runWithContext(createDefaultContext(), async () => {
97353
97891
  appendSteeringMessage(changeDir, message);
@@ -97598,19 +98136,13 @@ function AgentMode({
97598
98136
  loadConfig = loadRalphyConfig
97599
98137
  }) {
97600
98138
  const { exit } = use_app_default();
97601
- const { stdout } = use_stdout_default();
97602
98139
  const { isRawModeSupported } = use_stdin_default();
97603
98140
  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
98141
  const [logs, setLogs] = import_react61.useState([]);
97610
98142
  const [, setTick] = import_react61.useState(0);
97611
98143
  const [clock, setClock] = import_react61.useState(0);
97612
98144
  const [focusedIdx, setFocusedIdx] = import_react61.useState(0);
97613
- const [showPendingTasks, setShowPendingTasks] = import_react61.useState(true);
98145
+ const [showPendingTasks, setShowPendingTasks] = import_react61.useState(false);
97614
98146
  const [showAllSubtasks, setShowAllSubtasks] = import_react61.useState(false);
97615
98147
  const coordRef = import_react61.useRef(null);
97616
98148
  const workerMetaRef = import_react61.useRef(new Map);
@@ -97808,7 +98340,7 @@ function AgentMode({
97808
98340
  (async () => {
97809
98341
  for (const [changeName, meta3] of workerMetaRef.current) {
97810
98342
  try {
97811
- const file2 = Bun.file(join22(meta3.statesDir, changeName, ".ralph-state.json"));
98343
+ const file2 = Bun.file(join23(meta3.statesDir, changeName, ".ralph-state.json"));
97812
98344
  if (await file2.exists()) {
97813
98345
  const json2 = await file2.json();
97814
98346
  meta3.iter = json2.iteration ?? meta3.iter;
@@ -97818,9 +98350,9 @@ function AgentMode({
97818
98350
  }
97819
98351
  if (meta3.changeDir) {
97820
98352
  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"));
98353
+ const tasksFile = Bun.file(join23(meta3.changeDir, "tasks.md"));
98354
+ const proposalFile = Bun.file(join23(meta3.changeDir, "proposal.md"));
98355
+ const designFile = Bun.file(join23(meta3.changeDir, "design.md"));
97824
98356
  const [tasksText, proposalText, designText] = await Promise.all([
97825
98357
  tasksFile.exists().then((ok) => ok ? tasksFile.text() : null),
97826
98358
  proposalFile.exists().then((ok) => ok ? proposalFile.text() : null),
@@ -98651,11 +99183,14 @@ function AgentMode({
98651
99183
  },
98652
99184
  onSubmit: async (message) => {
98653
99185
  try {
98654
- await appendSteering(join22(tasksDir, w.changeName), message);
99186
+ await appendSteering(join23(tasksDir, w.changeName), message);
98655
99187
  } catch (err) {
98656
99188
  appendLog(`! steering append failed for ${w.changeName}: ${err.message}`, "red");
98657
99189
  throw err;
98658
99190
  }
99191
+ try {
99192
+ await coordRef.current?.notifySteeringAppended?.(w.changeName, message);
99193
+ } catch {}
98659
99194
  const restarted = await coordRef.current?.restartWorker(w.changeName);
98660
99195
  if (restarted) {
98661
99196
  appendLog(` ${w.changeName}: steering applied, restarting worker`, "cyan");
@@ -98855,7 +99390,7 @@ var exports_list = {};
98855
99390
  __export(exports_list, {
98856
99391
  runList: () => runList
98857
99392
  });
98858
- import { join as join23 } from "path";
99393
+ import { join as join24 } from "path";
98859
99394
  function countTaskItems(content) {
98860
99395
  const checked = (content.match(/^- \[x\]/gm) ?? []).length;
98861
99396
  const unchecked = (content.match(/^- \[ \]/gm) ?? []).length;
@@ -98868,13 +99403,13 @@ function buildLocalRows(statesDir, projectRoot) {
98868
99403
  const sources = [{ dir: statesDir, label: "main" }];
98869
99404
  const worktreesRoot = worktreesDir2(projectRoot);
98870
99405
  for (const wt of storage.list(worktreesRoot)) {
98871
- sources.push({ dir: join23(worktreesRoot, wt, ".ralph", "tasks"), label: `wt:${wt}` });
99406
+ sources.push({ dir: join24(worktreesRoot, wt, ".ralph", "tasks"), label: `wt:${wt}` });
98872
99407
  }
98873
99408
  for (const { dir, label } of sources) {
98874
99409
  for (const entry of storage.list(dir)) {
98875
99410
  if (seen.has(entry))
98876
99411
  continue;
98877
- const raw = storage.read(join23(dir, entry, ".ralph-state.json"));
99412
+ const raw = storage.read(join24(dir, entry, ".ralph-state.json"));
98878
99413
  if (raw === null)
98879
99414
  continue;
98880
99415
  let state;
@@ -98889,7 +99424,7 @@ function buildLocalRows(statesDir, projectRoot) {
98889
99424
  const firstLine = promptRaw.split(`
98890
99425
  `).find((l) => l.trim() !== "") ?? "";
98891
99426
  let progress = "\u2014";
98892
- const tasksContent = storage.read(join23(dir, entry, "tasks.md"));
99427
+ const tasksContent = storage.read(join24(dir, entry, "tasks.md"));
98893
99428
  if (tasksContent !== null) {
98894
99429
  const { checked, unchecked } = countTaskItems(tasksContent);
98895
99430
  const total = checked + unchecked;
@@ -99283,6 +99818,7 @@ var init_list = __esm(() => {
99283
99818
  init_types2();
99284
99819
  init_worktree();
99285
99820
  init_config();
99821
+ init_linear();
99286
99822
  init_list_sort();
99287
99823
  localCmdRunner = {
99288
99824
  run: async (cmd, cwd2) => {
@@ -99305,8 +99841,8 @@ var exports_json_runner = {};
99305
99841
  __export(exports_json_runner, {
99306
99842
  runAgentJson: () => runAgentJson
99307
99843
  });
99308
- import { join as join24 } from "path";
99309
- import { mkdir as mkdir7 } from "fs/promises";
99844
+ import { join as join25 } from "path";
99845
+ import { mkdir as mkdir8 } from "fs/promises";
99310
99846
  import { homedir as homedir5 } from "os";
99311
99847
  function cleanOutputLine2(raw) {
99312
99848
  const clean = raw.replace(ANSI_STRIP_RE2, "").trim();
@@ -99330,7 +99866,7 @@ async function runAgentJson({
99330
99866
  statesDir,
99331
99867
  tasksDir
99332
99868
  }) {
99333
- await mkdir7(join24(homedir5(), ".ralph"), { recursive: true }).catch(() => {
99869
+ await mkdir8(join25(homedir5(), ".ralph"), { recursive: true }).catch(() => {
99334
99870
  return;
99335
99871
  });
99336
99872
  const cfgPath = await ensureRalphyConfig(projectRoot);
@@ -99484,8 +100020,8 @@ var exports_src2 = {};
99484
100020
  __export(exports_src2, {
99485
100021
  main: () => main2
99486
100022
  });
99487
- import { mkdir as mkdir8 } from "fs/promises";
99488
- import { join as join25 } from "path";
100023
+ import { mkdir as mkdir9 } from "fs/promises";
100024
+ import { join as join26 } from "path";
99489
100025
  async function main2(argv) {
99490
100026
  if (argv.includes("--help") || argv.includes("-h")) {
99491
100027
  printHelp2();
@@ -99519,9 +100055,9 @@ async function main2(argv) {
99519
100055
  });
99520
100056
  return typeof process.exitCode === "number" ? process.exitCode : 0;
99521
100057
  }
99522
- await mkdir8(statesDir, { recursive: true });
99523
- await mkdir8(tasksDir, { recursive: true });
99524
- await mkdir8(join25(projectRoot, ".ralph"), { recursive: true });
100058
+ await mkdir9(statesDir, { recursive: true });
100059
+ await mkdir9(tasksDir, { recursive: true });
100060
+ await mkdir9(join26(projectRoot, ".ralph"), { recursive: true });
99525
100061
  if (args.jsonOutput) {
99526
100062
  const { runAgentJson: runAgentJson2 } = await Promise.resolve().then(() => (init_json_runner(), exports_json_runner));
99527
100063
  await runAgentJson2({ args, projectRoot, statesDir, tasksDir });