@seanyao/roll 3.617.1 → 3.617.2

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 +17 -0
  2. package/dist/roll.mjs +480 -67
  3. package/package.json +2 -1
package/CHANGELOG.md CHANGED
@@ -2,6 +2,23 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## v3.617.2 — 2026-06-17
6
+
7
+ ### 修复
8
+
9
+ - **各类 agent 的展示、用量和 smoke 行为改走统一注册表**:模型名、实时观察窗、usage recovery、dashboard 回填、pairing 成本和 loop smoke 不再散落写 claude/pi/kimi/codex 特判;新增 agent 只需补 AgentSpec,非 claude 不再显示成 `?` 或被 mock smoke 降级。(FIX-313) `[loop]`
10
+ <!-- evidence: .roll/features/loop-engine/FIX-313/latest/FIX-313-report.html -->
11
+
12
+ - **Loop 页的"已合并显示"这次真生效了**:上一版加的"PR 合了 → 显示绿色"其实是哑的——一个缓冲区上限太小的 bug 让它读不到 git 记录、静默失效;现在已合并的交付会正确显示为已交付(绿),且只按这个循环自己开的 PR 判定,不会被别的 PR 误判成绿。(FIX-349, FIX-348, FIX-350) `[loop-observability]`
13
+
14
+ - **重复的故事编号当场拦住**:同一个编号被两个地方使用时,不再静默读到错的卡;新增一个 CI 检查,扫到重复编号就报错。(FIX-340) `[loop-engine]`
15
+
16
+ - **发布没成功的循环不再误标"失败"**:循环把活干完、过了质量闸,但因为临时的 GitHub 故障没能开 PR 时,现在显示"未发布"(中性灰),不再误显示成"失败"(红);`roll loop run-once --help` 也改为显示帮助,不再误跑一个循环。(FIX-351) `[loop-engine]`
17
+
18
+ ### 性能(可选,默认关闭)
19
+
20
+ - **两个 execute 提速开关(默认关,需手动开启)**:`prebuild_dist`(循环工作区一建好就预构建,省 agent 找入口的往返)和 `project_map`(spawn 时给 agent 注入精简的仓库结构 + 本卡相关文件,省它摸索)。对所有 agent 通用、不破循环隔离;不开就完全没影响。(FIX-338) `[loop-engine]`
21
+
5
22
  ## v3.617.1 — 2026-06-17
6
23
 
7
24
  ### 质量与可信
package/dist/roll.mjs CHANGED
@@ -2382,6 +2382,14 @@ var init_terminal = __esm({
2382
2382
  "orphan_timeout",
2383
2383
  "idle_no_work",
2384
2384
  "gave_up",
2385
+ // FIX-351 — a cycle that reached the end with its gates PASSED (attest
2386
+ // produced + peer ok/consulted, real TCR commits, exit 0 → a `built` capture)
2387
+ // but whose publish could NOT complete (push / `gh pr create` failed before
2388
+ // any orphan branch was pushed). The WORK is sound and locally committed —
2389
+ // this is NOT a gate failure. A neutral "ran locally, never published" state,
2390
+ // distinct from `failed` (a gate genuinely failed / errored) and from
2391
+ // `aborted_with_delivery` (publish failed but the branch WAS pushed for audit).
2392
+ "unpublished",
2385
2393
  "unknown"
2386
2394
  ];
2387
2395
  }
@@ -3076,6 +3084,131 @@ var init_registry = __esm({
3076
3084
  }
3077
3085
  });
3078
3086
 
3087
+ // packages/core/dist/agent/specs.js
3088
+ function canonicalSpecKey(name) {
3089
+ const raw = name.trim().toLowerCase();
3090
+ if (raw === "antigravity" || raw === "gemini")
3091
+ return "agy";
3092
+ if (raw === "openai")
3093
+ return "codex";
3094
+ if (raw === "deepseek")
3095
+ return "pi";
3096
+ return raw;
3097
+ }
3098
+ function withAgentSpecs(extra = []) {
3099
+ const out3 = {};
3100
+ for (const spec of [...BASE_AGENT_SPECS, ...extra]) {
3101
+ out3[canonicalSpecKey(spec.name)] = spec;
3102
+ for (const alias of spec.aliases ?? [])
3103
+ out3[canonicalSpecKey(alias)] = spec;
3104
+ }
3105
+ return out3;
3106
+ }
3107
+ function getAgentSpec(name, registry2 = AGENT_SPECS) {
3108
+ return registry2[canonicalSpecKey(name)];
3109
+ }
3110
+ function agentDefaultModel(name, registry2 = AGENT_SPECS) {
3111
+ return getAgentSpec(name, registry2)?.defaultModel ?? name;
3112
+ }
3113
+ function agentNormalizerKind(name, registry2 = AGENT_SPECS) {
3114
+ return getAgentSpec(name, registry2)?.normalizer ?? "generic";
3115
+ }
3116
+ function agentSmokeCommand(name, registry2 = AGENT_SPECS) {
3117
+ return getAgentSpec(name, registry2)?.smokeCommand ?? `${name} --version`;
3118
+ }
3119
+ var BASE_AGENT_SPECS, AGENT_SPECS;
3120
+ var init_specs = __esm({
3121
+ "packages/core/dist/agent/specs.js"() {
3122
+ "use strict";
3123
+ BASE_AGENT_SPECS = [
3124
+ {
3125
+ name: "claude",
3126
+ displayName: "claude",
3127
+ defaultModel: "claude-sonnet-4",
3128
+ normalizer: "claude",
3129
+ usage: { stdoutExtractor: "claude-stream", sessionBackfill: "claude-projects" },
3130
+ smokeCommand: 'claude -p "Reply with a single word: hello"'
3131
+ },
3132
+ {
3133
+ name: "codex",
3134
+ aliases: ["openai"],
3135
+ displayName: "codex",
3136
+ defaultModel: "gpt-5.5",
3137
+ normalizer: "codex",
3138
+ usage: { stdoutExtractor: "openai", sessionRecovery: "codex" },
3139
+ smokeCommand: 'codex exec "Reply with a single word: hello"'
3140
+ },
3141
+ {
3142
+ name: "kimi",
3143
+ displayName: "kimi",
3144
+ defaultModel: "kimi-k2",
3145
+ normalizer: "generic",
3146
+ usage: { stdoutExtractor: "kimi", sessionRecovery: "kimi" },
3147
+ smokeCommand: 'kimi-code -p "Reply with a single word: hello"'
3148
+ },
3149
+ {
3150
+ name: "qwen",
3151
+ displayName: "qwen",
3152
+ defaultModel: "qwen-coder-plus",
3153
+ normalizer: "generic",
3154
+ usage: { stdoutExtractor: "qwen" },
3155
+ smokeCommand: 'qwen -p "Reply with a single word: hello"'
3156
+ },
3157
+ {
3158
+ name: "agy",
3159
+ aliases: ["antigravity", "gemini"],
3160
+ displayName: "antigravity (agy)",
3161
+ defaultModel: "gemini-2.5-pro",
3162
+ normalizer: "generic",
3163
+ usage: { stdoutExtractor: "gemini" },
3164
+ smokeCommand: 'agy -p "Reply with a single word: hello"'
3165
+ },
3166
+ {
3167
+ name: "pi",
3168
+ aliases: ["deepseek"],
3169
+ displayName: "pi",
3170
+ defaultModel: "deepseek-v4-pro",
3171
+ normalizer: "generic",
3172
+ usage: { stdoutExtractor: "pi", sessionRecovery: "pi" },
3173
+ smokeCommand: 'pi -p "Reply with a single word: hello"'
3174
+ },
3175
+ {
3176
+ name: "cursor",
3177
+ displayName: "cursor",
3178
+ defaultModel: "cursor",
3179
+ normalizer: "generic",
3180
+ usage: { stdoutExtractor: "generic" },
3181
+ smokeCommand: "cursor --version"
3182
+ },
3183
+ {
3184
+ name: "opencode",
3185
+ displayName: "opencode",
3186
+ defaultModel: "opencode",
3187
+ normalizer: "generic",
3188
+ usage: { stdoutExtractor: "generic" },
3189
+ smokeCommand: "opencode --version"
3190
+ },
3191
+ {
3192
+ name: "trae",
3193
+ displayName: "trae",
3194
+ defaultModel: "trae",
3195
+ normalizer: "generic",
3196
+ usage: { stdoutExtractor: "generic" },
3197
+ smokeCommand: "trae --version"
3198
+ },
3199
+ {
3200
+ name: "openclaw",
3201
+ displayName: "openclaw",
3202
+ defaultModel: "openclaw",
3203
+ normalizer: "generic",
3204
+ usage: { stdoutExtractor: "generic" },
3205
+ smokeCommand: "openclaw --version"
3206
+ }
3207
+ ];
3208
+ AGENT_SPECS = withAgentSpecs();
3209
+ }
3210
+ });
3211
+
3079
3212
  // packages/core/dist/agent/router.js
