@seanyao/roll 4.719.3 → 4.721.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.
Files changed (3) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/roll.mjs +199 -54
  3. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## v4.721.1 — 2026-07-21
6
+
7
+ ### 稳定性
8
+ - loop 看门狗的"进展"判定从 stdout 改为 git 状态(新 commit / 工作区文件变动):输出缓冲型 agent 写文件时不再被 15 分钟无进展误杀;新增 `no-state-change` 杀因——默认 25 分钟无 commit 且无文件变动即终止(即使 stdout 仍在输出),原地打转的 cycle 不再烧满 45 分钟墙钟;阈值可用 `ROLL_CYCLE_NO_STATE_CHANGE_SEC` 覆盖(FIX-1477)[loop]
9
+ - attest 闸的"无 AC 块豁免"提前到渲染之前评估:无 AC 块的卡不再因渲染失败或空壳报告被硬拦,直接合法放行(FIX-1476)[loop]
10
+
11
+ ## v4.720.1 — 2026-07-20
12
+
13
+ ### 稳定性
14
+ - `roll worktree cleanup` 现在能认出压缩合并的交付,未交付分支仍会保留(FIX-1471)[loop]
15
+ - 暂停后可用 `roll loop go --cards` 只跑指定卡,其他启动方式继续保持暂停(FIX-1472)[loop]
16
+
5
17
  ## v4.719.3 — 2026-07-19
6
18
 
7
19
  ### 稳定性
