@seanyao/roll 3.614.1 → 3.614.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,24 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## v3.614.3 — 2026-06-14
6
+
7
+ ### 改进
8
+
9
+ - **设计技能更严**:`roll-design` 现在把"详细设计"做成一道硬步骤——拆任务之前,必须先拿出能照着写的方案(数据结构、**至少一个完整样例**、接口、映射规则、边界情况)并经确认。规矩一句话:拿不出一个完整样例,就还没设计完。(US-SKILL-029)
10
+
11
+ ### 修复
12
+
13
+ - 失败/空转的一轮循环现在也记下用了哪个模型:之前那条记录里模型是空的(只补过另一处、这条漏了),导致实时窗和循环账本看不到是哪个 AI 在跑;现在哪个都记上。(FIX-294)
14
+
15
+ ## v3.614.2 — 2026-06-14
16
+
17
+ ### 修复
18
+
19
+ - `roll loop go` 启动时会给清楚的反馈了:告诉你起的是哪个会话、在做哪些卡、第一轮起没起、怎么只读地看——之前只甩一句模糊的 "started" 就退出,你根本不知道开没开。会话复用时也会补上观察窗,attach 进去能直接看到实时流、而不是落在干活的窗口里。还能 `--attach` 直接在前台跟住实时流(此时 Ctrl-C 只停看、不停循环)。(FIX-289)
20
+ - 失败/空转的一轮循环现在也记下用了哪个模型、烧了多少 token、花了多少钱:之前账本那行只剩时间、其余全是 —;读不到用量时如实标"未知",不再假装成 0。而且每一轮结束(不管成没成)都会刷新项目网页,失败的循环不再在循环页里隐身。(FIX-290)
21
+ - 网页截图取证不再因为"本地没装 Chromium"就悄悄降级成纯文本:有图形界面时直接用系统截屏(打开浏览器截真窗口、真像素),没界面才用无头浏览器,都不行才如实记"没截成"——绝不拿文本冒充截图。(FIX-291)
22
+
5
23
  ## v3.614.1 — 2026-06-14
6
24
 
7
25
  ### 新功能