3080
3213
  function classifyComplexity(estMin) {
3081
3214
  if (estMin === null || estMin === void 0)
@@ -3380,7 +3513,8 @@ function makeStdoutExtractor(spec) {
3380
3513
  };
3381
3514
  }
3382
3515
  function extractUsage(agent, lines2) {
3383
- const fn = REGISTRY[agent];
3516
+ const key = getAgentSpec(agent)?.usage.stdoutExtractor ?? agent;
3517
+ const fn = REGISTRY[key];
3384
3518
  if (fn === void 0)
3385
3519
  return null;
3386
3520
  let result;
@@ -3391,8 +3525,9 @@ function extractUsage(agent, lines2) {
3391
3525
  }
3392
3526
  if (result === null)
3393
3527
  return null;
3394
- for (const key of REQUIRED_FIELDS) {
3395
- if (result[key] === void 0 || result[key] === null)
3528
+ const required2 = key === "claude-stream" ? REQUIRED_FIELDS.filter((field2) => field2 !== "cost_list_usd") : REQUIRED_FIELDS;
3529
+ for (const field2 of required2) {
3530
+ if (result[field2] === void 0 || result[field2] === null)
3396
3531
  return null;
3397
3532
  }
3398
3533
  return result;
@@ -3639,6 +3774,7 @@ var MODEL_RE, INPUT_RE, OUTPUT_RE, COST_RE, TOTAL_RE_OPENAI, TOTAL_RE_GENERIC, S
3639
3774
  var init_tracker = __esm({
3640
3775
  "packages/core/dist/cost/tracker.js"() {
3641
3776
  "use strict";
3777
+ init_specs();
3642
3778
  init_prices();
3643
3779
  MODEL_RE = /^\s*model\s*[:=]\s*([A-Za-z0-9][\w.\-]*)/i;
3644
3780
  INPUT_RE = /input(?:\s+tokens)?\s*[:=]\s*([\d,]+)/i;
@@ -3658,11 +3794,13 @@ var init_tracker = __esm({
3658
3794
  qwenExtract = makeStdoutExtractor(STDOUT_AGENTS["qwen"]);
3659
3795
  piExtract = () => null;
3660
3796
  REGISTRY = {
3797
+ "claude-stream": sumClaudeStream,
3661
3798
  pi: piExtract,
3662
3799
  openai: openaiExtract,
3663
3800
  gemini: geminiExtract,
3664
3801
  kimi: kimiExtract,
3665
- qwen: qwenExtract
3802
+ qwen: qwenExtract,
3803
+ generic: makeStdoutExtractor({ defaultModel: "generic", totalKind: "generic" })
3666
3804
  };
3667
3805
  REQUIRED_FIELDS = [
3668
3806
  "model",
@@ -3800,7 +3938,7 @@ function peerReviewCost(peer, stdout) {
3800
3938
  try {
3801
3939
  const canon = canonicalAgentName(peer);
3802
3940
  const lines2 = stdout.split("\n");
3803
- const usage2 = canon === "claude" ? sumClaudeStream(lines2) : extractUsage(canon, lines2);
3941
+ const usage2 = extractUsage(canon, lines2);
3804
3942
  if (usage2 === null)
3805
3943
  return 0;
3806
3944
  if ((usage2.input_tokens ?? 0) === 0 && (usage2.output_tokens ?? 0) === 0)
@@ -5442,10 +5580,10 @@ function codexJsonl(line) {
5442
5580
  }
5443
5581
  }
5444
5582
  function normalizerFor(agent) {
5445
- const a = (agent ?? "").trim().toLowerCase();
5446
- if (a === "claude")
5583
+ const kind = agentNormalizerKind(agent);
5584
+ if (kind === "claude")
5447
5585
  return claudeNormalizer;
5448
- if (a === "codex")
5586
+ if (kind === "codex")
5449
5587
  return codexNormalizer;
5450
5588
  return genericNormalizer;
5451
5589
  }
@@ -5453,6 +5591,7 @@ var KIND_TIER, DEFAULT_HEARTBEAT_GAP_MS, SUPPRESS_TOOLS, PEER_VERDICTS, claudeNo
5453
5591
  var init_activity_signal = __esm({
5454
5592
  "packages/core/dist/loop/activity-signal.js"() {
5455
5593
  "use strict";
5594
+ init_specs();
5456
5595
  init_signals();
5457
5596
  KIND_TIER = {
5458
5597
  lifecycle: "A",
@@ -6298,6 +6437,8 @@ function mapV2Status(status2) {
6298
6437
  return "gave_up";
6299
6438
  case "orphan":
6300
6439
  return "aborted_with_delivery";
6440
+ case "local":
6441
+ return "unpublished";
6301
6442
  case "failed":
6302
6443
  return "failed";
6303
6444
  case "aborted":
@@ -6327,17 +6468,18 @@ function classifyPublish(pub) {
6327
6468
  if (pub.status === 0)
6328
6469
  return "published";
6329
6470
  if (pub.status === 2) {
6330
- if (pub.manualMerge === true)
6331
- return "failed";
6471
+ if (pub.manualMerge === true) {
6472
+ return pub.orphanPushed === true ? "orphan" : "local";
6473
+ }
6332
6474
  if (pub.mergedBack === true)
6333
6475
  return "done";
6334
6476
  if (pub.orphanPushed === true)
6335
6477
  return "orphan";
6336
- return "failed";
6478
+ return "local";
6337
6479
  }
6338
6480
  if (pub.orphanPushed === true)
6339
6481
  return "orphan";
6340
- return "failed";
6482
+ return "local";
6341
6483
  }
6342
6484
  function watchdogVerdict(elapsedSec, limitSec = CYCLE_TIMEOUT_SEC) {
6343
6485
  if (elapsedSec >= limitSec)
@@ -6542,8 +6684,8 @@ function cycleStep(state, event) {
6542
6684
  { kind: "append_alert", message: `cycle ${state.ctx.cycleId}: publish failed; orphan branch+tag pushed; worktree cleaned` }
6543
6685
  ]);
6544
6686
  }
6545
- return terminate({ ...state, phase: "publish" }, "failed", [
6546
- { kind: "append_alert", message: `cycle ${state.ctx.cycleId}: publish failed; worktree preserved at branch ${state.ctx.branch}` }
6687
+ return terminate({ ...state, phase: "publish" }, "local", [
6688
+ { kind: "append_alert", message: `cycle ${state.ctx.cycleId}: gates passed but publish did not complete \u2014 work committed locally, worktree preserved at branch ${state.ctx.branch} (unpublished, not a failure)` }
6547
6689
  ]);
6548
6690
  }
6549
6691
  case "merge_polled": {
@@ -6915,7 +7057,15 @@ function parseLoopSafety(lines2, start) {
6915
7057
  ...flat["peer_gate"] === "hard" || flat["peer_gate"] === "soft" ? { peerGate: flat["peer_gate"] } : {},
6916
7058
  // FIX-298: the network-guard recovery hook. A non-empty string is the shell
6917
7059
  // command the guard runs to enable connectivity before re-checking.
6918
- ...flat["proxy_enable_cmd"] !== void 0 && flat["proxy_enable_cmd"] !== "" ? { proxyEnableCmd: flat["proxy_enable_cmd"] } : {}
7060
+ ...flat["proxy_enable_cmd"] !== void 0 && flat["proxy_enable_cmd"] !== "" ? { proxyEnableCmd: flat["proxy_enable_cmd"] } : {},
7061
+ // FIX-338: the prebuild-dist execute-speed lever. DEFAULT-OFF — only an
7062
+ // explicit `prebuild_dist: true` turns it on; anything else (absent / false /
7063
+ // garbage) leaves it false so deploy stays a NO-OP (稳字纪律).
7064
+ ...flat["prebuild_dist"] === "true" ? { prebuildDist: true } : {},
7065
+ // FIX-338 (杠杆2): the project-map injection execute-speed lever. DEFAULT-OFF —
7066
+ // only an explicit `project_map: true` turns it on; anything else (absent /
7067
+ // false / garbage) leaves it false so deploy stays a NO-OP (稳字纪律).
7068
+ ...flat["project_map"] === "true" ? { projectMap: true } : {}
6919
7069
  };
6920
7070
  return [i, cfg];
6921
7071
  }
@@ -8441,7 +8591,10 @@ var init_selectors = __esm({
8441
8591
  // was unmapped, fell to outcome "" → "unknown" panel, and the failure was
8442
8592
  // silently swallowed from every failed count (the FIX-248 class).
8443
8593
  reverted: "failed",
8444
- orphan: "aborted_with_delivery"
8594
+ orphan: "aborted_with_delivery",
8595
+ // FIX-351: gates passed but publish did not land (work committed locally) — a
8596
+ // neutral non-failure terminal, NOT mapped into the failed cluster.
8597
+ local: "unpublished"
8445
8598
  };
8446
8599
  }
8447
8600
  });
@@ -8451,6 +8604,7 @@ var init_dist2 = __esm({
8451
8604
  "packages/core/dist/index.js"() {
8452
8605
  "use strict";
8453
8606
  init_registry();
8607
+ init_specs();
8454
8608
  init_router();
8455
8609
  init_pairing();
8456
8610
  init_peer_review();
@@ -8575,11 +8729,15 @@ function fmtDur(s) {
8575
8729
  function fmtModel(model) {
8576
8730
  if (model === null || model === void 0 || model === "")
8577
8731
  return "\u2014";
8578
- if (!model.startsWith("claude-"))
8579
- return "?";
8580
- let s = model.slice("claude-".length);
8732
+ const prefixed = /^([a-z][a-z0-9_]*)-(.+)$/i.exec(model);
8733
+ if (prefixed === null)
8734
+ return model;
8735
+ const vendor = prefixed[1] ?? "";
8736
+ let s = prefixed[2] ?? "";
8737
+ if (vendor !== "claude")
8738
+ return model;
8581
8739
  s = s.replace(/-\d{6,8}$/, "");
8582
- return s !== "" ? s : "?";
8740
+ return s !== "" ? s : model;
8583
8741
  }
8584
8742
  function pyFixed1(x) {
8585
8743
  const scaled = x * 10;
@@ -8781,8 +8939,8 @@ function cycleRow(cy) {
8781
8939
  const timeC = outcome === "fail" ? "red" : "fg";
8782
8940
  const sidC = outcome === "fail" ? "red" : "blue";
8783
8941
  let modelLabel = fmtModel(cy.model);
8784
- if ((modelLabel === "\u2014" || modelLabel === "?") && cy.agent) {
8785
- modelLabel = AGENT_PRIMARY_MODEL[cy.agent] ?? cy.agent;
8942
+ if (modelLabel === "\u2014" && cy.agent) {
8943
+ modelLabel = agentDefaultModel(cy.agent);
8786
8944
  }
8787
8945
  const showModel = COLS >= 100;
8788
8946
  const modelSeg = showModel ? c("muted", pad(modelLabel, 11)) + " " : "";
@@ -8818,10 +8976,11 @@ function cycleRow(cy) {
8818
8976
  }
8819
8977
  return lines2;
8820
8978
  }
8821
- var COLS, renderState, PAL, BOLD, RESET, ANSI_RE2, RESET_RAW, AGENT_PRIMARY_MODEL, BG_FAIL, WEEKDAY_EN, WEEKDAY_ZH;
8979
+ var COLS, renderState, PAL, BOLD, RESET, ANSI_RE2, RESET_RAW, BG_FAIL, WEEKDAY_EN, WEEKDAY_ZH;
8822
8980
  var init_render = __esm({
8823
8981
  "packages/cli/dist/render.js"() {
8824
8982
  "use strict";
8983
+ init_dist2();
8825
8984
  COLS = 100;
8826
8985
  renderState = { useColor: true };
8827
8986
  PAL = {
@@ -8841,11 +9000,6 @@ var init_render = __esm({
8841
9000
  RESET = "\x1B[0m";
8842
9001
  ANSI_RE2 = /\x1b\[[\d;]*m/g;
8843
9002
  RESET_RAW = "\x1B[0m";
8844
- AGENT_PRIMARY_MODEL = {
8845
- pi: "deepseek-v4-pro",
8846
- deepseek: "deepseek-v4-pro",
8847
- kimi: "kimi-k2-0905"
8848
- };
8849
9003
  BG_FAIL = "\x1B[48;2;55;15;15m";
8850
9004
  WEEKDAY_EN = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
8851
9005
  WEEKDAY_ZH = ["\u5468\u4E00", "\u5468\u4E8C", "\u5468\u4E09", "\u5468\u56DB", "\u5468\u4E94", "\u5468\u516D", "\u5468\u65E5"];
@@ -12183,6 +12337,11 @@ function outcomeToPanel(outcome, state) {
12183
12337
  return "done";
12184
12338
  case "idle_no_work":
12185
12339
  return "idle";
12340
+ // FIX-351: gates passed but publish did not land (work committed locally) —
12341
+ // a NEUTRAL state, NOT a failure. Classified `idle`-side so the panel/tally
12342
+ // never counts a sound-but-unpublished cycle as a fail (it is not red).
12343
+ case "unpublished":
12344
+ return "idle";
12186
12345
  case "failed":
12187
12346
  case "blocked":
12188
12347
  case "aborted_no_delivery":
@@ -14049,6 +14208,7 @@ duration: ${facts.durationMs}ms
14049
14208
  // packages/cli/dist/commands/dashboard.js
14050
14209
  init_render();
14051
14210
  init_dist3();
14211
+ init_dist2();
14052
14212
  import { execFileSync as execFileSync2 } from "node:child_process";
14053
14213
  import { createHash as createHash3 } from "node:crypto";
14054
14214
  import { existsSync as existsSync18, readFileSync as readFileSync17, readdirSync as readdirSync7, realpathSync as realpathSync2, statSync as statSync8 } from "node:fs";
@@ -14731,7 +14891,9 @@ function repairOrphanCyclesFromGit(cycles, gitMerges) {
14731
14891
  }
14732
14892
  }
14733
14893
  }
14734
- function loadClaudeSessionUsage(label4, slug) {
14894
+ function loadStreamJsonSessionUsage(label4, slug, agent) {
14895
+ if (getAgentSpec(agent)?.usage.sessionBackfill !== "claude-projects")
14896
+ return null;
14735
14897
  const user = process.env["USER"] ?? "seanyao";
14736
14898
  const worktreePath = `/Users/${user}/.shared/roll/worktrees/${slug}-cycle-${label4}`;
14737
14899
  const projName = "-" + worktreePath.replaceAll("/", "-").replaceAll(".", "-").replace(/^-+/, "");
@@ -14796,7 +14958,7 @@ function loadClaudeSessionUsage(label4, slug) {
14796
14958
  return null;
14797
14959
  return { model, ...sums, cost_reported_usd: cost, duration_ms: durationMs };
14798
14960
  }
14799
- function backfillUsageFromClaudeSessions(cycles, slug) {
14961
+ function backfillUsageFromAgentSessions(cycles, slug) {
14800
14962
  for (const cy of cycles) {
14801
14963
  const ue = cy.usage_event;
14802
14964
  if (ue !== void 0 && (toInt2(ue["input_tokens"]) || toInt2(ue["output_tokens"]))) {
@@ -14827,7 +14989,7 @@ function backfillUsageFromClaudeSessions(cycles, slug) {
14827
14989
  }
14828
14990
  if (cy.input_tokens || cy.output_tokens)
14829
14991
  continue;
14830
- const u = loadClaudeSessionUsage(cy.label, slug);
14992
+ const u = loadStreamJsonSessionUsage(cy.label, slug, cy.agent ?? "");
14831
14993
  if (!u)
14832
14994
  continue;
14833
14995
  cy.input_tokens = toInt2(u.input_tokens);
@@ -15414,7 +15576,7 @@ function isoLabel(d) {
15414
15576
  return `${d.getUTCFullYear()}${pad2(d.getUTCMonth() + 1)}${pad2(d.getUTCDate())}-${pad2(d.getUTCHours())}${pad2(d.getUTCMinutes())}${pad2(d.getUTCSeconds())}-30585`;
15415
15577
  }
15416
15578
  function render(out3, events, cron, state, backlog, args) {
15417
- const { days, lang: lang9, runs, gitMerges, claudeSlug, now } = args;
15579
+ const { days, lang: lang9, runs, gitMerges, projectSlug: projectSlug4, now } = args;
15418
15580
  const cycles = aggregate(events, cron);
15419
15581
  const matchedRunIds = Object.keys(runs).length > 0 ? mergeRunsIntoCycles(cycles, runs) : /* @__PURE__ */ new Set();
15420
15582
  for (const cy of cycles) {
@@ -15430,7 +15592,7 @@ function render(out3, events, cron, state, backlog, args) {
15430
15592
  }
15431
15593
  if (Object.keys(gitMerges).length > 0)
15432
15594
  repairOrphanCyclesFromGit(cycles, gitMerges);
15433
- backfillUsageFromClaudeSessions(cycles, claudeSlug ?? "");
15595
+ backfillUsageFromAgentSessions(cycles, projectSlug4 ?? "");
15434
15596
  const byDay = bucketByDay(cycles);
15435
15597
  const daysKeys = [...byDay.keys()].sort().reverse().slice(0, days);
15436
15598
  const bilingual2 = (enLine, zhLine) => {
@@ -15692,7 +15854,7 @@ function collectCycles(slug, days) {
15692
15854
  mergeRunsIntoCycles(cycles, runs);
15693
15855
  if (Object.keys(gitMerges).length > 0)
15694
15856
  repairOrphanCyclesFromGit(cycles, gitMerges);
15695
- backfillUsageFromClaudeSessions(cycles, slug);
15857
+ backfillUsageFromAgentSessions(cycles, slug);
15696
15858
  return cycles;
15697
15859
  }
15698
15860
  function rollupForStory(cycles, storyId) {
@@ -15993,7 +16155,7 @@ roll-loop-status.py: error: unrecognized arguments: ${args.unknown.join(" ")}
15993
16155
  lang: lang9,
15994
16156
  runs,
15995
16157
  gitMerges,
15996
- claudeSlug: slug,
16158
+ projectSlug: slug,
15997
16159
  now,
15998
16160
  rtDir: useFixture || slug === null ? null : loopRuntimeDir(slug)
15999
16161
  });
@@ -16907,6 +17069,12 @@ function overviewTab(input) {
16907
17069
  }
16908
17070
  var VERDICT_COLORS = {
16909
17071
  delivered: C.green,
17072
+ pending_merge: C.amber,
17073
+ // PR open, merge pending — in-flight (amber), not red
17074
+ // FIX-351: gates passed but publish did not land (work committed locally) —
17075
+ // a NEUTRAL blue, clearly distinct from `failed` (red). The dashboard reads
17076
+ // it as "ran locally, not published", never as a failure.
17077
+ unpublished: C.blue,
16910
17078
  reverted: C.amber,
16911
17079
  failed: C.red,
16912
17080
  blocked: C.purple,
@@ -16915,6 +17083,9 @@ var VERDICT_COLORS = {
16915
17083
  };
16916
17084
  var VERDICT_ZH = {
16917
17085
  delivered: "\u5DF2\u4EA4\u4ED8",
17086
+ pending_merge: "\u5F85\u5408\u5E76",
17087
+ unpublished: "\u672A\u53D1\u5E03",
17088
+ // FIX-351: 闸通过但未发布(本地已提交)——中性,非失败
16918
17089
  reverted: "\u5DF2\u56DE\u6EDA",
16919
17090
  failed: "\u5931\u8D25",
16920
17091
  blocked: "\u88AB\u963B\u585E",
@@ -18091,6 +18262,8 @@ function ledgerVerdict(status2, outcome) {
18091
18262
  if (outcome === "published_pending_merge" || status2 === "published" || status2 === "built" || status2 === "done") {
18092
18263
  return "pending_merge";
18093
18264
  }
18265
+ if (outcome === "unpublished" || status2 === "local")
18266
+ return "unpublished";
18094
18267
  if (outcome === "blocked" || status2 === "blocked")
18095
18268
  return "blocked";
18096
18269
  if (outcome === "idle_no_work" || status2 === "idle")
@@ -18112,7 +18285,9 @@ function reconcilePendingMergeVerdicts(rows, isMerged) {
18112
18285
  return rows.map((r) => {
18113
18286
  if (r.verdict !== "pending_merge")
18114
18287
  return r;
18115
- if (r.storyId === "" || !isMerged(r.storyId))
18288
+ if (r.storyId === "" && r.prNumber === void 0)
18289
+ return r;
18290
+ if (!isMerged(r.storyId, r.prNumber))
18116
18291
  return r;
18117
18292
  return {
18118
18293
  ...r,
@@ -18164,14 +18339,15 @@ function readEventFacts(projectPath3) {
18164
18339
  const byCycle = /* @__PURE__ */ new Map();
18165
18340
  const prMergedBy = /* @__PURE__ */ new Map();
18166
18341
  const prOpenBy = /* @__PURE__ */ new Map();
18342
+ const prByCycle = /* @__PURE__ */ new Map();
18167
18343
  const path = join23(projectPath3, ".roll", "loop", "events.ndjson");
18168
18344
  if (!existsSync25(path))
18169
- return { byCycle, prMergedBy, prOpenBy };
18345
+ return { byCycle, prMergedBy, prOpenBy, prByCycle };
18170
18346
  let content = "";
18171
18347
  try {
18172
18348
  content = readFileSync23(path, "utf8");
18173
18349
  } catch {
18174
- return { byCycle, prMergedBy, prOpenBy };
18350
+ return { byCycle, prMergedBy, prOpenBy, prByCycle };
18175
18351
  }
18176
18352
  const facts = (id) => {
18177
18353
  let f = byCycle.get(id);
@@ -18195,14 +18371,28 @@ function readEventFacts(projectPath3) {
18195
18371
  prMergedBy.set(e.storyId, e.prNumber);
18196
18372
  else if (e.type === "pr:open")
18197
18373
  prOpenBy.set(e.storyId, e.prNumber);
18374
+ else if (e.type === "cycle:terminal" && e.pr.present) {
18375
+ const n = terminalPrNumber(e.pr.value);
18376
+ if (n !== void 0)
18377
+ prByCycle.set(e.cycleId, n);
18378
+ }
18198
18379
  }
18199
- return { byCycle, prMergedBy, prOpenBy };
18380
+ return { byCycle, prMergedBy, prOpenBy, prByCycle };
18381
+ }
18382
+ function terminalPrNumber(pr) {
18383
+ if (typeof pr.number === "number" && Number.isInteger(pr.number) && pr.number > 0)
18384
+ return pr.number;
18385
+ const m7 = /\/pull\/(\d+)/.exec(pr.url);
18386
+ if (m7 === null)
18387
+ return void 0;
18388
+ const n = Number(m7[1]);
18389
+ return Number.isInteger(n) && n > 0 ? n : void 0;
18200
18390
  }
18201
18391
  function rowTape(row2, verdict, ev, prNumber, prOpen) {
18202
18392
  const storyId = typeof row2["story_id"] === "string" ? row2["story_id"] : "";
18203
18393
  const tcr = typeof row2["tcr_count"] === "number" ? row2["tcr_count"] : 0;
18204
18394
  const seg = (key, detail, state) => ({ key, detail, state });
18205
- const endState = verdict === "delivered" ? "pass" : verdict === "idle" ? "idle" : verdict === "unknown" ? "unknown" : "fail";
18395
+ const endState = verdict === "delivered" ? "pass" : verdict === "idle" || verdict === "unpublished" ? "idle" : verdict === "unknown" ? "unknown" : "fail";
18206
18396
  return [
18207
18397
  seg("cycle", typeof row2["ts"] === "string" ? row2["ts"].replace("T", " ").replace(/:\d{2}Z$/, "Z") : "\u2014", "pass"),
18208
18398
  seg("story", storyId !== "" ? storyId : "\u2014", storyId !== "" ? "pass" : "idle"),
@@ -18228,7 +18418,7 @@ function collectCycleLedger(projectPath3) {
18228
18418
  } catch {
18229
18419
  return [];
18230
18420
  }
18231
- const { byCycle, prMergedBy, prOpenBy } = readEventFacts(projectPath3);
18421
+ const { byCycle, prMergedBy, prOpenBy, prByCycle } = readEventFacts(projectPath3);
18232
18422
  const rows = [];
18233
18423
  for (const line of content.split("\n")) {
18234
18424
  if (line.trim() === "")
@@ -18269,7 +18459,10 @@ function collectCycleLedger(projectPath3) {
18269
18459
  cost: cost !== void 0 ? `$${cost.toFixed(2)}` : usageUnknown ? "?" : "\u2014",
18270
18460
  duration: fmtDuration(row2["duration_sec"]),
18271
18461
  tape: rowTape(row2, verdict, ev, prNumber, prOpen),
18272
- evidence
18462
+ evidence,
18463
+ // FIX-348: the cycle's own PR number (cycle:terminal twin), falling back to
18464
+ // the merged/open PR event keyed by story when the terminal twin is absent.
18465
+ prNumber: prByCycle.get(cycleId) ?? prNumber ?? prOpen
18273
18466
  });
18274
18467
  }
18275
18468
  const byId = /* @__PURE__ */ new Map();
@@ -21924,6 +22117,20 @@ function storyHasMergeEvidence(facts, storyId) {
21924
22117
  return true;
21925
22118
  return factsPrNumbers(facts, storyId).length > 0;
21926
22119
  }
22120
+ function gitHasPrMergeCommit(facts, prNumber) {
22121
+ if (facts === null || !Number.isInteger(prNumber) || prNumber <= 0)
22122
+ return false;
22123
+ for (const c2 of facts.commits) {
22124
+ for (const m7 of c2.subject.matchAll(/(?:\(#|PR\s*#?)(\d+)\)?/gi)) {
22125
+ if (numberFromString(m7[1]) === prNumber)
22126
+ return true;
22127
+ }
22128
+ }
22129
+ return false;
22130
+ }
22131
+ function cycleMergeTruth(facts) {
22132
+ return (storyId, prNumber) => prNumber !== void 0 && prNumber !== null ? gitHasPrMergeCommit(facts, prNumber) : storyId !== "" && storyHasMergeEvidence(facts, storyId);
22133
+ }
21927
22134
  function factsPrNumbers(facts, storyId) {
21928
22135
  const nums = /* @__PURE__ */ new Set();
21929
22136
  for (const subject of factsSubjects(facts, storyId)) {
@@ -21995,7 +22202,13 @@ function statDir(p) {
21995
22202
  return statSync13(p).isFile();
21996
22203
  }
21997
22204
  function execGit(cwd, args) {
21998
- return execFileSync6("git", args, { cwd, encoding: "utf8", timeout: 1e4, stdio: ["ignore", "pipe", "ignore"] });
22205
+ return execFileSync6("git", args, {
22206
+ cwd,
22207
+ encoding: "utf8",
22208
+ timeout: 1e4,
22209
+ stdio: ["ignore", "pipe", "ignore"],
22210
+ maxBuffer: 256 * 1024 * 1024
22211
+ });
21999
22212
  }
22000
22213
 
22001
22214
  // packages/cli/dist/commands/index-gen.js
@@ -22335,7 +22548,10 @@ function generateDossierPages(cwd, rebuild) {
22335
22548
  // rewrites its runs row. Reuses the SAME offline git facts the dossier
22336
22549
  // already built (storyHasMergeEvidence — a `git log` check, no gh call;
22337
22550
  // refreshDossierMergeBaseline fetched origin/main just above).
22338
- cycles: reconcilePendingMergeVerdicts(collectCycleLedger(cwd), (id) => storyHasMergeEvidence(runCache.git, id)),
22551
+ // FIX-348: cycleMergeTruth ALSO matches the row's recorded PR number, so
22552
+ // a merged delivery whose squash commit carries `(#N)` but does NOT name
22553
+ // the story-id (e.g. FIX-287 / PR #773) still reconciles to delivered.
22554
+ cycles: reconcilePendingMergeVerdicts(collectCycleLedger(cwd), cycleMergeTruth(runCache.git)),
22339
22555
  agents: agentRows,
22340
22556
  releasePanel: collectReleasePanel(cwd),
22341
22557
  skills: collectSkillsPanel(cwd),
@@ -24919,7 +25135,7 @@ import { join as join42 } from "node:path";
24919
25135
  init_render();
24920
25136
  function reconciledLedger(cwd) {
24921
25137
  const git4 = collectGitDossierFacts(cwd);
24922
- return reconcilePendingMergeVerdicts(collectCycleLedger(cwd), (id) => storyHasMergeEvidence(git4, id));
25138
+ return reconcilePendingMergeVerdicts(collectCycleLedger(cwd), cycleMergeTruth(git4));
24923
25139
  }
24924
25140
  var CYCLES_USAGE = "Usage: roll cycles [--since 1d|3d|7d|all] [--detail <id>]\n The cycle ledger: one line per cycle, failures never swallowed (default --since 3d).\n --detail <id> the per-cycle build-phase timeline (per-commit / heartbeat timing).\n\u5468\u671F\u8D26\u672C\uFF1A\u6BCF\u884C\u4E00\u4E2A cycle\uFF0C\u5931\u8D25\u4E0D\u88AB\u541E\uFF08\u9ED8\u8BA4 --since 3d\uFF09\u3002\n --detail <id> \u5355\u4E2A cycle \u7684 build \u9636\u6BB5\u65F6\u95F4\u7EBF\uFF08\u6BCF\u63D0\u4EA4/\u5FC3\u8DF3\u8BA1\u65F6\uFF09\u3002";
24925
25141
  var WINDOWS = { "1d": 1, "3d": 3, "7d": 7 };
@@ -24931,6 +25147,8 @@ var VERDICT_COLOR3 = {
24931
25147
  delivered: "green",
24932
25148
  pending_merge: "yellow",
24933
25149
  // FIX-322: opened a PR, merge pending — in-flight, NOT delivered (amber)
25150
+ unpublished: "blue",
25151
+ // FIX-351: gates passed, work local, publish didn't land — neutral, NOT red
24934
25152
  reverted: "yellow",
24935
25153
  failed: "red",
24936
25154
  blocked: "purple",
@@ -25185,7 +25403,7 @@ function findCycle(rows, raw) {
25185
25403
  }
25186
25404
  function renderCycleTrace(row2, lang9, slug) {
25187
25405
  const lines2 = [];
25188
- lines2.push(`#${cycleNo(row2.cycleId)} \xB7 ${c(row2.verdict === "delivered" ? "green" : row2.verdict === "idle" ? "muted" : "red", row2.verdict)} \xB7 ${row2.model} \xB7 ${row2.tokens} \xB7 ${row2.cost} \xB7 ${row2.duration}`);
25406
+ lines2.push(`#${cycleNo(row2.cycleId)} \xB7 ${c(row2.verdict === "delivered" ? "green" : row2.verdict === "idle" || row2.verdict === "unpublished" ? "muted" : "red", row2.verdict)} \xB7 ${row2.model} \xB7 ${row2.tokens} \xB7 ${row2.cost} \xB7 ${row2.duration}`);
25189
25407
  lines2.push(lang9 === "zh" ? `story ${row2.storyId === "" ? "\u2014\uFF08\u65E0\u6545\u4E8B\uFF09" : row2.storyId}` : `story ${row2.storyId === "" ? "\u2014 (no story)" : row2.storyId}`);
25190
25408
  lines2.push("");
25191
25409
  const reached = new Set(row2.tape.filter((s) => s.detail !== "\u2014" || s.state !== "unknown").map((s) => s.key));
@@ -28600,23 +28818,47 @@ function reportCandidates(worktreeCwd, storyId) {
28600
28818
  function acMapCandidates(worktreeCwd, storyId) {
28601
28819
  return [join57(cardArchiveDir(worktreeCwd, storyId), "ac-map.json")];
28602
28820
  }
28603
- function storySpecPath(worktreeCwd, storyId) {
28821
+ function storySpecMatches(worktreeCwd, storyId) {
28604
28822
  const featuresDir = join57(worktreeCwd, ".roll", "features");
28823
+ const matches = [];
28824
+ let entries;
28605
28825
  try {
28606
- for (const epic of readdirSync23(featuresDir, { withFileTypes: true })) {
28607
- if (!epic.isDirectory())
28608
- continue;
28609
- const spec = join57(featuresDir, epic.name, storyId, "spec.md");
28610
- if (existsSync58(spec))
28611
- return spec;
28612
- const legacy = join57(featuresDir, epic.name, `${storyId}.md`);
28613
- if (existsSync58(legacy))
28614
- return legacy;
28615
- }
28826
+ entries = readdirSync23(featuresDir, { withFileTypes: true });
28616
28827
  } catch {
28617
- return null;
28828
+ return matches;
28618
28829
  }
28619
- return null;
28830
+ for (const epic of entries) {
28831
+ if (!epic.isDirectory())
28832
+ continue;
28833
+ const spec = join57(featuresDir, epic.name, storyId, "spec.md");
28834
+ if (existsSync58(spec)) {
28835
+ matches.push(spec);
28836
+ continue;
28837
+ }
28838
+ const legacy = join57(featuresDir, epic.name, `${storyId}.md`);
28839
+ if (existsSync58(legacy))
28840
+ matches.push(legacy);
28841
+ }
28842
+ return matches;
28843
+ }
28844
+ var DuplicateStoryIdError = class extends Error {
28845
+ storyId;
28846
+ matches;
28847
+ constructor(storyId, matches) {
28848
+ super(`duplicate story id ${storyId}: resolves to ${matches.length} specs \u2014 a story id MUST resolve uniquely (\u4E00\u4E2A ID \u4E00\u4EFD spec). Disambiguate (rename/archive) one of:
28849
+ ` + matches.map((m7) => ` - ${m7}`).join("\n"));
28850
+ this.storyId = storyId;
28851
+ this.matches = matches;
28852
+ this.name = "DuplicateStoryIdError";
28853
+ }
28854
+ };
28855
+ function storySpecPath(worktreeCwd, storyId) {
28856
+ const matches = storySpecMatches(worktreeCwd, storyId);
28857
+ if (matches.length === 0)
28858
+ return null;
28859
+ if (matches.length > 1)
28860
+ throw new DuplicateStoryIdError(storyId, matches);
28861
+ return matches[0] ?? null;
28620
28862
  }
28621
28863
  function storyHasAcBlock(worktreeCwd, storyId) {
28622
28864
  const spec = storySpecPath(worktreeCwd, storyId);
@@ -29367,7 +29609,18 @@ story validate: '${id}' \u4E0D\u662F\u5408\u6CD5\u6545\u4E8B ID
29367
29609
  return 2;
29368
29610
  }
29369
29611
  const cwd = process.cwd();
29370
- const spec = storySpecPath(cwd, id);
29612
+ let spec;
29613
+ try {
29614
+ spec = storySpecPath(cwd, id);
29615
+ } catch (e) {
29616
+ if (e instanceof DuplicateStoryIdError) {
29617
+ process.stderr.write(`story validate: ${e.message}
29618
+ story validate: ${id} \u89E3\u6790\u5230\u591A\u4EFD spec \u2014 \u5FC5\u987B\u552F\u4E00(\u6D88\u9664\u91CD\u590D ID \u540E\u91CD\u8BD5)
29619
+ `);
29620
+ return 2;
29621
+ }
29622
+ throw e;
29623
+ }
29371
29624
  if (spec === null) {
29372
29625
  process.stderr.write(`story validate: no spec found for ${id} (looked for features/<epic>/${id}/spec.md and features/<epic>/${id}.md)
29373
29626
  story validate: \u627E\u4E0D\u5230 ${id} \u7684 spec
@@ -30835,6 +31088,7 @@ async function loopWatchCommand(args, deps = realDeps6()) {
30835
31088
 
30836
31089
  // packages/cli/dist/commands/loop-maint.js
30837
31090
  init_dist3();
31091
+ init_dist2();
30838
31092
  init_dist();
30839
31093
  import { spawnSync as spawnSync8 } from "node:child_process";
30840
31094
  import { existsSync as existsSync61, mkdirSync as mkdirSync28, readFileSync as readFileSync56, readdirSync as readdirSync25, renameSync as renameSync8, rmSync as rmSync10, statSync as statSync21, writeFileSync as writeFileSync28 } from "node:fs";
@@ -31097,7 +31351,7 @@ function realTestDeps() {
31097
31351
  };
31098
31352
  }
31099
31353
  function defaultSmokeCmd(agent) {
31100
- return agent === "claude" ? 'claude -p "Reply with a single word: hello"; sleep 10' : `echo 'mock ${agent} output line 1'; echo 'mock ${agent} output line 2'`;
31354
+ return `${agentSmokeCommand(agent)}; sleep 10`;
31101
31355
  }
31102
31356
  function loopTestCommand(args = [], deps = realTestDeps()) {
31103
31357
  const slug = deps.slug();
@@ -32353,6 +32607,7 @@ async function executeCommand(cmd, ports, ctx) {
32353
32607
  const depsOk = await bootstrapWorktreeDeps(ports.paths.worktreePath, ports.paths.alertsPath, ports.events, ports.depsExec);
32354
32608
  if (!depsOk)
32355
32609
  return { event: { type: "worktree_failed" } };
32610
+ await bootstrapWorktreePrebuild(ports.paths.worktreePath, ports.paths.alertsPath, ports.events, readPrebuildDistEnabled(ports.repoCwd), ports.depsExec);
32356
32611
  return { event: { type: "worktree_created" } };
32357
32612
  }
32358
32613
  // backlog/picker pickStory (read backlog INSIDE the worktree, bin/roll:8938).
@@ -32437,11 +32692,12 @@ async function executeCommand(cmd, ports, ctx) {
32437
32692
  }
32438
32693
  const captureSink = ctx.evidenceRunDir !== void 0 && ctx.evidenceRunDir !== "" ? createCaptureMarkerSink(ctx.evidenceRunDir, ports.capture) : void 0;
32439
32694
  const observer = startCycleObserver(ports, ctx.cycleId ?? "");
32695
+ const skillBodyForSpawn = maybeInjectProjectMap(ports.skillBody, ports.paths.worktreePath, readProjectMapEnabled(ports.repoCwd), ctx.storyId);
32440
32696
  let res;
32441
32697
  try {
32442
32698
  res = await ports.agentSpawn(cmd.agent, {
32443
32699
  cwd: ports.paths.worktreePath,
32444
- skillBody: ports.skillBody,
32700
+ skillBody: skillBodyForSpawn,
32445
32701
  ...ctx.evidenceRunDir !== void 0 ? { runDir: ctx.evidenceRunDir } : {},
32446
32702
  writableRoots: agentWritableRoots(ports.repoCwd, ports.paths.alertsPath),
32447
32703
  env: {
@@ -32485,16 +32741,17 @@ ${res.stderr}
32485
32741
  try {
32486
32742
  const agentName = ctx.agent ?? cmd.agent;
32487
32743
  const lines2 = res.stdout.split("\n");
32488
- let usage2 = sumClaudeStream(lines2);
32489
- if (usage2 === null && agentName === "pi") {
32744
+ const usageSpec = getAgentSpec(agentName)?.usage;
32745
+ let usage2 = usageSpec?.stdoutExtractor === "claude-stream" ? extractUsage(agentName, lines2) : null;
32746
+ if (usage2 === null && usageSpec?.sessionRecovery === "pi") {
32490
32747
  const rootOverride = (process.env["ROLL_PI_SESSIONS_ROOT"] ?? "").trim();
32491
32748
  usage2 = recoverPiUsage(ports.paths.worktreePath, ctx.startSec, ...rootOverride !== "" ? [rootOverride] : []);
32492
32749
  }
32493
- if (usage2 === null && agentName === "kimi") {
32750
+ if (usage2 === null && usageSpec?.sessionRecovery === "kimi") {
32494
32751
  const rootOverride = (process.env["ROLL_KIMI_SESSIONS_DIR"] ?? "").trim();
32495
32752
  usage2 = recoverKimiUsage(ports.paths.worktreePath, ctx.startSec, ...rootOverride !== "" ? [rootOverride] : []);
32496
32753
  }
32497
- if (usage2 === null && (agentName === "codex" || agentName === "openai")) {
32754
+ if (usage2 === null && usageSpec?.sessionRecovery === "codex") {
32498
32755
  const rootOverride = (process.env["ROLL_CODEX_SESSIONS_DIR"] ?? "").trim();
32499
32756
  usage2 = recoverCodexUsage(ports.paths.worktreePath, ctx.startSec, ...rootOverride !== "" ? [rootOverride] : []);
32500
32757
  }
@@ -32998,7 +33255,10 @@ function buildTerminalRecord(cmd, ctx, attestCwd, nowSec2) {
32998
33255
  failed: "failed",
32999
33256
  blocked: "blocked",
33000
33257
  aborted: "aborted_no_delivery",
33001
- orphan: "aborted_with_delivery"
33258
+ orphan: "aborted_with_delivery",
33259
+ // FIX-351: gates passed but publish could not complete (work committed
33260
+ // locally, never published) — a neutral terminal, NOT a failure.
33261
+ local: "unpublished"
33002
33262
  };
33003
33263
  let attest;
33004
33264
  if (storyId === "") {
@@ -33355,6 +33615,147 @@ async function bootstrapWorktreeDeps(worktreePath, alertsPath, events, exec = ex
33355
33615
  return false;
33356
33616
  }
33357
33617
  }
33618
+ var PREBUILD_TIMEOUT_MS = 6e5;
33619
+ function readPrebuildDistEnabled(repoCwd) {
33620
+ try {
33621
+ const p = join66(repoCwd, ".roll", "policy.yaml");
33622
+ if (!existsSync66(p))
33623
+ return false;
33624
+ return parsePolicy(readFileSync62(p, "utf8")).loopSafety.prebuildDist === true;
33625
+ } catch {
33626
+ return false;
33627
+ }
33628
+ }
33629
+ async function bootstrapWorktreePrebuild(worktreePath, alertsPath, events, enabled, exec = execFileAsync9) {
33630
+ if (!enabled)
33631
+ return;
33632
+ if (!existsSync66(join66(worktreePath, "package.json")))
33633
+ return;
33634
+ if (!existsSync66(join66(worktreePath, "pnpm-lock.yaml")))
33635
+ return;
33636
+ try {
33637
+ await exec("pnpm", ["-r", "build"], {
33638
+ cwd: worktreePath,
33639
+ timeout: PREBUILD_TIMEOUT_MS,
33640
+ maxBuffer: 16 * 1024 * 1024
33641
+ });
33642
+ } catch (e) {
33643
+ const msg6 = e instanceof Error ? e.message.split("\n")[0] : String(e);
33644
+ events.appendAlert(alertsPath, `[WARN] worktree dist prebuild failed (pnpm -r build): ${msg6} \u2014 continuing; agent will build on demand`);
33645
+ }
33646
+ }
33647
+ function readProjectMapEnabled(repoCwd) {
33648
+ try {
33649
+ const p = join66(repoCwd, ".roll", "policy.yaml");
33650
+ if (!existsSync66(p))
33651
+ return false;
33652
+ return parsePolicy(readFileSync62(p, "utf8")).loopSafety.projectMap === true;
33653
+ } catch {
33654
+ return false;
33655
+ }
33656
+ }
33657
+ var PROJECT_MAP_MAX_CHARS = 1800;
33658
+ var PROJECT_MAP_MAX_TOPLEVEL = 24;
33659
+ var PROJECT_MAP_MAX_RELEVANT = 12;
33660
+ var PROJECT_MAP_SKIP = /* @__PURE__ */ new Set([
33661
+ ".git",
33662
+ "node_modules",
33663
+ ".vite",
33664
+ "dist",
33665
+ "coverage",
33666
+ ".turbo",
33667
+ ".cache",
33668
+ ".DS_Store"
33669
+ ]);
33670
+ var PROJECT_MAP_CONTAINERS = /* @__PURE__ */ new Set(["packages", "apps", "skills"]);
33671
+ function shallowDirents(dir) {
33672
+ return readdirSync28(dir, { withFileTypes: true });
33673
+ }
33674
+ function shallowList(dir, limit) {
33675
+ try {
33676
+ const out3 = [];
33677
+ for (const ent of shallowDirents(dir)) {
33678
+ if (PROJECT_MAP_SKIP.has(ent.name))
33679
+ continue;
33680
+ out3.push(ent.isDirectory() ? `${ent.name}/` : ent.name);
33681
+ }
33682
+ out3.sort((a, b) => a.localeCompare(b));
33683
+ return out3.slice(0, limit);
33684
+ } catch {
33685
+ return [];
33686
+ }
33687
+ }
33688
+ function findRelevantFiles(root, token, limit) {
33689
+ const needle = token.toLowerCase();
33690
+ const hits = [];
33691
+ let scanned = 0;
33692
+ const maxScan = 4e3;
33693
+ const walk2 = (dir, rel) => {
33694
+ if (hits.length >= limit || scanned >= maxScan)
33695
+ return;
33696
+ let ents;
33697
+ try {
33698
+ ents = shallowDirents(dir);
33699
+ } catch {
33700
+ return;
33701
+ }
33702
+ for (const ent of ents) {
33703
+ if (hits.length >= limit || scanned >= maxScan)
33704
+ return;
33705
+ if (PROJECT_MAP_SKIP.has(ent.name))
33706
+ continue;
33707
+ scanned += 1;
33708
+ const childRel = rel === "" ? ent.name : `${rel}/${ent.name}`;
33709
+ if (ent.isDirectory()) {
33710
+ walk2(join66(dir, ent.name), childRel);
33711
+ } else if (childRel.toLowerCase().includes(needle)) {
33712
+ hits.push(childRel);
33713
+ }
33714
+ }
33715
+ };
33716
+ walk2(root, "");
33717
+ hits.sort((a, b) => a.localeCompare(b));
33718
+ return hits.slice(0, limit);
33719
+ }
33720
+ function buildProjectMap(worktreePath, storyId) {
33721
+ const top = shallowList(worktreePath, PROJECT_MAP_MAX_TOPLEVEL);
33722
+ if (top.length === 0)
33723
+ return "";
33724
+ const lines2 = ["[\u9879\u76EE\u5730\u56FE / project map]", "\u7ED3\u6784 / structure:"];
33725
+ for (const name of top) {
33726
+ lines2.push(` ${name}`);
33727
+ const bare = name.replace(/\/$/, "");
33728
+ if (name.endsWith("/") && PROJECT_MAP_CONTAINERS.has(bare)) {
33729
+ for (const child of shallowList(join66(worktreePath, bare), PROJECT_MAP_MAX_TOPLEVEL)) {
33730
+ lines2.push(` ${child}`);
33731
+ }
33732
+ }
33733
+ }
33734
+ const token = (storyId ?? "").trim();
33735
+ if (token.length >= 3) {
33736
+ const relevant = findRelevantFiles(worktreePath, token, PROJECT_MAP_MAX_RELEVANT);
33737
+ if (relevant.length > 0) {
33738
+ lines2.push(`\u672C\u5361\u76F8\u5173\u6587\u4EF6 / files matching ${token}:`);
33739
+ for (const f of relevant)
33740
+ lines2.push(` ${f}`);
33741
+ }
33742
+ }
33743
+ let map = lines2.join("\n");
33744
+ if (map.length > PROJECT_MAP_MAX_CHARS) {
33745
+ map = `${map.slice(0, PROJECT_MAP_MAX_CHARS - 3)}...`;
33746
+ }
33747
+ return map;
33748
+ }
33749
+ function maybeInjectProjectMap(skillBody, worktreePath, enabled, storyId) {
33750
+ if (!enabled)
33751
+ return skillBody;
33752
+ const map = buildProjectMap(worktreePath, storyId);
33753
+ if (map === "")
33754
+ return skillBody;
33755
+ return `${map}
33756
+
33757
+ ${skillBody}`;
33758
+ }
33358
33759
  function skillsEntryCount(worktreePath) {
33359
33760
  const dir = join66(worktreePath, "skills");
33360
33761
  if (!existsSync66(dir))
@@ -34219,7 +34620,13 @@ function readField(path, re) {
34219
34620
  }
34220
34621
  return void 0;
34221
34622
  }
34623
+ var RUN_ONCE_USAGE = "Usage: roll loop run-once [--dry-run]\n Run ONE loop cycle now: pick a Todo card, build it through TCR, run the\n gates (attest + peer), and publish a PR. Exits when the cycle terminates.\n --dry-run Print the command plan only \u2014 no git / gh / agent side effects.\n\u7ACB\u5373\u8DD1\u4E00\u4E2A loop \u5468\u671F:\u9009\u4E00\u5F20 Todo \u5361,\u7ECF TCR \u5EFA\u9020,\u8FC7\u95F8(\u9A8C\u6536+\u540C\u884C\u8BC4\u5BA1),\u53D1 PR\u3002\n --dry-run \u53EA\u6253\u5370\u547D\u4EE4\u8BA1\u5212\u2014\u2014\u4E0D\u52A8 git / gh / agent\u3002";
34222
34624
  async function loopRunOnceCommand(args) {
34625
+ if (args.includes("--help") || args.includes("-h")) {
34626
+ process.stdout.write(`${RUN_ONCE_USAGE}
34627
+ `);
34628
+ return 0;
34629
+ }
34223
34630
  const dryRun = args.includes("--dry-run");
34224
34631
  const id = await projectIdentity();
34225
34632
  const cycleId = makeCycleId();
@@ -34339,6 +34746,12 @@ loop run-once: \u627E\u4E0D\u5230 roll-loop SKILL.md \u2014 \u62D2\u7EDD\u76F2\u
34339
34746
  if (result.terminal === "published") {
34340
34747
  process.stdout.write("loop run-once: delivery published \u2014 PR open, merge pending (PR loop merges; backfill credits on merge evidence)\nloop run-once: \u4EA4\u4ED8\u5DF2\u53D1\u5E03\u2014\u2014PR \u5DF2\u5F00,\u7B49\u5F85\u5408\u5E76(PR loop \u8D1F\u8D23\u5408\u5E76;\u5408\u5E76\u8BC1\u636E\u843D\u5730\u540E\u7531\u56DE\u586B\u8BB0\u8D26)\n");
34341
34748
  }
34749
+ if (result.terminal === "local") {
34750
+ const storyId = (result.state?.ctx?.storyId ?? "").trim();
34751
+ announceReport(id.path, id.slug, storyId);
34752
+ resetConsecutiveFails(id.path);
34753
+ process.stdout.write("loop run-once: gates passed but publish did not complete \u2014 work committed locally on the branch, not published (unpublished, not a failure)\nloop run-once: \u95F8\u5DF2\u901A\u8FC7\u4F46\u672A\u5B8C\u6210\u53D1\u5E03\u2014\u2014\u5DE5\u4F5C\u5DF2\u5728\u5206\u652F\u4E0A\u672C\u5730\u63D0\u4EA4,\u5C1A\u672A\u53D1\u5E03(\u672A\u53D1\u5E03,\u975E\u5931\u8D25)\n");
34754
+ }
34342
34755
  const isFail = result.terminal === "failed" || result.terminal === "blocked";
34343
34756
  if (isFail) {
34344
34757
  if (await isOffline()) {
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "@seanyao/roll",
3
- "version": "3.617.1",
3
+ "version": "3.617.2",
4
4
  "description": "Roll — Roll out features with AI agents",
5
5
  "packageManager": "pnpm@11.1.3",
6
6
  "scripts": {
7
7
  "test": "bash scripts/test-ts.sh",
8
+ "lint:story-ids": "node scripts/lint-story-ids.mjs",
8
9
  "test:e2e": "pnpm -r build && pnpm --filter @roll/cli exec vitest run test/critical-flows.e2e.test.ts",
9
10
  "test:cov": "pnpm -r exec vitest run --coverage.enabled --coverage.provider=v8 --coverage.include='src/**' --coverage.reporter=text-summary --coverage.reporter=text",
10
11
  "bundle": "node scripts/bundle.mjs",