package/dist/roll.mjs CHANGED
@@ -14642,15 +14642,20 @@ function timeoutTeardownCommands(ctx) {
14642
14642
  function cycleTimeoutVerdict(input) {
14643
14643
  const wall = input.wallLimitSec ?? CYCLE_WALL_TIMEOUT_SEC;
14644
14644
  const idle = input.noProgressLimitSec ?? CYCLE_NO_PROGRESS_SEC;
14645
+ const stateIdle = input.noStateChangeLimitSec ?? CYCLE_NO_STATE_CHANGE_SEC;
14645
14646
  if (wall > 0 && input.elapsedSec >= wall) {
14646
14647
  return { timedOut: true, reason: "wall", elapsedSec: input.elapsedSec, idleSec: input.idleSec };
14647
14648
  }
14648
14649
  if (idle > 0 && input.idleSec >= idle) {
14649
14650
  return { timedOut: true, reason: "no-progress", elapsedSec: input.elapsedSec, idleSec: input.idleSec };
14650
14651
  }
14652
+ if (input.stateIdleSec !== void 0 && stateIdle > 0 && input.stateIdleSec >= stateIdle) {
14653
+ return { timedOut: true, reason: "no-state-change", elapsedSec: input.elapsedSec, idleSec: input.idleSec };
14654
+ }
14651
14655
  const wallRemain = wall > 0 ? wall - input.elapsedSec : Number.POSITIVE_INFINITY;
14652
14656
  const idleRemain = idle > 0 ? idle - input.idleSec : Number.POSITIVE_INFINITY;
14653
- return { timedOut: false, remainingSec: Math.min(wallRemain, idleRemain) };
14657
+ const stateRemain = input.stateIdleSec !== void 0 && stateIdle > 0 ? stateIdle - input.stateIdleSec : Number.POSITIVE_INFINITY;
14658
+ return { timedOut: false, remainingSec: Math.min(wallRemain, idleRemain, stateRemain) };
14654
14659
  }
14655
14660
  function stallVerdict(input) {
14656
14661
  const threshold = input.stallThresholdSec ?? CYCLE_STALL_THRESHOLD_SEC;
@@ -15141,7 +15146,7 @@ function adversarialDegradedCmd(cycleId, storyId, cause, from = "adversarial") {
15141
15146
  function initialCycleState(ctx) {
15142
15147
  return { phase: "pick", ctx, attempt: 0, done: false };
15143
15148
  }
15144
- var CYCLE_TIMEOUT_SEC, WATCHDOG_KILL_GRACE_SEC, CYCLE_WALL_TIMEOUT_SEC, CYCLE_NO_PROGRESS_SEC, CYCLE_STALL_THRESHOLD_SEC, STALL_STARTUP_GRACE_SEC, MAX_AGENT_ATTEMPTS, RETRY_BASE_BACKOFF_SEC, EVENT_VALID_PHASES;
15149
+ var CYCLE_TIMEOUT_SEC, WATCHDOG_KILL_GRACE_SEC, CYCLE_WALL_TIMEOUT_SEC, CYCLE_NO_PROGRESS_SEC, CYCLE_NO_STATE_CHANGE_SEC, CYCLE_STALL_THRESHOLD_SEC, STALL_STARTUP_GRACE_SEC, MAX_AGENT_ATTEMPTS, RETRY_BASE_BACKOFF_SEC, EVENT_VALID_PHASES;
15145
15150
  var init_orchestrator = __esm({
15146
15151
  "packages/core/dist/loop/orchestrator.js"() {
15147
15152
  "use strict";
@@ -15153,6 +15158,7 @@ var init_orchestrator = __esm({
15153
15158
  WATCHDOG_KILL_GRACE_SEC = 5;
15154
15159
  CYCLE_WALL_TIMEOUT_SEC = 2700;
15155
15160
  CYCLE_NO_PROGRESS_SEC = 900;
15161
+ CYCLE_NO_STATE_CHANGE_SEC = 1500;
15156
15162
  CYCLE_STALL_THRESHOLD_SEC = 600;
15157
15163
  STALL_STARTUP_GRACE_SEC = 120;
15158
15164
  MAX_AGENT_ATTEMPTS = 3;
@@ -247550,6 +247556,7 @@ Loop \u5DF2\u5904\u4E8E\u6682\u505C
247550
247556
  Loop \u5DF2\u6682\u505C \u2014 \u540E\u7EED\u6392\u7A0B\u5468\u671F\u5C06\u8DF3\u8FC7
247551
247557
  `);
247552
247558
  process.stdout.write("mode: guided \u2014 scheduler will not start long-running Story execution until `roll loop resume`\n");
247559
+ process.stdout.write(" supervisor one-shot: `roll loop go --cards <ids> --max-cycles 1` runs exactly those cards while staying paused (no autonomous scheduling).\n");
247553
247560
  return 0;
247554
247561
  }
247555
247562
  async function loopResumeCommand(_args, deps = realDeps()) {
@@ -251253,6 +251260,11 @@ function runAttestGate(worktreeCwd, storyId, cycleId, mode, sinceSec, sinks, sco
251253
251260
  sinks.event({ cycleId, verdict: "skipped", reasons: reasons2 });
251254
251261
  return { verdict: "skipped", mode, reasons: reasons2, blocked: blocked4 };
251255
251262
  }
251263
+ if (storyHasAcBlock(specRepoCwd, storyId) === false) {
251264
+ const reasons2 = ["story has no AC block; acceptance report not required"];
251265
+ sinks.event({ cycleId, verdict: "produced", reasons: reasons2 });
251266
+ return { verdict: "produced", mode, reasons: reasons2, blocked: false };
251267
+ }
251256
251268
  if (renderExitCode !== 0) {
251257
251269
  const reasons2 = [`attest render failed for ${storyId} (exit ${renderExitCode})`];
251258
251270
  const blocked4 = mode === "hard";
@@ -251260,11 +251272,6 @@ function runAttestGate(worktreeCwd, storyId, cycleId, mode, sinceSec, sinks, sco
251260
251272
  sinks.event({ cycleId, verdict: "skipped", reasons: reasons2 });
251261
251273
  return { verdict: "skipped", mode, reasons: reasons2, blocked: blocked4 };
251262
251274
  }
251263
- if (storyHasAcBlock(specRepoCwd, storyId) === false) {
251264
- const reasons2 = ["story has no AC block; acceptance report not required"];
251265
- sinks.event({ cycleId, verdict: "produced", reasons: reasons2 });
251266
- return { verdict: "produced", mode, reasons: reasons2, blocked: false };
251267
- }
251268
251275
  const diagnostics = violatesMustDeclareSurface(specRepoCwd, storyId) ? [MUST_DECLARE_FAIL_REASON] : [];
251269
251276
  const redAcs = redAcFailures(worktreeCwd, storyId, scoreRepoCwd);
251270
251277
  if (redAcs.length > 0) {
@@ -264497,6 +264504,7 @@ import { createInterface as createInterface3 } from "node:readline";
264497
264504
  // packages/cli/dist/lib/goal-progress.js
264498
264505
  init_dist();
264499
264506
  var GOAL_ALLOWED_CARDS_ENV = "ROLL_LOOP_GO_ALLOWED_CARDS";
264507
+ var GOAL_GUIDED_ENV = "ROLL_LOOP_GO_GUIDED";
264500
264508
  var DELIVERY_STATUSES = /* @__PURE__ */ new Set(["built", "published", "done", "merged"]);
264501
264509
  var DELIVERY_OUTCOMES = /* @__PURE__ */ new Set(["published_pending_merge", "delivered"]);
264502
264510
  function parseAllowedCardsEnv(env = process.env) {
@@ -264505,6 +264513,11 @@ function parseAllowedCardsEnv(env = process.env) {
264505
264513
  const raw = env[GOAL_ALLOWED_CARDS_ENV] ?? "";
264506
264514
  return new Set(raw.split(",").map((s) => s.trim()).filter((s) => s !== ""));
264507
264515
  }
264516
+ function isGuidedRunOnce(allowed2, env = process.env) {
264517
+ if ((env[GOAL_GUIDED_ENV] ?? "").trim() !== "1")
264518
+ return false;
264519
+ return allowed2 !== void 0 && allowed2.size > 0;
264520
+ }
264508
264521
  var OUT_OF_SCOPE_STATUS = "\u{1F6AB} Hold (outside goal scope)";
264509
264522
  function scopeBacklogForAllowedCards(items, allowed2) {
264510
264523
  if (allowed2 === void 0)
@@ -265428,6 +265441,7 @@ function parseOptions(args) {
265428
265441
  let noTmux = false;
265429
265442
  let attach = false;
265430
265443
  let scopeSpecified = false;
265444
+ let cardsFlagSpecified = false;
265431
265445
  let reviewMode = "auto";
265432
265446
  let reviewModeSpecified = false;
265433
265447
  for (let i = 0; i < args.length; i += 1) {
@@ -265497,11 +265511,13 @@ function parseOptions(args) {
265497
265511
  }
265498
265512
  if (arg === "--cards") {
265499
265513
  cards.push(...parseCards(args[i + 1]));
265514
+ cardsFlagSpecified = true;
265500
265515
  i += 1;
265501
265516
  continue;
265502
265517
  }
265503
265518
  if (arg.startsWith("--cards=")) {
265504
265519
  cards.push(...parseCards(arg.slice("--cards=".length)));
265520
+ cardsFlagSpecified = true;
265505
265521
  continue;
265506
265522
  }
265507
265523
  if (!arg.startsWith("-"))
@@ -265517,6 +265533,7 @@ function parseOptions(args) {
265517
265533
  attach,
265518
265534
  scope,
265519
265535
  scopeSpecified,
265536
+ cardsFlagSpecified,
265520
265537
  reviewMode,
265521
265538
  reviewModeSpecified,
265522
265539
  ...maxCycles !== void 0 ? { maxCycles } : {},
@@ -265526,6 +265543,9 @@ function parseOptions(args) {
265526
265543
  function hasHelpArg2(args) {
265527
265544
  return args.includes("--help") || args.includes("-h");
265528
265545
  }
265546
+ function isGuidedRun(opts) {
265547
+ return opts.cardsFlagSpecified && opts.scopeSpecified && opts.scope.kind === "cards" && opts.scope.cards.length > 0;
265548
+ }
265529
265549
  function loopGoHelp() {
265530
265550
  return [
265531
265551
  "Usage: roll loop go [--epic <name>|--cards <ids>|--all] [--for <duration>] [--max-cycles <n>] [--review <auto|hetero|self|off>] [--attach] [--no-tmux]",
@@ -265550,6 +265570,12 @@ function loopGoHelp() {
265550
265570
  " The startup banner shows the EFFECTIVE scope; an inherited scope is flagged so a flagless go can never silently narrow. Pass --all to reset to the full backlog.",
265551
265571
  " \u542F\u52A8\u6A2A\u5E45\u663E\u793A\u751F\u6548\u7684 scope\uFF1B\u6CBF\u7528\u7684 scope \u4F1A\u88AB\u6807\u6CE8\uFF0Cflagless go \u7EDD\u4E0D\u9759\u9ED8\u6536\u7A84\u3002\u7528 --all \u91CD\u7F6E\u56DE\u5168\u91CF\u3002",
265552
265572
  "",
265573
+ "Guided one-shot while paused (FIX-1472):",
265574
+ " An explicit `roll loop go --cards X` runs the named card(s) even while `roll loop pause` is in effect \u2014 it is a supervisor action, not autonomous scheduling. It installs no scheduler and picks no other card; bound it with --max-cycles.",
265575
+ " \u663E\u5F0F\u7684 `roll loop go --cards X` \u5373\u4F7F\u5728 `roll loop pause` \u751F\u6548\u65F6\u4E5F\u4F1A\u6267\u884C\u6307\u5B9A\u5361\u2014\u2014\u8FD9\u662F Supervisor \u624B\u52A8\u52A8\u4F5C\uFF0C\u4E0D\u662F\u81EA\u4E3B\u6392\u7A0B\u3002\u5B83\u4E0D\u5B89\u88C5\u6392\u7A0B\u3001\u4E0D\u9009\u522B\u7684\u5361\uFF1B\u7528 --max-cycles \u9650\u5B9A\u6B21\u6570\u3002",
265576
+ " Pause still stops autonomous scheduling; only an explicitly-named --cards scope bypasses it (a flagless/inherited/--all/--epic go keeps honoring the pause).",
265577
+ " pause \u4ECD\u7136\u505C\u6B62\u81EA\u4E3B\u6392\u7A0B\uFF1B\u53EA\u6709\u672C\u6B21\u663E\u5F0F\u6307\u5B9A\u7684 --cards \u8303\u56F4\u624D\u4F1A\u7ED5\u8FC7\uFF08flagless/\u6CBF\u7528/--all/--epic \u7684 go \u4ECD\u9075\u5B88\u6682\u505C\uFF09\u3002",
265578
+ "",
265553
265579
  "Progress guardrails (the loop stops on NO PROGRESS, not on cost):",
265554
265580
  " productivity floor A cycle whose agent EXECUTED but produced 0 commits and no delivery is a `gave_up` terminal \u2014 alerted on the first occurrence (no streak).",
265555
265581
  " dead-loop breaker A card is skipped after consecutive no-progress cycles; the whole goal STOPS after K consecutive no-progress cycles (a loud ALERT) \u2014 an unmergeable card can never spin forever.",
@@ -265673,6 +265699,20 @@ function followGoLiveFeed(projectPath3, rollBin4) {
265673
265699
  watch.on("error", finish);
265674
265700
  });
265675
265701
  }
265702
+ function buildRunOnceChildEnv(input, base = process.env) {
265703
+ return {
265704
+ ...base,
265705
+ ROLL_LOOP_GO_CHILD: "1",
265706
+ // FIX-1022: unattended go-driver child must never trigger the macOS
265707
+ // screencapture TCC prompt (isTTY is unreliable under PTY wrapping).
265708
+ ROLL_NO_SCREENCAP: base["ROLL_NO_SCREENCAP"] ?? "1",
265709
+ ...input.allowedCards !== void 0 ? { [GOAL_ALLOWED_CARDS_ENV]: input.allowedCards.join(",") } : {},
265710
+ // FIX-1472: guided is set on EVERY call (never conditionally spread) so an
265711
+ // inherited ambient "1" cannot survive into an unguided child. Fail closed
265712
+ // to "0" — isGuidedRunOnce treats anything but "1" as not guided.
265713
+ [GOAL_GUIDED_ENV]: input.guided === true ? "1" : "0"
265714
+ };
265715
+ }
265676
265716
  function realRunOnce(input) {
265677
265717
  const bin = rollBin();
265678
265718
  const cmd = bin.endsWith(".js") || bin.endsWith(".mjs") ? process.execPath : bin;
@@ -265681,14 +265721,7 @@ function realRunOnce(input) {
265681
265721
  const child = spawn8(cmd, args, {
265682
265722
  cwd: input.projectPath,
265683
265723
  detached: true,
265684
- env: {
265685
- ...process.env,
265686
- ROLL_LOOP_GO_CHILD: "1",
265687
- // FIX-1022: unattended go-driver child must never trigger the macOS
265688
- // screencapture TCC prompt (isTTY is unreliable under PTY wrapping).
265689
- ROLL_NO_SCREENCAP: process.env["ROLL_NO_SCREENCAP"] ?? "1",
265690
- ...input.allowedCards !== void 0 ? { [GOAL_ALLOWED_CARDS_ENV]: input.allowedCards.join(",") } : {}
265691
- },
265724
+ env: buildRunOnceChildEnv(input),
265692
265725
  stdio: "inherit"
265693
265726
  });
265694
265727
  child.on("exit", (code) => resolve17(code ?? 1));
@@ -266587,11 +266620,12 @@ async function runGoWorker(id, opts, deps) {
266587
266620
  progress = progressFromGoal(goal);
266588
266621
  initialUsage = runSummaryFromGoal(goal);
266589
266622
  const deadlineSec = timeboxDeadlineSec(goal, opts, startedSec);
266623
+ const guided = isGuidedRun(opts);
266590
266624
  bus.appendEvent(evPath, { type: "goal:session_start", sessionId: sid, scope: goal.scope, ts: startedSec });
266591
266625
  while (true) {
266592
266626
  if (stopRequested)
266593
266627
  break;
266594
- if (existsSync64(pauseMarkerPath2(id.path, id.slug))) {
266628
+ if (!guided && existsSync64(pauseMarkerPath2(id.path, id.slug))) {
266595
266629
  stopReason2 = "pause_marker";
266596
266630
  break;
266597
266631
  }
@@ -266647,10 +266681,10 @@ async function runGoWorker(id, opts, deps) {
266647
266681
  }
266648
266682
  if (stopRequested)
266649
266683
  break;
266650
- if (existsSync64(pauseMarkerPath2(id.path, id.slug)))
266684
+ if (!guided && existsSync64(pauseMarkerPath2(id.path, id.slug)))
266651
266685
  continue;
266652
266686
  const before = readRunSnapshot(runsPath2(id.path));
266653
- await deps.runOnce({ projectPath: id.path, allowedCards });
266687
+ await deps.runOnce({ projectPath: id.path, allowedCards, ...guided ? { guided: true } : {} });
266654
266688
  goal = updateUsage(id.path, goal, baseline, initialUsage, deps.nowIso());
266655
266689
  writeGoal(gPath, goal);
266656
266690
  const after = readRunSnapshot(runsPath2(id.path));
@@ -266707,7 +266741,7 @@ async function runGoWorker(id, opts, deps) {
266707
266741
  stopReason2 = "safety_pause";
266708
266742
  break;
266709
266743
  }
266710
- if (existsSync64(pauseMarkerPath2(id.path, id.slug))) {
266744
+ if (!guided && existsSync64(pauseMarkerPath2(id.path, id.slug))) {
266711
266745
  stopReason2 = "pause_marker";
266712
266746
  break;
266713
266747
  }
@@ -284487,7 +284521,8 @@ function readCycleTimeoutThresholds(repoCwd) {
284487
284521
  }
284488
284522
  return {
284489
284523
  wallSec: envNum("ROLL_CYCLE_WALL_TIMEOUT_SEC") ?? wallSec,
284490
- noProgressSec: envNum("ROLL_CYCLE_NO_PROGRESS_SEC") ?? noProgressSec
284524
+ noProgressSec: envNum("ROLL_CYCLE_NO_PROGRESS_SEC") ?? noProgressSec,
284525
+ noStateChangeSec: envNum("ROLL_CYCLE_NO_STATE_CHANGE_SEC") ?? CYCLE_NO_STATE_CHANGE_SEC
284491
284526
  };
284492
284527
  }
284493
284528
  function readStallThreshold(repoCwd) {
@@ -284503,15 +284538,18 @@ function readStallThreshold(repoCwd) {
284503
284538
  }
284504
284539
  function startSpawnTimeoutWatchdog(opts) {
284505
284540
  const { cycleId, thresholds, clock: clock2, commitCount, appendEvent: appendEvent3 } = opts;
284541
+ const stateSignature = opts.stateSignature;
284506
284542
  const kill = opts.kill ?? (() => killLiveAgents("SIGKILL"));
284507
284543
  const pollMs = opts.pollMs ?? (Number((process.env["ROLL_TIMEOUT_POLL_MS"] ?? "").trim()) || TIMEOUT_POLL_MS);
284508
- if (thresholds.wallSec <= 0 && thresholds.noProgressSec <= 0) {
284544
+ if (thresholds.wallSec <= 0 && thresholds.noProgressSec <= 0 && thresholds.noStateChangeSec <= 0) {
284509
284545
  return { markProgress: () => {
284510
284546
  }, stop: () => ({ firedReason: null }) };
284511
284547
  }
284512
284548
  const startSec = clock2();
284513
284549
  let lastProgressSec = startSec;
284550
+ let lastStateSec = startSec;
284514
284551
  let lastCommitCount = -1;
284552
+ let lastSignature;
284515
284553
  let firedReason = null;
284516
284554
  let running = false;
284517
284555
  const markProgress = () => {
@@ -284526,16 +284564,32 @@ function startSpawnTimeoutWatchdog(opts) {
284526
284564
  const n = await commitCount();
284527
284565
  if (n > lastCommitCount) {
284528
284566
  lastCommitCount = n;
284529
- lastProgressSec = clock2();
284567
+ const now3 = clock2();
284568
+ lastProgressSec = now3;
284569
+ lastStateSec = now3;
284530
284570
  }
284531
284571
  } catch {
284532
284572
  }
284573
+ if (stateSignature !== void 0) {
284574
+ try {
284575
+ const sig = await stateSignature();
284576
+ if (lastSignature !== void 0 && sig !== lastSignature) {
284577
+ const now3 = clock2();
284578
+ lastProgressSec = now3;
284579
+ lastStateSec = now3;
284580
+ }
284581
+ lastSignature = sig;
284582
+ } catch {
284583
+ }
284584
+ }
284533
284585
  const now2 = clock2();
284534
284586
  const verdict = cycleTimeoutVerdict({
284535
284587
  elapsedSec: now2 - startSec,
284536
284588
  idleSec: now2 - lastProgressSec,
284589
+ stateIdleSec: now2 - lastStateSec,
284537
284590
  wallLimitSec: thresholds.wallSec,
284538
- noProgressLimitSec: thresholds.noProgressSec
284591
+ noProgressLimitSec: thresholds.noProgressSec,
284592
+ noStateChangeLimitSec: thresholds.noStateChangeSec
284539
284593
  });
284540
284594
  if (verdict.timedOut) {
284541
284595
  firedReason = verdict.reason;
@@ -284565,6 +284619,12 @@ function startSpawnTimeoutWatchdog(opts) {
284565
284619
  lastCommitCount = await commitCount();
284566
284620
  } catch {
284567
284621
  }
284622
+ if (stateSignature !== void 0) {
284623
+ try {
284624
+ lastSignature = await stateSignature();
284625
+ } catch {
284626
+ }
284627
+ }
284568
284628
  })();
284569
284629
  const timer = setInterval(() => void tick(), pollMs);
284570
284630
  timer.unref?.();
@@ -284732,11 +284792,13 @@ async function executeSpawnAgentCommand(cmd, ports, ctx) {
284732
284792
  appendEvent: (ev) => ports.events.appendEvent(ports.paths.eventsPath, ev),
284733
284793
  thresholdSec: readStallThreshold(ports.repoCwd).thresholdSec
284734
284794
  });
284795
+ const statusSignature = ports.git.worktreeStatusSignature;
284735
284796
  const timeoutWatchdog = startSpawnTimeoutWatchdog({
284736
284797
  cycleId: ctx.cycleId ?? "",
284737
284798
  thresholds: readCycleTimeoutThresholds(ports.repoCwd),
284738
284799
  clock: ports.clock,
284739
284800
  commitCount: () => ports.git.commitsAhead(execCwd, observeBase),
284801
+ ...statusSignature !== void 0 ? { stateSignature: () => statusSignature(execCwd) } : {},
284740
284802
  appendEvent: (ev) => ports.events.appendEvent(ports.paths.eventsPath, ev)
284741
284803
  });
284742
284804
  const skillBodyForSpawn = maybeInjectProjectMap(
@@ -287724,6 +287786,13 @@ function nodePorts(opts) {
287724
287786
  const n = Number((r.stdout ?? "0").trim());
287725
287787
  return Number.isFinite(n) ? n : 0;
287726
287788
  },
287789
+ async worktreeStatusSignature(worktreeCwd) {
287790
+ const r = await execFileAsync17("git", ["status", "--porcelain"], {
287791
+ cwd: worktreeCwd,
287792
+ encoding: "utf8"
287793
+ });
287794
+ return r.stdout ?? "";
287795
+ },
287727
287796
  async mainAhead(repoCwd) {
287728
287797
  const r = await execFileAsync17("git", ["rev-list", "--count", "origin/main..main"], {
287729
287798
  cwd: repoCwd,
@@ -288829,7 +288898,7 @@ import { execFileSync as execFileSync32 } from "node:child_process";
288829
288898
  import { homedir as homedir34 } from "node:os";
288830
288899
  import { appendFileSync as appendFileSync20, existsSync as existsSync134, mkdirSync as mkdirSync73, rmSync as rmSync25 } from "node:fs";
288831
288900
  import { dirname as dirname73, join as join157, relative as relative14, resolve as resolve14, sep as sep8 } from "node:path";
288832
- var MERGED_KINDS = /* @__PURE__ */ new Set(["ancestor", "patch_equivalent"]);
288901
+ var MERGED_KINDS = /* @__PURE__ */ new Set(["ancestor", "patch_equivalent", "final_tree"]);
288833
288902
  function isSafelyDisposable(rec) {
288834
288903
  return rec.owner === "loop" && rec.active === false && rec.dirtyTracked === false && rec.disposition === "disposable_candidate" && MERGED_KINDS.has(rec.mergeEvidence.kind) && typeof rec.head === "string" && rec.head.length > 0;
288835
288904
  }
@@ -288906,9 +288975,12 @@ function planWorktreeCleanup(audit, threshold, standaloneMergedBranches = []) {
288906
288975
  preserved
288907
288976
  };
288908
288977
  }
288909
- function defaultRemoveBranch(repositoryRoot, branch) {
288978
+ function defaultRemoveBranch(repositoryRoot, branch, expectedSha) {
288979
+ if (!isFullGitOid(expectedSha)) {
288980
+ return { ok: false, detail: `refused: expected sha ${expectedSha} is not a full git OID` };
288981
+ }
288910
288982
  try {
288911
- execFileSync32("git", ["-C", repositoryRoot, "branch", "-D", branch], {
288983
+ execFileSync32("git", ["-C", repositoryRoot, "update-ref", "-d", `refs/heads/${branch}`, expectedSha], {
288912
288984
  encoding: "utf8",
288913
288985
  stdio: ["ignore", "pipe", "pipe"]
288914
288986
  });
@@ -289111,7 +289183,7 @@ async function applyWorktreeCleanup(plan, options) {
289111
289183
  branchesRemoved.push(removalB);
289112
289184
  continue;
289113
289185
  }
289114
- const r = removeBranchFn(repositoryRoot, bc.branch);
289186
+ const r = removeBranchFn(repositoryRoot, bc.branch, bc.expectedSha);
289115
289187
  if (!r.ok) {
289116
289188
  refuseB(`delete-failed: ${r.detail}`);
289117
289189
  continue;
@@ -289257,7 +289329,7 @@ function renderResultHuman(result2) {
289257
289329
  }
289258
289330
  return lines3.join("\n").trimEnd() + "\n";
289259
289331
  }
289260
- var CLEANUP_USAGE = "Usage: roll worktree cleanup [--dry-run | --apply] [--json] [--repo <path>]\n Safely recover from branch/worktree canary pressure using the worktree\n audit as the SOLE authority. Removes ONLY inactive, merged, clean\n `disposable_candidate` loop worktrees, plus (FIX-1454) standalone ephemeral\n branches that are verifiably merged (ancestor of the integration branch or a\n merged GitHub PR) and attached to no worktree \u2014 never a path/ref that is\n merely old or counted, and never a preserved (unpublished / dirty / active /\n external / current / protected / unmerged) one.\n\n Always dry-run first. Default (no flag) is --dry-run.\n --dry-run print counted refs/dirs, audit dispositions, and the minimal\n candidate set to clear pressure. Never mutates git state.\n --apply re-run the audit before EVERY removal; remove only revalidated\n candidates via git, prune registration, emit events. A changed\n head / new dirt / missing path / concurrent activation fails\n closed (no substitution). Then resume explicitly: roll loop resume\n --json emit the schema-1 plan (dry-run) or result (apply) as JSON\n --repo override the project root (default: current directory)\n --reclaim-orphan <path> (FIX-1460) bounded-rm ONE named orphan loop dir\n (deregistered from git; delivery not auto-provable) after you\n review it. Fails closed unless it is an inactive loop orphan\n inside .roll/loop/worktrees. Auto-reclaim of provably-delivered\n orphans happens under --apply.\n\n \u5B89\u5168\u6E05\u7406:\u4EC5\u79FB\u9664\u5BA1\u8BA1\u5224\u5B9A\u4E3A\u5DF2\u5408\u5E76\u3001\u5E72\u51C0\u3001\u975E\u6D3B\u8DC3\u7684 disposable_candidate;\n \u5148\u8DD1 --dry-run,\u518D --apply,\u6700\u540E\u624B\u52A8 roll loop resume\u3002";
289332
+ var CLEANUP_USAGE = "Usage: roll worktree cleanup [--dry-run | --apply] [--json] [--repo <path>]\n Safely recover from branch/worktree canary pressure using the worktree\n audit as the SOLE authority. Removes ONLY inactive, merged, clean\n `disposable_candidate` loop worktrees, plus (FIX-1454) standalone ephemeral\n branches that are verifiably delivered and attached to no worktree. Delivery\n is PROVEN by one of: every commit is an ancestor of the integration branch;\n `git cherry` shows every commit already has an equivalent patch upstream; or\n (FIX-1471, squash merges) the branch tip tree is byte-identical to the merge\n commit of the branch's OWN merged GitHub PR (matched by exact head ref) AND\n that merge commit is an ancestor of the integration branch. A merged PR alone\n is NEVER sufficient, and no arbitrary same-tree commit on the integration\n branch is ever used. Never a path/ref that is merely old or counted, and never\n a preserved (unpublished / dirty / active / external / current / protected /\n unmerged) one.\n\n Always dry-run first. Default (no flag) is --dry-run.\n --dry-run print counted refs/dirs, audit dispositions, and the minimal\n candidate set to clear pressure. Never mutates git state.\n --apply re-run the audit before EVERY removal; remove only revalidated\n candidates via git, prune registration, emit events. A changed\n head / new dirt / missing path / concurrent activation fails\n closed (no substitution). Then resume explicitly: roll loop resume\n --json emit the schema-1 plan (dry-run) or result (apply) as JSON\n --repo override the project root (default: current directory)\n --reclaim-orphan <path> (FIX-1460) bounded-rm ONE named orphan loop dir\n (deregistered from git; delivery not auto-provable) after you\n review it. Fails closed unless it is an inactive loop orphan\n inside .roll/loop/worktrees. Auto-reclaim of provably-delivered\n orphans happens under --apply.\n\n \u5B89\u5168\u6E05\u7406:\u4EC5\u79FB\u9664\u5BA1\u8BA1\u5224\u5B9A\u4E3A\u5DF2\u5408\u5E76\u3001\u5E72\u51C0\u3001\u975E\u6D3B\u8DC3\u7684 disposable_candidate;\n \u5148\u8DD1 --dry-run,\u518D --apply,\u6700\u540E\u624B\u52A8 roll loop resume\u3002";
289261
289333
  function resolveThreshold() {
289262
289334
  const parsed = parseInt(process.env["ROLL_BRANCH_CANARY_MAX"] ?? "", 10);
289263
289335
  return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_BRANCH_CANARY_MAX;
@@ -289265,20 +289337,41 @@ function resolveThreshold() {
289265
289337
  function gitCap(repoRoot3, args) {
289266
289338
  return execFileSync32("git", ["-C", repoRoot3, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
289267
289339
  }
289268
- function classifyBranchMerge(branch, integrationBranch, git5) {
289340
+ function classifyBranchMerge(branch, integrationBranch, git5, prMergeCommit) {
289269
289341
  if (git5(["merge-base", "--is-ancestor", branch, integrationBranch]).ok)
289270
289342
  return "ancestor";
289271
289343
  const cherry = git5(["cherry", integrationBranch, branch]);
289272
- if (!cherry.ok)
289344
+ if (cherry.ok) {
289345
+ const lines3 = cherry.stdout.split("\n").map((l) => l.trim()).filter((l) => l !== "");
289346
+ if (lines3.length > 0 && lines3.every((l) => l.startsWith("-")))
289347
+ return "patch_equivalent";
289348
+ }
289349
+ return classifyFinalTreeDelivery(branch, integrationBranch, git5, prMergeCommit);
289350
+ }
289351
+ function classifyFinalTreeDelivery(branch, integrationBranch, git5, prMergeCommit) {
289352
+ const rawMerge = prMergeCommit(branch);
289353
+ if (rawMerge === null)
289273
289354
  return null;
289274
- const lines3 = cherry.stdout.split("\n").map((l) => l.trim()).filter((l) => l !== "");
289275
- if (lines3.length === 0)
289355
+ const merge = rawMerge.trim();
289356
+ if (!isFullGitOid(merge))
289276
289357
  return null;
289277
- if (lines3.some((l) => l.startsWith("+")))
289358
+ if (!git5(["merge-base", "--is-ancestor", merge, integrationBranch]).ok)
289278
289359
  return null;
289279
- if (lines3.every((l) => l.startsWith("-")))
289280
- return "patch_equivalent";
289281
- return null;
289360
+ const branchTreeProbe = git5(["rev-parse", `${branch}^{tree}`]);
289361
+ if (!branchTreeProbe.ok)
289362
+ return null;
289363
+ const branchTree = branchTreeProbe.stdout.trim();
289364
+ if (branchTree === "")
289365
+ return null;
289366
+ const mergeTreeProbe = git5(["rev-parse", `${merge}^{tree}`]);
289367
+ if (!mergeTreeProbe.ok)
289368
+ return null;
289369
+ const mergeTree = mergeTreeProbe.stdout.trim();
289370
+ if (mergeTree === "")
289371
+ return null;
289372
+ if (branchTree !== mergeTree)
289373
+ return null;
289374
+ return "final_tree";
289282
289375
  }
289283
289376
  function buildStandaloneBranchDeps(repoRoot3, audit, integrationBranch) {
289284
289377
  const attachedBranches = new Set(audit.records.filter((r) => r.owner === "loop" && typeof r.branch === "string" && r.branch !== "").map((r) => r.branch.replace(/^refs\/heads\//, "")));
@@ -289298,11 +289391,12 @@ function buildStandaloneBranchDeps(repoRoot3, audit, integrationBranch) {
289298
289391
  return null;
289299
289392
  }
289300
289393
  },
289301
- // FIX-1458 (#1465): delivery is proven ONLY by fresh git patch evidence via
289302
- // classifyBranchMerge (ancestor OR `git cherry` patch-equivalence). A merged
289303
- // GitHub PR is deliberately NOT consulted: a squash merge leaves the exact
289304
- // branch tips undelivered, so authorizing deletion on a merged PR alone
289305
- // silently discards unique commits (US-ORG-003/007/004 in the report).
289394
+ // FIX-1458 (#1465), FIX-1471: delivery is proven by fresh git patch evidence
289395
+ // (ancestor OR `git cherry` patch-equivalence) OR, for the squash-merge case,
289396
+ // by the branch tip tree matching the merge commit of the branch's OWN merged
289397
+ // GitHub PR (an ancestor of integration). A merged PR label alone never
289398
+ // authorizes deletion, and no arbitrary same-tree integration commit is ever
289399
+ // consulted — that would silently discard unique commits (US-ORG-003/007/004).
289306
289400
  branchMerge: (branch) => classifyBranchMerge(branch, integrationBranch, (args) => {
289307
289401
  try {
289308
289402
  return { ok: true, stdout: gitCap(repoRoot3, args) };
@@ -289310,9 +289404,39 @@ function buildStandaloneBranchDeps(repoRoot3, audit, integrationBranch) {
289310
289404
  const stdout = typeof err16.stdout === "string" ? err16.stdout : "";
289311
289405
  return { ok: false, stdout };
289312
289406
  }
289313
- })
289407
+ }, (b) => ghMergedPrMergeCommit(repoRoot3, b))
289314
289408
  };
289315
289409
  }
289410
+ function isFullGitOid(value) {
289411
+ return /^[0-9a-f]{40}$/.test(value) || /^[0-9a-f]{64}$/.test(value);
289412
+ }
289413
+ function parseMergedPrMergeCommit(raw, branch) {
289414
+ let j;
289415
+ try {
289416
+ j = JSON.parse(raw);
289417
+ } catch {
289418
+ return null;
289419
+ }
289420
+ if (j.state !== "MERGED")
289421
+ return null;
289422
+ if (typeof j.mergedAt !== "string" || j.mergedAt === "")
289423
+ return null;
289424
+ if (typeof j.headRefName !== "string" || j.headRefName !== branch)
289425
+ return null;
289426
+ const oid = j.mergeCommit?.oid;
289427
+ if (typeof oid !== "string" || !isFullGitOid(oid))
289428
+ return null;
289429
+ return oid;
289430
+ }
289431
+ function ghMergedPrMergeCommit(repoRoot3, branch) {
289432
+ let out3;
289433
+ try {
289434
+ out3 = execFileSync32("gh", ["pr", "view", branch, "--json", "state,mergedAt,mergeCommit,headRefName"], { cwd: repoRoot3, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
289435
+ } catch {
289436
+ return null;
289437
+ }
289438
+ return parseMergedPrMergeCommit(out3, branch);
289439
+ }
289316
289440
  function resolveIntegrationForCleanup(repoRoot3) {
289317
289441
  try {
289318
289442
  const head = gitCap(repoRoot3, ["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"]).trim();
@@ -290111,7 +290235,7 @@ async function loopRunOnceCommand(args) {
290111
290235
  removePendingPublish(rt, storyId);
290112
290236
  }
290113
290237
  }
290114
- if (isLoopPaused(id.path, id.slug)) {
290238
+ if (isLoopPaused(id.path, id.slug) && !isGuidedRunOnce(allowedCards)) {
290115
290239
  const lang10 = resolveLang({
290116
290240
  rollLang: process.env["ROLL_LANG"],
290117
290241
  lcAll: process.env["LC_ALL"],
@@ -292823,28 +292947,49 @@ function assertTestProofFresh(exec) {
292823
292947
  throw new Error("code changed since last test run \u2014 run `roll test` on the current staged tree");
292824
292948
  }
292825
292949
  }
292950
+ function hasOnlyPreparedReleaseChanges(exec) {
292951
+ const status2 = exec("git", ["status", "--porcelain", "--untracked-files=all"]).trim();
292952
+ if (status2 === "")
292953
+ return false;
292954
+ const allowed2 = /* @__PURE__ */ new Set(["package.json", "CHANGELOG.md"]);
292955
+ const unexpected = status2.split("\n").map((line) => line.slice(2).trim()).filter((path) => !allowed2.has(path));
292956
+ if (unexpected.length > 0) {
292957
+ throw new Error(`unexpected worktree changes during release recovery: ${unexpected.join(", ")}`);
292958
+ }
292959
+ return true;
292960
+ }
292826
292961
  function commitPushWithGate(opts) {
292827
292962
  const { branch, message, rollManaged, exec } = opts;
292828
292963
  const original = exec("git", ["rev-parse", "--abbrev-ref", "HEAD"]).trim();
292964
+ const preparedChanges = hasOnlyPreparedReleaseChanges(exec);
292829
292965
  let createdLocal = false;
292966
+ const checkoutExistingBranch = () => {
292967
+ if (preparedChanges) {
292968
+ exec("git", ["checkout", "--merge", branch]);
292969
+ return;
292970
+ }
292971
+ exec("git", ["checkout", branch]);
292972
+ };
292830
292973
  const checkoutBranch = () => {
292974
+ let localSha = "";
292831
292975
  try {
292832
- const localSha = exec("git", ["rev-parse", "--verify", `refs/heads/${branch}`]).trim();
292833
- if (localSha !== "") {
292834
- exec("git", ["checkout", branch]);
292835
- return;
292836
- }
292976
+ localSha = exec("git", ["rev-parse", "--verify", `refs/heads/${branch}`]).trim();
292837
292977
  } catch {
292838
292978
  }
292979
+ if (localSha !== "") {
292980
+ checkoutExistingBranch();
292981
+ return;
292982
+ }
292983
+ let remote = "";
292839
292984
  try {
292840
- const remote = exec("git", ["ls-remote", "--heads", "origin", branch]).trim();
292841
- if (remote !== "") {
292842
- exec("git", ["fetch", "origin", `${branch}:${branch}`]);
292843
- exec("git", ["checkout", branch]);
292844
- return;
292845
- }
292985
+ remote = exec("git", ["ls-remote", "--heads", "origin", branch]).trim();
292846
292986
  } catch {
292847
292987
  }
292988
+ if (remote !== "") {
292989
+ exec("git", ["fetch", "origin", `${branch}:${branch}`]);
292990
+ checkoutExistingBranch();
292991
+ return;
292992
+ }
292848
292993
  createdLocal = true;
292849
292994
  exec("git", ["checkout", "-b", branch]);
292850
292995
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seanyao/roll",
3
- "version": "4.719.3",
3
+ "version": "4.721.1",
4
4
  "description": "Roll — Roll out features with AI agents",
5
5
  "packageManager": "pnpm@11.1.3",
6
6
  "scripts": {