package/dist/roll.mjs CHANGED
@@ -2324,6 +2324,7 @@ function buildTerminalEvent(input) {
2324
2324
  cycleId: input.cycleId,
2325
2325
  storyId: input.storyId,
2326
2326
  agent: input.agent,
2327
+ model: input.model ?? "",
2327
2328
  startedAt: input.startedAt,
2328
2329
  endedAt: input.endedAt,
2329
2330
  outcome: input.outcome,
@@ -2412,6 +2413,10 @@ var init_truth_registry = __esm({
2412
2413
  { field: "cycleId", surface: "event:cycle:terminal", anchor: "cycle_outcome", writer: "buildTerminalEvent", kind: "authoritative" },
2413
2414
  { field: "storyId", surface: "event:cycle:terminal", anchor: "cycle_outcome", writer: "buildTerminalEvent", kind: "authoritative" },
2414
2415
  { field: "agent", surface: "event:cycle:terminal", anchor: "cycle_outcome", writer: "buildTerminalEvent", kind: "authoritative" },
2416
+ // FIX-294: routed model is a dispatch-time fact (like agent) — authoritative,
2417
+ // ALWAYS present even when usage couldn't be parsed; the `usage` fact below
2418
+ // still owns the present-or-reasoned token/cost truth.
2419
+ { field: "model", surface: "event:cycle:terminal", anchor: "cycle_outcome", writer: "buildTerminalEvent", kind: "authoritative" },
2415
2420
  { field: "startedAt", surface: "event:cycle:terminal", anchor: "cycle_outcome", writer: "buildTerminalEvent", kind: "authoritative" },
2416
2421
  { field: "endedAt", surface: "event:cycle:terminal", anchor: "cycle_outcome", writer: "buildTerminalEvent", kind: "authoritative" },
2417
2422
  { field: "outcome", surface: "event:cycle:terminal", anchor: "cycle_outcome", writer: "buildTerminalEvent", kind: "authoritative" },
@@ -9452,6 +9457,10 @@ function terminalBoundsScript(title) {
9452
9457
  "end tell"
9453
9458
  ].join("\n");
9454
9459
  }
9460
+ async function hasGuiSession(run) {
9461
+ const gui = await run("launchctl", ["managername"]);
9462
+ return gui.code === 0 && gui.stdout.includes("Aqua");
9463
+ }
9455
9464
  async function resolveWindowRect(title, run) {
9456
9465
  const r = await run("osascript", ["-e", terminalBoundsScript(title)]);
9457
9466
  if (r.code !== 0)
@@ -9464,6 +9473,89 @@ async function resolveWindowRect(title, run) {
9464
9473
  return null;
9465
9474
  return { x: x1, y: y1, w: x2 - x1, h: y2 - y1 };
9466
9475
  }
9476
+ function browserOpenScript(target, browserApp, r) {
9477
+ const app = appleScriptString(browserApp);
9478
+ const url = appleScriptString(target);
9479
+ return [
9480
+ `tell application "${app}"`,
9481
+ " activate",
9482
+ " make new window",
9483
+ " try",
9484
+ ` set URL of active tab of front window to "${url}"`,
9485
+ " on error",
9486
+ " try",
9487
+ ` set URL of front document to "${url}"`,
9488
+ " end try",
9489
+ " end try",
9490
+ ` delay ${BROWSER_RENDER_DELAY_S}`,
9491
+ " try",
9492
+ ` set bounds of front window to {${r.x}, ${r.y}, ${r.x + r.w}, ${r.y + r.h}}`,
9493
+ " end try",
9494
+ "end tell"
9495
+ ].join("\n");
9496
+ }
9497
+ function browserBoundsScript(browserApp) {
9498
+ const app = appleScriptString(browserApp);
9499
+ return [
9500
+ `tell application "${app}"`,
9501
+ " try",
9502
+ " activate",
9503
+ " set index of front window to 1",
9504
+ " delay 0.3",
9505
+ " return bounds of front window",
9506
+ " on error",
9507
+ ' return ""',
9508
+ " end try",
9509
+ "end tell"
9510
+ ].join("\n");
9511
+ }
9512
+ function browserCloseScript(browserApp) {
9513
+ const app = appleScriptString(browserApp);
9514
+ return [
9515
+ `tell application "${app}"`,
9516
+ " try",
9517
+ " close front window",
9518
+ " end try",
9519
+ "end tell"
9520
+ ].join("\n");
9521
+ }
9522
+ async function resolveBrowserRect(browserApp, run) {
9523
+ const r = await run("osascript", ["-e", browserBoundsScript(browserApp)]);
9524
+ if (r.code !== 0)
9525
+ return null;
9526
+ const parts = r.stdout.trim().split(",").map((part) => Number(part.trim()));
9527
+ if (parts.length !== 4 || parts.some((n) => !Number.isFinite(n)))
9528
+ return null;
9529
+ const [x1, y1, x2, y2] = parts;
9530
+ if (x2 <= x1 || y2 <= y1)
9531
+ return null;
9532
+ return { x: x1, y: y1, w: x2 - x1, h: y2 - y1 };
9533
+ }
9534
+ async function captureWebViaBrowser(req, run) {
9535
+ const browserApp = req.browser ?? DEFAULT_BROWSER;
9536
+ const rect = parseRegion(req.region ?? DEFAULT_REGION);
9537
+ if (rect === null)
9538
+ return "bad region";
9539
+ const target = req.url;
9540
+ const opened = await run("osascript", ["-e", browserOpenScript(target, browserApp, rect)]);
9541
+ if (opened.code !== 0)
9542
+ return `osascript ${browserApp} open failed`;
9543
+ const liveRect = await resolveBrowserRect(browserApp, run);
9544
+ if (liveRect === null) {
9545
+ await run("osascript", ["-e", browserCloseScript(browserApp)]);
9546
+ return "browser window not found \u2014 refusing a blind-region shot";
9547
+ }
9548
+ const front = await run("sh", ["-c", "lsappinfo info -only name $(lsappinfo front)"]);
9549
+ if (front.code !== 0 || !front.stdout.includes(browserApp)) {
9550
+ await run("osascript", ["-e", browserCloseScript(browserApp)]);
9551
+ return `${browserApp} not frontmost \u2014 refusing to shoot another app's pixels`;
9552
+ }
9553
+ const shot = await run("screencapture", ["-x", "-R", `${liveRect.x},${liveRect.y},${liveRect.w},${liveRect.h}`, req.out]);
9554
+ await run("osascript", ["-e", browserCloseScript(browserApp)]);
9555
+ if (shot.code !== 0)
9556
+ return "screencapture failed (screen-recording permission?)";
9557
+ return null;
9558
+ }
9467
9559
  function terminalExitTabScript(title) {
9468
9560
  const esc8 = appleScriptString(title);
9469
9561
  return [
@@ -9550,9 +9642,17 @@ async function captureScreenshot(req, deps = {}) {
9550
9642
  return skip("ROLL_ATTEST_NO_BROWSER=1");
9551
9643
  if (req.url === void 0 || req.url === "")
9552
9644
  return skip("no url");
9553
- const r = await run("npx", ["-y", "playwright@latest", "screenshot", req.url, req.out]);
9554
- if (r.code !== 0)
9555
- return skip("playwright unavailable or capture failed");
9645
+ if (platform3 === "darwin" && await hasGuiSession(run)) {
9646
+ const reason = await captureWebViaBrowser(req, run);
9647
+ if (reason !== null)
9648
+ return skip(`GUI browser capture: ${reason}`);
9649
+ } else {
9650
+ const r = await run("npx", ["-y", "playwright@latest", "screenshot", req.url, req.out]);
9651
+ if (r.code !== 0) {
9652
+ const why = platform3 === "darwin" ? "no GUI session" : "non-macOS host";
9653
+ return skip(`headless Chromium unavailable or capture failed (${why}; screencapture fallback not applicable)`);
9654
+ }
9655
+ }
9556
9656
  } else if (req.kind === "mobile-ios") {
9557
9657
  if (platform3 !== "darwin")
9558
9658
  return skip("not macOS");
@@ -9578,8 +9678,7 @@ async function captureScreenshot(req, deps = {}) {
9578
9678
  const rawLine = req.tmux !== void 0 && req.tmux !== "" ? `tmux attach -t ${req.tmux}` : req.command ?? "";
9579
9679
  if (containsSecret(rawLine))
9580
9680
  return skip("secret in capture command \u2014 redact & reshoot");
9581
- const gui = await run("launchctl", ["managername"]);
9582
- if (gui.code !== 0 || !gui.stdout.includes("Aqua"))
9681
+ if (!await hasGuiSession(run))
9583
9682
  return skip("no GUI session");
9584
9683
  const rect = parseRegion(req.region ?? DEFAULT_REGION);
9585
9684
  if (rect === null)
@@ -9638,7 +9737,7 @@ async function captureAll(reqs, deps = {}) {
9638
9737
  out3.push(await captureScreenshot(r, deps));
9639
9738
  return out3;
9640
9739
  }
9641
- var execFileAsync7, defaultRun2, SAFE_STEM, DEFAULT_REGION, TERMINAL_DONE_TIMEOUT_MS, TERMINAL_DONE_POLL_MS;
9740
+ var execFileAsync7, defaultRun2, SAFE_STEM, DEFAULT_REGION, TERMINAL_DONE_TIMEOUT_MS, TERMINAL_DONE_POLL_MS, DEFAULT_BROWSER, BROWSER_RENDER_DELAY_S;
9642
9741
  var init_screenshot = __esm({
9643
9742
  "packages/infra/dist/screenshot.js"() {
9644
9743
  "use strict";
@@ -9661,6 +9760,8 @@ var init_screenshot = __esm({
9661
9760
  DEFAULT_REGION = "0,0,1280,800";
9662
9761
  TERMINAL_DONE_TIMEOUT_MS = 12e4;
9663
9762
  TERMINAL_DONE_POLL_MS = 100;
9763
+ DEFAULT_BROWSER = "Google Chrome";
9764
+ BROWSER_RENDER_DELAY_S = 2;
9664
9765
  }
9665
9766
  });
9666
9767
 
@@ -9684,6 +9785,9 @@ __export(dist_exports, {
9684
9785
  attachArgv: () => attachArgv,
9685
9786
  branchCreate: () => branchCreate,
9686
9787
  branchDelete: () => branchDelete,
9788
+ browserBoundsScript: () => browserBoundsScript,
9789
+ browserCloseScript: () => browserCloseScript,
9790
+ browserOpenScript: () => browserOpenScript,
9687
9791
  canonicalProjectPath: () => canonicalProjectPath,
9688
9792
  captureAll: () => captureAll,
9689
9793
  captureFromMarker: () => captureFromMarker,
@@ -17137,7 +17241,9 @@ function ledgerVerdict(status2, outcome) {
17137
17241
  function ledgerFailedCount(rows) {
17138
17242
  return rows.filter((r) => r.verdict === "failed" || r.verdict === "reverted" || r.verdict === "blocked").length;
17139
17243
  }
17140
- function fmtTokens2(tin, tout) {
17244
+ function fmtTokens2(tin, tout, usageUnknown) {
17245
+ if (usageUnknown)
17246
+ return "?";
17141
17247
  const a = typeof tin === "number" ? tin : 0;
17142
17248
  const b = typeof tout === "number" ? tout : 0;
17143
17249
  if (a + b === 0)
@@ -17241,6 +17347,7 @@ function collectCycleLedger(projectPath3) {
17241
17347
  const rawTs = row2["ts"];
17242
17348
  const ts = typeof rawTs === "string" ? Date.parse(rawTs) : typeof rawTs === "number" ? rawTs > 1e10 ? rawTs : rawTs * 1e3 : Number.NaN;
17243
17349
  const cost = typeof row2["cost_effective_usd"] === "number" ? row2["cost_effective_usd"] : typeof row2["cost_usd"] === "number" ? row2["cost_usd"] : void 0;
17350
+ const usageUnknown = row2["usage_unknown"] === true;
17244
17351
  const ev = byCycle.get(cycleId);
17245
17352
  const prNumber = storyId !== "" ? prMergedBy.get(storyId) : void 0;
17246
17353
  const prOpen = storyId !== "" ? prOpenBy.get(storyId) : void 0;
@@ -17254,8 +17361,8 @@ function collectCycleLedger(projectPath3) {
17254
17361
  storyId,
17255
17362
  agent: String(row2["agent"] ?? ""),
17256
17363
  model: typeof row2["model"] === "string" && row2["model"] !== "" ? row2["model"] : String(row2["agent"] ?? "\u2014") || "\u2014",
17257
- tokens: fmtTokens2(row2["tokens_in"], row2["tokens_out"]),
17258
- cost: cost !== void 0 ? `$${cost.toFixed(2)}` : "\u2014",
17364
+ tokens: fmtTokens2(row2["tokens_in"], row2["tokens_out"], usageUnknown),
17365
+ cost: cost !== void 0 ? `$${cost.toFixed(2)}` : usageUnknown ? "?" : "\u2014",
17259
17366
  duration: fmtDuration(row2["duration_sec"]),
17260
17367
  tape: rowTape(row2, verdict, ev, prNumber, prOpen),
17261
17368
  evidence
@@ -23605,6 +23712,8 @@ function pad3(s, w) {
23605
23712
  function tokensTotal(tokens) {
23606
23713
  if (tokens === "\u2014")
23607
23714
  return "\u2014";
23715
+ if (tokens === "?")
23716
+ return "?";
23608
23717
  const parts = tokens.split("/");
23609
23718
  const num4 = (p) => p.endsWith("k") ? Number(p.slice(0, -1)) * 1e3 : Number(p);
23610
23719
  const total = parts.reduce((a, p) => a + (Number.isFinite(num4(p)) ? num4(p) : 0), 0);
@@ -24728,6 +24837,7 @@ function realDeps4() {
24728
24837
  }
24729
24838
  },
24730
24839
  startTmux: startGoTmux,
24840
+ followFeed: followGoLiveFeed,
24731
24841
  runOnce: realRunOnce,
24732
24842
  readUsageLimits: readAnthropicUsageLimits,
24733
24843
  sleep: (ms) => new Promise((resolve5) => setTimeout(resolve5, ms)),
@@ -24781,6 +24891,7 @@ function parseOptions2(args) {
24781
24891
  let worker = false;
24782
24892
  let noTmux = false;
24783
24893
  let noWait = false;
24894
+ let attach = false;
24784
24895
  let scopeSpecified = false;
24785
24896
  let reviewMode = "auto";
24786
24897
  let reviewModeSpecified = false;
@@ -24798,6 +24909,10 @@ function parseOptions2(args) {
24798
24909
  noWait = true;
24799
24910
  continue;
24800
24911
  }
24912
+ if (arg === "--attach" || arg === "--follow") {
24913
+ attach = true;
24914
+ continue;
24915
+ }
24801
24916
  if (arg === "--epic") {
24802
24917
  const epic = args[i + 1]?.trim() ?? "";
24803
24918
  if (epic !== "") {
@@ -24882,6 +24997,7 @@ function parseOptions2(args) {
24882
24997
  worker,
24883
24998
  noTmux,
24884
24999
  noWait,
25000
+ attach,
24885
25001
  scope,
24886
25002
  scopeSpecified,
24887
25003
  reviewMode,
@@ -24897,7 +25013,7 @@ function hasHelpArg2(args) {
24897
25013
  }
24898
25014
  function loopGoHelp() {
24899
25015
  return [
24900
- "Usage: roll loop go [--epic <name>|--cards <ids>] [--budget <usd>] [--for <duration>] [--max-cycles <n>] [--review <auto|hetero|self|off>] [--no-wait] [--no-tmux]",
25016
+ "Usage: roll loop go [--epic <name>|--cards <ids>] [--budget <usd>] [--for <duration>] [--max-cycles <n>] [--review <auto|hetero|self|off>] [--no-wait] [--attach] [--no-tmux]",
24901
25017
  " Chain goal-mode cycles until the scoped backlog is complete, paused, budget-limited, or capped.",
24902
25018
  " \u6309 goal \u8303\u56F4\u8FDE\u7EED\u6267\u884C cycle\uFF0C\u76F4\u5230\u5B8C\u6210\u3001\u6682\u505C\u3001\u9884\u7B97\u53D7\u9650\u6216\u8FBE\u5230\u4E0A\u9650\u3002",
24903
25019
  "",
@@ -24910,6 +25026,7 @@ function loopGoHelp() {
24910
25026
  " --usage-threshold <ratio> Pause when account usage reaches this ratio; default 0.85.",
24911
25027
  " --no-wait Do not wait for usage windows to recover after a usage-limit pause.",
24912
25028
  " --review <mode> Final review policy before completion: auto, hetero, self, or off.",
25029
+ " --attach, --follow Start the session, then follow the read-only live feed in the foreground (Ctrl-C stops the view, not the loop).",
24913
25030
  " --no-tmux Run in the current process instead of starting a tmux session.",
24914
25031
  "",
24915
25032
  "Limits are explicit per run (FIX-279):",
@@ -25000,21 +25117,76 @@ function shellQuote3(value) {
25000
25117
  function rollBin() {
25001
25118
  return (process.env["ROLL_BIN"] ?? "").trim() || process.argv[1] || "roll";
25002
25119
  }
25120
+ function goSessionName(slug) {
25121
+ return `roll-loop-${slug}`;
25122
+ }
25123
+ function watchCommand(projectPath3, slug, rollBin3) {
25124
+ const live = shellQuote3(join44(runtimeDir2(projectPath3), "live.log"));
25125
+ return `printf 'roll goal \xB7 ${slug}\\n'; tail -n +1 -F ${live} | ${shellQuote3(rollBin3)} loop fmt`;
25126
+ }
25127
+ function planGoTmuxCommands(input, state) {
25128
+ const session = goSessionName(input.slug);
25129
+ const watch = watchCommand(input.projectPath, input.slug, input.rollBin);
25130
+ const workerArgs = input.args.filter((arg) => arg !== "--worker" && arg !== "--no-tmux" && arg !== "--attach" && arg !== "--follow").concat("--worker").map(shellQuote3).join(" ");
25131
+ const command = `cd ${shellQuote3(input.projectPath)} && ROLL_LOOP_GO_WORKER=1 ROLL_LOOP_NO_TMUX=1 ROLL_BIN=${shellQuote3(input.rollBin)} ${shellQuote3(input.rollBin)} loop go ${workerArgs}`;
25132
+ const plan = [];
25133
+ if (!state.sessionExists) {
25134
+ plan.push(["new-session", "-d", "-s", session, "-x", "200", "-y", "50", "-n", "watch", watch]);
25135
+ } else if (!state.watchWindowExists) {
25136
+ plan.push(["new-window", "-d", "-t", session, "-n", "watch", watch]);
25137
+ }
25138
+ plan.push(["new-window", "-d", "-t", session, "-n", "go", command]);
25139
+ return plan;
25140
+ }
25141
+ function probeGoTmuxState(slug) {
25142
+ const session = goSessionName(slug);
25143
+ const sessionExists = spawnSync4("tmux", ["has-session", "-t", session], { stdio: "ignore" }).status === 0;
25144
+ if (!sessionExists)
25145
+ return { sessionExists: false, watchWindowExists: false };
25146
+ const listed = spawnSync4("tmux", ["list-windows", "-t", session, "-F", "#{window_name}"], { encoding: "utf8" });
25147
+ const windows = listed.status === 0 ? String(listed.stdout ?? "").split("\n").map((w) => w.trim()) : [];
25148
+ return { sessionExists: true, watchWindowExists: windows.includes("watch") };
25149
+ }
25003
25150
  function startGoTmux(input) {
25004
- const session = `roll-loop-${input.slug}`;
25005
- const rt = runtimeDir2(input.projectPath);
25006
- const workerArgs = input.args.filter((arg) => arg !== "--worker" && arg !== "--no-tmux").concat("--worker").map(shellQuote3).join(" ");
25007
- const watch = `printf 'roll goal \xB7 ${input.slug}\\n'; tail -n +1 -F ${shellQuote3(join44(rt, "live.log"))} | ${shellQuote3(input.rollBin)} loop fmt`;
25151
+ const session = goSessionName(input.slug);
25008
25152
  try {
25009
- if (spawnSync4("tmux", ["has-session", "-t", session], { stdio: "ignore" }).status !== 0) {
25010
- spawnSync4("tmux", ["new-session", "-d", "-s", session, "-x", "200", "-y", "50", "-n", "watch", watch], { stdio: "ignore" });
25153
+ const plan = planGoTmuxCommands(input, probeGoTmuxState(input.slug));
25154
+ let goStarted = false;
25155
+ for (const argv of plan) {
25156
+ const ok8 = spawnSync4("tmux", argv, { stdio: "ignore" }).status === 0;
25157
+ if (argv[0] === "new-window" && argv.includes("go") && argv[argv.length - 1]?.startsWith("cd "))
25158
+ goStarted = ok8;
25011
25159
  }
25012
- const command = `cd ${shellQuote3(input.projectPath)} && ROLL_LOOP_GO_WORKER=1 ROLL_LOOP_NO_TMUX=1 ROLL_BIN=${shellQuote3(input.rollBin)} ${shellQuote3(input.rollBin)} loop go ${workerArgs}`;
25013
- return spawnSync4("tmux", ["new-window", "-d", "-t", session, "-n", "go", command], { stdio: "ignore" }).status === 0;
25160
+ return goStarted;
25014
25161
  } catch {
25015
25162
  return false;
25016
25163
  }
25017
25164
  }
25165
+ function followGoLiveFeed(projectPath3, rollBin3) {
25166
+ return new Promise((resolve5) => {
25167
+ const live = join44(runtimeDir2(projectPath3), "live.log");
25168
+ const tail3 = spawn3("tail", ["-n", "+1", "-F", live], { stdio: ["ignore", "pipe", "inherit"] });
25169
+ const fmt = spawn3(rollBin3.endsWith(".js") || rollBin3.endsWith(".mjs") ? process.execPath : rollBin3, rollBin3.endsWith(".js") || rollBin3.endsWith(".mjs") ? [rollBin3, "loop", "fmt"] : ["loop", "fmt"], { stdio: ["pipe", "inherit", "inherit"] });
25170
+ if (tail3.stdout !== null && fmt.stdin !== null)
25171
+ tail3.stdout.pipe(fmt.stdin);
25172
+ const finish = () => {
25173
+ try {
25174
+ tail3.kill("SIGTERM");
25175
+ } catch {
25176
+ }
25177
+ try {
25178
+ fmt.kill("SIGTERM");
25179
+ } catch {
25180
+ }
25181
+ process.removeListener("SIGINT", finish);
25182
+ resolve5();
25183
+ };
25184
+ process.on("SIGINT", finish);
25185
+ fmt.on("exit", finish);
25186
+ tail3.on("exit", finish);
25187
+ tail3.on("error", finish);
25188
+ });
25189
+ }
25018
25190
  function realRunOnce(input) {
25019
25191
  const bin = rollBin();
25020
25192
  const cmd = bin.endsWith(".js") || bin.endsWith(".mjs") ? process.execPath : bin;
@@ -25756,6 +25928,28 @@ function installStopHandlers(onStop) {
25756
25928
  process.off(signal2, handler);
25757
25929
  };
25758
25930
  }
25931
+ function describeScope(scope) {
25932
+ if (scope.kind === "cards")
25933
+ return `cards ${scope.cards.join(", ")}`;
25934
+ if (scope.kind === "epic")
25935
+ return `epic ${scope.epic}`;
25936
+ return "all Todo backlog cards";
25937
+ }
25938
+ function goStartupFeedback(slug, scope) {
25939
+ const session = goSessionName(slug);
25940
+ return [
25941
+ `Goal go session started: ${session}`,
25942
+ ` scope: ${describeScope(scope)}`,
25943
+ " cycles: first cycle is running now; cycles chain until the scope is complete, paused, or capped.",
25944
+ ` observe: tmux attach -t ${session} (read-only watch window; the 'go' window is the worker \u2014 do not Ctrl-C it)`,
25945
+ ` or follow inline: roll loop go --attach`,
25946
+ "",
25947
+ `goal \u8FDE\u8DD1\u4F1A\u8BDD\u5DF2\u542F\u52A8: ${session}`,
25948
+ " \u7B2C\u4E00\u4E2A cycle \u6B63\u5728\u8FD0\u884C\uFF1B\u4F1A\u6301\u7EED\u8FDE\u8DD1\u76F4\u5230\u8303\u56F4\u5B8C\u6210\u3001\u6682\u505C\u6216\u8FBE\u4E0A\u9650\u3002",
25949
+ ` \u89C2\u5BDF (\u53EA\u8BFB): tmux attach -t ${session} \u6216 roll loop go --attach`,
25950
+ ""
25951
+ ].join("\n");
25952
+ }
25759
25953
  async function loopGoCommand(args, deps = realDeps4()) {
25760
25954
  if (hasHelpArg2(args)) {
25761
25955
  process.stdout.write(loopGoHelp());
@@ -25766,9 +25960,11 @@ async function loopGoCommand(args, deps = realDeps4()) {
25766
25960
  if (!opts.worker && !opts.noTmux && deps.hasTmux()) {
25767
25961
  const started = deps.startTmux({ projectPath: id.path, slug: id.slug, args, rollBin: rollBin() });
25768
25962
  if (started) {
25769
- process.stdout.write(`Goal go session started in tmux \u2014 attach anytime: tmux attach -t roll-loop-${id.slug}
25770
- goal \u8FDE\u8DD1\u4F1A\u8BDD\u5DF2\u5728 tmux \u542F\u52A8 \u2014 \u53EF\u968F\u65F6\u89C2\u5BDF: tmux attach -t roll-loop-${id.slug}
25771
- `);
25963
+ process.stdout.write(goStartupFeedback(id.slug, opts.scope));
25964
+ if (opts.attach && deps.followFeed !== void 0) {
25965
+ process.stdout.write("Following live feed (Ctrl-C stops the view, not the loop) \u2026\n\u6B63\u5728\u8DDF\u968F\u5B9E\u65F6\u8F93\u51FA (Ctrl-C \u53EA\u505C\u6B62\u67E5\u770B\uFF0C\u4E0D\u4F1A\u505C\u6B62 loop) \u2026\n");
25966
+ await deps.followFeed(id.path, rollBin());
25967
+ }
25772
25968
  return 0;
25773
25969
  }
25774
25970
  }
@@ -30414,6 +30610,7 @@ ${diffStat}`;
30414
30610
  } else if (cmd.status === "idle" && (ctx.storyId ?? "") !== "") {
30415
30611
  ports.backlog.markStatus?.(ports.repoCwd, ctx.storyId ?? "", STATUS_MARKER.todo);
30416
30612
  }
30613
+ refreshAggregates(ports.repoCwd);
30417
30614
  return {};
30418
30615
  }
30419
30616
  // _worktree_alert.
@@ -30456,16 +30653,22 @@ function buildRunRow(cmd, ctx, nowSec2) {
30456
30653
  row2["duration_sec"] = dur;
30457
30654
  }
30458
30655
  }
30656
+ const routedModel = (ctx.model ?? "").trim() !== "" ? ctx.model : ctx.agent ?? "";
30657
+ if (routedModel !== "")
30658
+ row2["model"] = routedModel;
30459
30659
  if (ctx.cost !== void 0) {
30460
30660
  row2["cost_usd"] = ctx.cost.estimatedCost;
30461
30661
  row2["cost_effective_usd"] = ctx.cost.effectiveCost;
30462
- row2["model"] = ctx.cost.model;
30662
+ if (ctx.cost.model !== "")
30663
+ row2["model"] = ctx.cost.model;
30463
30664
  row2["tokens_in"] = ctx.cost.tokensIn;
30464
30665
  row2["tokens_out"] = ctx.cost.tokensOut;
30465
30666
  if (ctx.cost.cacheRead !== void 0)
30466
30667
  row2["tokens_cache_read"] = ctx.cost.cacheRead;
30467
30668
  if (ctx.cost.cacheWrite !== void 0)
30468
30669
  row2["tokens_cache_write"] = ctx.cost.cacheWrite;
30670
+ } else {
30671
+ row2["usage_unknown"] = true;
30469
30672
  }
30470
30673
  return row2;
30471
30674
  }
@@ -30505,10 +30708,13 @@ function buildTerminalRecord(cmd, ctx, worktreePath, nowSec2) {
30505
30708
  } else {
30506
30709
  usage2 = absent("no_parseable_usage");
30507
30710
  }
30711
+ const routedModel = (ctx.model ?? "").trim() !== "" ? ctx.model : ctx.agent ?? "";
30712
+ const model = ctx.cost !== void 0 && ctx.cost.model !== "" ? ctx.cost.model : routedModel;
30508
30713
  return buildTerminalEvent({
30509
30714
  cycleId: cmd.cycleId,
30510
30715
  storyId,
30511
30716
  agent: ctx.agent ?? "",
30717
+ model,
30512
30718
  startedAt: ctx.startSec ?? nowSec2,
30513
30719
  endedAt: nowSec2,
30514
30720
  outcome: OUTCOME[cmd.status] ?? "unknown",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seanyao/roll",
3
- "version": "3.614.1",
3
+ "version": "3.614.3",
4
4
  "description": "Roll — Roll out features with AI agents",
5
5
  "packageManager": "pnpm@11.1.3",
6
6
  "scripts": {
@@ -27,20 +27,23 @@ Load when the user wants to discuss approaches, design a solution, model domains
27
27
 
28
28
  1. Clarify only when intent or boundaries are unclear.
29
29
  2. Model domain depth proportional to risk and novelty.
30
- 3. Split into INVEST stories with acceptance criteria and dependencies.
31
- 4. Write specs through roll story new and update backlog.
32
- 5. Self-score the design quality.
30
+ 3. Detailed design before decomposition for any non-trivial work, produce a concrete, implementable design artifact and get owner sign-off: (a) data/contract schema, (b) AT LEAST ONE complete worked sample of the intended output/behavior, (c) key interface signatures, (d) mapping/normalization rules, (e) edge cases & failure modes. Depth scales with risk/novelty; trivial work may be light.
31
+ 4. Split into INVEST stories each a slice of the agreed detailed design.
32
+ 5. Write specs through roll story new and update backlog.
33
+ 6. Self-score the design quality.
33
34
 
34
35
  ## Hard Gates
35
36
 
36
37
  - Do not start implementation from this skill.
37
38
  - Backlog rows and spec files must stay consistent.
38
39
  - Peer review gates apply only when explicitly available/requested.
40
+ - No story decomposition until a detailed design exists and the owner has signed off (proportional to risk). Decomposition slices an agreed design — it is NOT a substitute for designing. If you cannot show at least one complete worked sample of the intended output/behavior, the design is NOT done.
39
41
 
40
42
  ## Gotchas
41
43
 
42
44
  - Design writes backlog/spec artifacts; it must not quietly start code implementation.
43
45
  - Use roll story new for story directories; do not hand-create backlog rows without matching dossier structure.
46
+ - Jumping from idea straight to INVEST stories (skipping detailed design) produces shallow specs and improvised, inconsistent implementation. Decomposition must slice a concrete, owner-agreed detailed design — schema + at least one complete worked sample + interfaces + mapping rules + edge cases.
44
47
 
45
48
  ## Maintenance
46
49
 
@@ -200,12 +200,28 @@ it('should handle concurrent writes', async () => {
200
200
 
201
201
  ---
202
202
 
203
+ ## 9. Detailed Design Completeness 📐
204
+
205
+ **Definition:** Before decomposing work into stories, the detailed design must be concrete and implementable — not a sketch.
206
+
207
+ **Must include (proportional to risk/novelty):**
208
+ - [ ] Data/contract **schema**
209
+ - [ ] **At least one complete worked sample** of the intended output/behavior
210
+ - [ ] Key **interface signatures**
211
+ - [ ] **Mapping/normalization rules**
212
+ - [ ] **Edge cases & failure modes**
213
+
214
+ **Rule:** If you cannot show at least one complete worked sample, the design is NOT done. Decomposition slices an agreed design — it is not a substitute for designing.
215
+
216
+ ---
217
+
203
218
  ## Mandatory Check Process
204
219
 
205
220
  During the **Test Design Review** phase of each Story, the following must be answered:
206
221
 
207
222
  ```markdown
208
223
  ### Engineering Common Sense Checklist
224
+ - [ ] **Detailed design completeness**: Does the detailed design include at least one complete worked sample plus schema, interface signatures, mapping rules, and edge cases?
209
225
  - [ ] **Idempotency**: Can it be run repeatedly? Are there tests?
210
226
  - [ ] **Cross-module contract**: Are IDs/formats/algorithms consistent?
211
227
  - [ ] **Data flow**: Is the producer → consumer pipeline complete?
@@ -176,7 +176,7 @@ Expected AC:
176
176
  Input: IDEA-NNN identifier from .roll/backlog.md
177
177
 
178
178
  Execution path:
179
- [Read .roll/backlog.md IDEA-NNN row] → [Analyze] → [DDD Slice] → [Split Stories]
179
+ [Read .roll/backlog.md IDEA-NNN row] → [Analyze] → [DDD Slice] → [Solution Design] → [Split Stories]
180
180
  → [Write BACKLOG 📋 Todo] → [Annotate IDEA row: → US-XXX] → Done
181
181
 
182
182
  IDEA annotation: append `→ US-XXX` to the IDEA row's Description column.
@@ -289,10 +289,22 @@ User Input
289
289
 
290
290
 
291
291
  ┌─────────────────────────────────────────┐
292
- │ 3. Solution Design
293
- - Architecture design
294
- │ - Module decomposition
292
+ │ 3. Detailed (Solution) Design ★MANDATORY
293
+ Before ANY decomposition, for
294
+ non-trivial work produce a concrete,
295
+ │ implementable artifact (depth scales │
296
+ │ with risk/novelty): │
297
+ │ - Architecture / module decomposition │
295
298
  │ - Dependency analysis │
299
+ │ Required artifacts (all of): │
300
+ │ (a) data/contract schema │
301
+ │ (b) ≥1 COMPLETE worked sample of │
302
+ │ the intended output/behavior │
303
+ │ (c) key interface signatures │
304
+ │ (d) mapping/normalization rules │
305
+ │ (e) edge cases & failure modes │
306
+ │ Rule: if you cannot show a complete │
307
+ │ worked sample, the design is NOT done.│
296
308
  │ [Greenfield] Tactical Model per Context:
297
309
  │ - Aggregate Root + Entities + VOs │
298
310
  │ - Invariants (业务不变式) │
@@ -312,6 +324,15 @@ User Input
312
324
  │ AGREE / skipped
313
325
 
314
326
  ┌─────────────────────────────────────────┐
327
+ │ [gate] Owner Sign-off on Detailed Design │ ← REQUIRED before decomposition
328
+ │ Decomposition slices an AGREED design;│
329
+ │ it does NOT replace designing. Get │
330
+ │ owner sign-off (proportional to risk) │
331
+ │ before splitting into stories. │
332
+ └──────────────────┬──────────────────────┘
333
+ │ Signed off
334
+
335
+ ┌─────────────────────────────────────────┐
315
336
  │ 4. Split into Stories │
316
337
  │ - INVEST principles │
317
338
  │ - Bounded Context → US domain prefix │