@seanyao/roll 2026.516.1 → 2026.517.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # Changelog
2
2
 
3
+ ## v2026.517.1
4
+
5
+ - **New**: loop 自动修复 story 引入的 CI 红 — 不再每次 CI 红都停下等人,修不好才写 ALERT `[loop]`
6
+
3
7
  ## v2026.515.1
4
8
 
5
9
  - **New**: `roll brief` / `roll dream` 生成文档后自动提交推送 — 每次晨报和夜检不再需要手动 commit `[loop]`
@@ -170,7 +174,6 @@ Roll 从「技能编排工具」变成了「自主执行系统」——三个新
170
174
 
171
175
  ## 2026.05.06
172
176
  - **Added**: OpenCode 集成 — 检测 opencode 环境,自动同步全局 AGENTS.md 规则文件
173
- - **Added**: roll-bipo-onboard 技能 — 新员工入职引导流程技能,含 bats 测试
174
177
  - **Improved**: Git 提交归属 — 用 Co-Authored-By trailer 替代 [client] 前缀,更标准的多 AI 工具归属方式
175
178
  - **Improved**: AGENTS.md 加入 Scope Gate — 防止技能执行时越界修改不相关文件
176
179
 
package/bin/roll CHANGED
@@ -4,7 +4,7 @@ set -euo pipefail
4
4
  # Roll — AI Agent Convention Manager
5
5
  # Single source of truth for how all AI coding agents behave.
6
6
 
7
- VERSION="2026.516.1"
7
+ VERSION="2026.517.1"
8
8
  ROLL_HOME="${ROLL_HOME:-${HOME}/.roll}"
9
9
  ROLL_CONFIG="${ROLL_HOME}/config.yaml"
10
10
  ROLL_GLOBAL="${ROLL_HOME}/conventions/global"
@@ -1493,45 +1493,19 @@ _peer_call() {
1493
1493
  local out_file
1494
1494
  out_file=$(mktemp)
1495
1495
  local cmd_str
1496
- case "$to" in
1497
- claude) cmd_str="claude -p --output-format text $(printf %q "$prompt")" ;;
1498
- kimi) cmd_str="kimi --quiet -p $(printf %q "$prompt")" ;;
1499
- pi) cmd_str="pi -p $(printf %q "$prompt")" ;;
1500
- deepseek) cmd_str="deepseek $(printf %q "$prompt")" ;;
1501
- codex) cmd_str="codex exec --json --output-last-message $(printf %q "$prompt")" ;;
1502
- opencode) cmd_str="opencode run $(printf %q "$prompt")" ;;
1503
- *)
1504
- err "Unsupported peer: $to 不支持的 peer: $to"
1505
- return 1 ;;
1506
- esac
1496
+ cmd_str=$(_agent_cmd_str "$to" peer "$prompt") || {
1497
+ err "Unsupported peer: $to 不支持的 peer: $to"
1498
+ return 1
1499
+ }
1507
1500
  _peer_dispatch_in_tmux "$session" "$cmd_str" "$out_file" "$stderr_log" "$call_timeout"
1508
1501
  output="$(cat "$out_file" 2>/dev/null || true)"
1509
1502
  rm -f "$out_file"
1510
1503
  else
1511
- case "$to" in
1512
- claude)
1513
- output="$(claude -p --output-format text "$prompt" 2>"$stderr_log" || true)"
1514
- ;;
1515
- kimi)
1516
- output="$(kimi --quiet -p "$prompt" 2>"$stderr_log" || true)"
1517
- ;;
1518
- pi)
1519
- output="$(pi -p "$prompt" 2>"$stderr_log" || true)"
1520
- ;;
1521
- deepseek)
1522
- output="$(deepseek "$prompt" 2>"$stderr_log" || true)"
1523
- ;;
1524
- codex)
1525
- output="$(codex exec --json --output-last-message "$prompt" 2>"$stderr_log" || true)"
1526
- ;;
1527
- opencode)
1528
- output="$(opencode run "$prompt" 2>"$stderr_log" || true)"
1529
- ;;
1530
- *)
1531
- err "Unsupported peer: $to 不支持的 peer: $to"
1532
- return 1
1533
- ;;
1534
- esac
1504
+ _agent_argv "$to" peer "$prompt" || {
1505
+ err "Unsupported peer: $to 不支持的 peer: $to"
1506
+ return 1
1507
+ }
1508
+ output="$("${_AGENT_ARGV[@]}" 2>"$stderr_log" || true)"
1535
1509
  fi
1536
1510
 
1537
1511
  printf '%s\n' "$output"
@@ -1884,21 +1858,57 @@ _parse_review_verdict() {
1884
1858
  echo "${type}${reason:+:${reason}}"
1885
1859
  }
1886
1860
 
1861
+ # REFACTOR-017: single source of truth for agent invocation argv.
1862
+ # Sets the global _AGENT_ARGV array to the command + args for (agent, mode, prompt).
1863
+ # Modes:
1864
+ # text — structured text (claude --output-format text; codex exec)
1865
+ # plain — default output (claude -p; codex exec)
1866
+ # peer — peer protocol (claude --output-format text; codex --json --output-last-message)
1867
+ # Returns 1 on unknown agent. Adding a new agent only needs an entry here.
1868
+ _agent_argv() {
1869
+ local agent="$1" mode="$2" prompt="$3"
1870
+ case "$agent" in
1871
+ claude)
1872
+ case "$mode" in
1873
+ text|peer) _AGENT_ARGV=(claude -p --output-format text "$prompt") ;;
1874
+ *) _AGENT_ARGV=(claude -p "$prompt") ;;
1875
+ esac ;;
1876
+ kimi) _AGENT_ARGV=(kimi --quiet -p "$prompt") ;;
1877
+ deepseek) _AGENT_ARGV=(deepseek "$prompt") ;;
1878
+ pi) _AGENT_ARGV=(pi -p "$prompt") ;;
1879
+ codex)
1880
+ case "$mode" in
1881
+ peer) _AGENT_ARGV=(codex exec --json --output-last-message "$prompt") ;;
1882
+ *) _AGENT_ARGV=(codex exec "$prompt") ;;
1883
+ esac ;;
1884
+ opencode) _AGENT_ARGV=(opencode run "$prompt") ;;
1885
+ *) return 1 ;;
1886
+ esac
1887
+ }
1888
+
1889
+ # Build a printf %q-escaped command string for (agent, mode, prompt).
1890
+ # Used where the command must be passed as a string (e.g. tmux send-keys).
1891
+ _agent_cmd_str() {
1892
+ _agent_argv "$@" || return 1
1893
+ local i out
1894
+ printf -v out '%q' "${_AGENT_ARGV[0]}"
1895
+ for ((i = 1; i < ${#_AGENT_ARGV[@]}; i++)); do
1896
+ printf -v out '%s %q' "$out" "${_AGENT_ARGV[i]}"
1897
+ done
1898
+ printf '%s' "$out"
1899
+ }
1900
+
1887
1901
  _agent_run_skill() {
1888
1902
  local skill="$1"
1889
1903
  local agent; agent=$(_project_agent)
1890
1904
  local skill_file="${ROLL_HOME}/skills/${skill}/SKILL.md"
1891
1905
  [[ -f "$skill_file" ]] || { err "Skill not found: ${skill}"; return 1; }
1892
1906
  local content; content=$(_skill_content "$skill_file")
1893
- case "$agent" in
1894
- claude) claude -p --output-format text "$content" ;;
1895
- kimi) kimi --quiet -p "$content" ;;
1896
- deepseek) deepseek "$content" ;;
1897
- pi) pi -p "$content" ;;
1898
- codex) codex exec "$content" ;;
1899
- opencode) opencode run "$content" ;;
1900
- *) err "Unknown agent '${agent}'. Run: roll agent use <claude|kimi|deepseek|pi|codex|opencode>"; return 1 ;;
1901
- esac
1907
+ _agent_argv "$agent" text "$content" || {
1908
+ err "Unknown agent '${agent}'. Run: roll agent use <claude|kimi|deepseek|pi|codex|opencode>"
1909
+ return 1
1910
+ }
1911
+ "${_AGENT_ARGV[@]}"
1902
1912
  }
1903
1913
 
1904
1914
  cmd_review_pr() {
@@ -1958,15 +1968,8 @@ cmd_review_pr() {
1958
1968
  local agent; agent=$(_project_agent)
1959
1969
  local output
1960
1970
  info "Reviewing PR #${pr_number} with ${agent}..."
1961
- case "$agent" in
1962
- claude) output=$(claude -p --output-format text "$prompt" 2>/dev/null) ;;
1963
- kimi) output=$(kimi --quiet -p "$prompt" 2>/dev/null) ;;
1964
- deepseek) output=$(deepseek "$prompt" 2>/dev/null) ;;
1965
- pi) output=$(pi -p "$prompt" 2>/dev/null) ;;
1966
- codex) output=$(codex exec "$prompt" 2>/dev/null) ;;
1967
- opencode) output=$(opencode run "$prompt" 2>/dev/null) ;;
1968
- *) err "Unknown agent '${agent}'"; return 1 ;;
1969
- esac
1971
+ _agent_argv "$agent" text "$prompt" || { err "Unknown agent '${agent}'"; return 1; }
1972
+ output=$("${_AGENT_ARGV[@]}" 2>/dev/null)
1970
1973
 
1971
1974
  echo "$output"
1972
1975
 
@@ -2046,9 +2049,9 @@ cmd_agent() {
2046
2049
  # ═══════════════════════════════════════════════════════════════════════════════
2047
2050
 
2048
2051
  _LOOP_TAG="# roll-loop"
2049
- _SHARED_ROOT="${HOME}/.shared/roll"
2050
- _LOOP_STATE="${HOME}/.shared/roll/loop/state.yaml"
2051
- _LOOP_ALERT="${HOME}/.shared/roll/loop/ALERT.md"
2052
+ : "${_SHARED_ROOT:=${HOME}/.shared/roll}"
2053
+ : "${_LOOP_STATE:=${_SHARED_ROOT}/loop/state.yaml}"
2054
+ : "${_LOOP_ALERT:=${_SHARED_ROOT}/loop/ALERT.md}"
2052
2055
  _LOOP_RUNS="${HOME}/.shared/roll/loop/runs.jsonl"
2053
2056
  _LOOP_MUTE_FILE="${HOME}/.shared/roll/mute"
2054
2057
  _LAUNCHD_DIR="${HOME}/Library/LaunchAgents"
@@ -2168,7 +2171,11 @@ _write_loop_runner_script() {
2168
2171
  # stream-json enables realtime streaming; loop-fmt.py humanizes the events.
2169
2172
  local fmt_script="${ROLL_PKG_DIR}/lib/loop-fmt.py"
2170
2173
  local roll_bin="${ROLL_PKG_DIR}/bin/roll"
2171
- local cmd_verbose="${cmd/claude -p/claude -p --verbose --output-format stream-json}"
2174
+ # FIX-041: loop cycle is autonomous — permission prompts and sandbox path
2175
+ # restrictions only cause the cycle to burn turns asking for approvals
2176
+ # it cannot receive. Bypass all permission checks for the inner claude
2177
+ # invocation. Worktree isolation contains the blast radius.
2178
+ local cmd_verbose="${cmd/claude -p/claude -p --verbose --dangerously-skip-permissions --output-format stream-json}"
2172
2179
  # US-AUTO-037: strip leading `cd "<path>" && ` (callers like
2173
2180
  # _install_launchd_plists prepend it). The runner now manages cwd itself
2174
2181
  # — pointing at the worktree when isolation succeeds, project_path otherwise.
@@ -2220,6 +2227,35 @@ WT="\$(_worktree_path "${slug}" "cycle-\${CYCLE_ID}")"
2220
2227
  BRANCH="loop/cycle-\${CYCLE_ID}"
2221
2228
  _USE_WORKTREE=0
2222
2229
  cd "${project_path}" 2>/dev/null || true
2230
+ # FIX-040: orphan worktree recovery — scan for worktrees left by previous failed
2231
+ # cycles (publish failed or inner script was SIGKILL'd). Attempt to publish each
2232
+ # before starting the new cycle. Glob is chronological via timestamp in name.
2233
+ for _orphan_wt in "\${_SHARED_ROOT}/worktrees/${slug}-cycle-"*; do
2234
+ [ -d "\$_orphan_wt" ] || continue
2235
+ # Confirm it's a real worktree directory (not glob literal when no matches)
2236
+ [ -d "\${_orphan_wt}/.git" ] || [ -f "\${_orphan_wt}/.git" ] || continue
2237
+ _orphan_branch=\$(cd "\$_orphan_wt" && git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "")
2238
+ [ -z "\$_orphan_branch" ] && continue
2239
+ _orphan_commits=\$(cd "\$_orphan_wt" && git rev-list --count origin/main..HEAD 2>/dev/null || echo 0)
2240
+ if [ "\$_orphan_commits" -gt 0 ]; then
2241
+ echo "[loop] FIX-040: recovering orphan worktree \$_orphan_wt (branch \$_orphan_branch, \${_orphan_commits} commits)"
2242
+ _orphan_ok=0
2243
+ if ( cd "\$_orphan_wt" && _loop_is_doc_only_change ); then
2244
+ ( cd "\$_orphan_wt" && _loop_publish_doc_pr "\$_orphan_branch" "doc: recover orphan \${_orphan_branch}" ) && _orphan_ok=1
2245
+ else
2246
+ ( cd "\$_orphan_wt" && _loop_publish_pr "\$_orphan_branch" "recover orphan \${_orphan_branch}" ) && _orphan_ok=1
2247
+ fi
2248
+ if [ "\$_orphan_ok" -eq 1 ]; then
2249
+ _worktree_cleanup "\$_orphan_wt" "\$_orphan_branch"
2250
+ echo "[loop] FIX-040: orphan recovered and cleaned: \$_orphan_branch"
2251
+ else
2252
+ echo "[loop] FIX-040: orphan recovery publish failed for \$_orphan_branch — leaving preserved"
2253
+ fi
2254
+ else
2255
+ echo "[loop] FIX-040: orphan worktree \$_orphan_wt has no commits; cleaning up"
2256
+ _worktree_cleanup "\$_orphan_wt" "\$_orphan_branch"
2257
+ fi
2258
+ done
2223
2259
  # US-AUTO-038: snapshot orphan claude/* branches before claude runs so the
2224
2260
  # post-claude cleanup can diff and delete only this session's additions.
2225
2261
  CLAUDE_BRANCH_SNAPSHOT="\$(_claude_remote_snapshot "${project_path}")"
@@ -2229,8 +2265,12 @@ if _worktree_fetch_origin main \\
2229
2265
  _worktree_submodule_init "\$WT" 2>/dev/null || true
2230
2266
  echo "[loop] cycle \${CYCLE_ID}: worktree \$WT on \$BRANCH"
2231
2267
  else
2232
- echo "[loop] cycle \${CYCLE_ID}: worktree setup failed; running in main tree (no isolation)"
2233
- WT="${project_path}"
2268
+ # P3 fix: skip the cycle entirely when worktree isolation fails.
2269
+ # --dangerously-skip-permissions is only safe paired with worktree isolation;
2270
+ # falling back to the main tree without isolation is unacceptable.
2271
+ _worktree_alert "cycle \${CYCLE_ID}: worktree setup failed — skipping cycle to avoid running without isolation"
2272
+ echo "[loop] cycle \${CYCLE_ID}: worktree setup failed; skipping cycle (no isolation)"
2273
+ exit 0
2234
2274
  fi
2235
2275
 
2236
2276
  FMT="${fmt_script}"
@@ -2282,12 +2322,34 @@ if [ "\$_USE_WORKTREE" = "1" ]; then
2282
2322
  _worktree_cleanup "\$WT" "\$BRANCH"
2283
2323
  echo "[loop] cycle \${CYCLE_ID}: gh unavailable; merged via ff and cleaned up"
2284
2324
  else
2285
- _worktree_alert "cycle \${CYCLE_ID}: gh unavailable AND merge_back failed; worktree preserved at \$WT"
2286
- echo "[loop] cycle \${CYCLE_ID}: gh+merge_back both failed; worktree preserved at \$WT"
2325
+ # FIX-039: gh unavailable + merge_back failed push orphan branch+tag to origin
2326
+ # as final safety net so code is never local-only before worktree cleanup.
2327
+ _orphan_tag="loop-orphan-\${CYCLE_ID}"
2328
+ if ( cd "\$WT" && git push origin "\$BRANCH" 2>/dev/null \
2329
+ && git tag "\$_orphan_tag" 2>/dev/null \
2330
+ && git push origin "\$_orphan_tag" 2>/dev/null ); then
2331
+ _worktree_cleanup "\$WT" "\$BRANCH"
2332
+ _worktree_alert "cycle \${CYCLE_ID}: gh+merge_back failed; FIX-039 pushed orphan+tag \${_orphan_tag}; worktree cleaned"
2333
+ echo "[loop] cycle \${CYCLE_ID}: FIX-039: orphan branch+tag \${_orphan_tag} pushed; worktree cleaned"
2334
+ else
2335
+ _worktree_alert "cycle \${CYCLE_ID}: gh+merge_back+push all failed; worktree preserved at \$WT"
2336
+ echo "[loop] cycle \${CYCLE_ID}: all publish paths failed; worktree preserved at \$WT"
2337
+ fi
2287
2338
  fi
2288
2339
  else
2289
- _worktree_alert "cycle \${CYCLE_ID}: PR publish failed; worktree preserved at \$WT (branch \$BRANCH)"
2290
- echo "[loop] cycle \${CYCLE_ID}: PR publish failed; worktree preserved at \$WT"
2340
+ # FIX-039: PR publish failed push orphan branch+tag to origin as safety net.
2341
+ # (_loop_publish_pr may have already pushed the branch; git push is idempotent.)
2342
+ _orphan_tag="loop-orphan-\${CYCLE_ID}"
2343
+ if ( cd "\$WT" && git push origin "\$BRANCH" 2>/dev/null \
2344
+ && git tag "\$_orphan_tag" 2>/dev/null \
2345
+ && git push origin "\$_orphan_tag" 2>/dev/null ); then
2346
+ _worktree_cleanup "\$WT" "\$BRANCH"
2347
+ _worktree_alert "cycle \${CYCLE_ID}: PR publish failed; FIX-039 pushed orphan+tag \${_orphan_tag}; worktree cleaned"
2348
+ echo "[loop] cycle \${CYCLE_ID}: FIX-039: orphan branch+tag \${_orphan_tag} pushed; worktree cleaned"
2349
+ else
2350
+ _worktree_alert "cycle \${CYCLE_ID}: PR publish failed; worktree preserved at \$WT (branch \$BRANCH)"
2351
+ echo "[loop] cycle \${CYCLE_ID}: PR publish failed; worktree preserved at \$WT"
2352
+ fi
2291
2353
  fi
2292
2354
  fi
2293
2355
  else
@@ -2379,7 +2441,7 @@ fi
2379
2441
  echo "\$\$" > "\$LOCK"
2380
2442
  trap 'rm -f "\$LOCK"' EXIT
2381
2443
  if command -v tmux >/dev/null 2>&1; then
2382
- tmux list-sessions -F "#{session_name}" 2>/dev/null | grep "^roll-loop-" | while read _s; do
2444
+ tmux list-sessions -F "#{session_name}" 2>/dev/null | grep "^roll-loop-${slug}\$" | while read _s; do
2383
2445
  tmux kill-session -t "\$_s" 2>/dev/null || true
2384
2446
  done
2385
2447
  tmux new-session -d -s "\$SESSION" -x 200 -y 50 "bash \"\$INNER_SCRIPT\""
@@ -2520,17 +2582,19 @@ _install_launchd_plists() {
2520
2582
  _agent_skill_cmd() {
2521
2583
  local skill_path="$1"
2522
2584
  local agent; agent=$(_project_agent)
2523
- # Strip YAML frontmatter inline for cron commands
2524
2585
  local strip="awk 'NR==1 && /^---$/{skip=1;next} skip && /^---$/{skip=0;next} !skip{print}' '${skill_path}'"
2525
- case "$agent" in
2526
- claude) local _bin; _bin=$(command -v claude 2>/dev/null || echo "claude"); echo "${_bin} -p \"\$(${strip})\"" ;;
2527
- kimi) echo "kimi --quiet -p \"\$(${strip})\"" ;;
2528
- deepseek) echo "deepseek \"\$(${strip})\"" ;;
2529
- pi) echo "pi -p \"\$(${strip})\"" ;;
2530
- codex) echo "codex exec \"\$(${strip})\"" ;;
2531
- opencode) echo "opencode run \"\$(${strip})\"" ;;
2532
- *) err "Unknown agent '${agent}'. Run: roll agent use <claude|kimi|deepseek|pi|codex|opencode>"; return 1 ;;
2533
- esac
2586
+ _agent_argv "$agent" plain "__PROMPT__" || {
2587
+ err "Unknown agent '${agent}'. Run: roll agent use <claude|kimi|deepseek|pi|codex|opencode>"
2588
+ return 1
2589
+ }
2590
+ # In cron context, use absolute claude path so a fresh shell can find it.
2591
+ [[ "$agent" == "claude" ]] && _AGENT_ARGV[0]="$(command -v claude 2>/dev/null || echo claude)"
2592
+ # Drop the prompt sentinel (always last), re-emit head args + quoted $(strip).
2593
+ local out="${_AGENT_ARGV[0]}" i prompt_idx=$((${#_AGENT_ARGV[@]} - 1))
2594
+ for ((i = 1; i < prompt_idx; i++)); do
2595
+ out+=" ${_AGENT_ARGV[i]}"
2596
+ done
2597
+ echo "${out} \"\$(${strip})\""
2534
2598
  }
2535
2599
 
2536
2600
  cmd_loop() {
@@ -2651,15 +2715,46 @@ _loop_off() {
2651
2715
  ok "Loop disabled 已停用"
2652
2716
  }
2653
2717
 
2718
+ _loop_is_active() {
2719
+ # Three-level liveness probe used by FIX-037 heal and `roll loop now`.
2720
+ # Returns 0 if any signal says the cycle is alive, 1 if all signals are dead.
2721
+ # Heartbeat is primary (FIX-038); LOCK PID and tmux session are fallbacks.
2722
+ # ROLL_HEARTBEAT_TIMEOUT (default 1800s) matches the outer heredoc's threshold.
2723
+ local slug="${1:?slug required}"
2724
+ local timeout="${ROLL_HEARTBEAT_TIMEOUT:-1800}"
2725
+ local hb_file="${_SHARED_ROOT}/loop/.heartbeat-${slug}"
2726
+ if [[ -f "$hb_file" ]]; then
2727
+ local ts; ts=$(cat "$hb_file" 2>/dev/null || echo "")
2728
+ if [[ "$ts" =~ ^[0-9]+$ ]]; then
2729
+ local age=$(( $(date -u +%s) - ts ))
2730
+ [[ $age -lt $timeout ]] && return 0
2731
+ fi
2732
+ fi
2733
+ local lock="${_SHARED_ROOT}/loop/.LOCK-${slug}"
2734
+ if [[ -f "$lock" ]]; then
2735
+ local pid; pid=$(head -1 "$lock" 2>/dev/null || echo "")
2736
+ [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null && return 0
2737
+ fi
2738
+ command -v tmux >/dev/null 2>&1 && tmux has-session -t "roll-loop-${slug}" 2>/dev/null && return 0
2739
+ return 1
2740
+ }
2741
+
2654
2742
  _loop_now() {
2743
+ local project_path; project_path=$(pwd -P)
2744
+ local slug; slug=$(_project_slug "$project_path")
2745
+ # Manual `roll loop now` must not bypass FIX-037 heal: if state says running
2746
+ # but no live signal exists, this trigger is the canonical recovery point.
2655
2747
  if [[ -f "$_LOOP_STATE" ]] && grep -q "status: running" "$_LOOP_STATE" 2>/dev/null; then
2656
- warn "Loop already running loop 正在运行中"; return 0
2748
+ if _loop_is_active "$slug"; then
2749
+ warn "Loop already running loop 正在运行中"; return 0
2750
+ fi
2751
+ info "Stale running state detected — healing before new cycle 检测到孤儿状态,正在修复..."
2752
+ printf "status: idle\n" > "$_LOOP_STATE"
2753
+ rm -f "${_SHARED_ROOT}/loop/.LOCK-${slug}" 2>/dev/null || true
2657
2754
  fi
2658
2755
  # Invoke the SAME runner script that launchd would invoke — same tmux,
2659
2756
  # same --verbose, same LOCK, same auto-attach popup. ROLL_LOOP_FORCE
2660
2757
  # bypasses only the active-window check (manual triggers aren't time-gated).
2661
- local project_path; project_path=$(pwd -P)
2662
- local slug; slug=$(_project_slug "$project_path")
2663
2758
  local runner="${_SHARED_ROOT}/loop/run-${slug}.sh"
2664
2759
  if [[ ! -f "$runner" ]]; then
2665
2760
  err "Runner script not found: ${runner}"
@@ -2832,6 +2927,7 @@ _loop_reset() {
2832
2927
  else
2833
2928
  info "No loop state to clear 无 loop 状态可清除"
2834
2929
  fi
2930
+ rm -rf "$(_loop_heal_dir)"
2835
2931
  }
2836
2932
 
2837
2933
  # Suppress the auto-attach popup. When the marker file exists, runner scripts
@@ -3223,6 +3319,42 @@ EOF
3223
3319
  return 1
3224
3320
  }
3225
3321
 
3322
+ _loop_heal_dir() {
3323
+ printf '%s\n' "${ROLL_LOOP_DIR:-${HOME}/.shared/roll/loop}/heal"
3324
+ }
3325
+
3326
+ # Bounded CI self-heal gate. Called by the loop SKILL when the
3327
+ # post-build CI check goes red. Counter is per-story, persisted under
3328
+ # $ROLL_LOOP_DIR/heal/<story-id>.count, so retries survive cycle boundaries.
3329
+ #
3330
+ # Exit 0: another heal attempt is allowed (counter incremented). Caller should
3331
+ # invoke roll-fix with the failure summary, then re-run the CI gate.
3332
+ # Exit 1: heal disabled (ROLL_LOOP_NO_HEAL=1) or exhausted (>= ROLL_LOOP_HEAL_MAX).
3333
+ # Caller should write ALERT and stop.
3334
+ _loop_self_heal_ci() {
3335
+ local story_id="$1"
3336
+ [[ -z "$story_id" ]] && return 1
3337
+ [[ "${ROLL_LOOP_NO_HEAL:-0}" == "1" ]] && return 1
3338
+ local max="${ROLL_LOOP_HEAL_MAX:-2}"
3339
+ local dir; dir=$(_loop_heal_dir)
3340
+ local counter="${dir}/${story_id}.count"
3341
+ mkdir -p "$dir"
3342
+ local current; current=$(<"$counter") 2>/dev/null || current=0
3343
+ [[ "$current" =~ ^[0-9]+$ ]] || current=0
3344
+ [[ "$current" -ge "$max" ]] && return 1
3345
+ echo $((current + 1)) > "$counter"
3346
+ return 0
3347
+ }
3348
+
3349
+ # Reset per-story heal counter. Called when CI eventually turns green or by
3350
+ # `roll loop reset`. Idempotent.
3351
+ _loop_clear_heal_state() {
3352
+ local story_id="$1"
3353
+ [[ -z "$story_id" ]] && return 0
3354
+ rm -f "$(_loop_heal_dir)/${story_id}.count" 2>/dev/null
3355
+ return 0
3356
+ }
3357
+
3226
3358
  # Verify TCR rhythm after a story completes. Returns 0 if ok, 1 if no TCR commits.
3227
3359
  # On failure: reverts story in BACKLOG.md to 📋 Todo and writes ALERT.
3228
3360
  _loop_enforce_tcr() {
@@ -4245,16 +4377,6 @@ p.write_text("".join(out))
4245
4377
  PYEOF
4246
4378
  }
4247
4379
 
4248
- cmd_release() {
4249
- local pkg; pkg=$(node -p "require('./package.json').name" 2>/dev/null || true)
4250
- if [[ "$pkg" == "@seanyao/roll" ]]; then
4251
- err "roll 自身发版请用 scripts/release.sh(npm publish 需要人工 2FA)"
4252
- echo " Run: scripts/release.sh"
4253
- exit 1
4254
- fi
4255
- _agent_run_skill "roll-release"
4256
- }
4257
-
4258
4380
  # ═══════════════════════════════════════════════════════════════════════════════
4259
4381
  # BACKLOG — show pending tasks / manage status
4260
4382
  # ═══════════════════════════════════════════════════════════════════════════════
@@ -4951,7 +5073,6 @@ usage() {
4951
5073
  echo " backlog defer <pat> [reason] Mark matching items as ⏸ Deferred 标记为已推迟"
4952
5074
  echo " backlog unblock <pat> Restore matching items to 📋 Todo 恢复为待处理"
4953
5075
  echo " agent [use <name>|list] [Config] Per-project agent selection 切换项目 agent"
4954
- echo " release [Publish] Sync changelog + version bump + npm publish 同步日志并发版"
4955
5076
  echo " ci [--wait] [CI] Show or wait for current commit's CI status 查看/等待 CI 状态"
4956
5077
  echo " review-pr <number> [PR Review] AI-powered code review for a PR AI 代码评审"
4957
5078
  echo ""
@@ -4983,7 +5104,6 @@ main() {
4983
5104
  backlog) cmd_backlog "$@" ;;
4984
5105
  alert) cmd_alert "$@" ;;
4985
5106
  agent) cmd_agent "$@" ;;
4986
- release) cmd_release "$@" ;;
4987
5107
  ci) cmd_ci "$@" ;;
4988
5108
  review-pr) cmd_review_pr "$@" ;;
4989
5109
  version|--version|-v) echo "roll v${VERSION}" ;;
@@ -50,7 +50,18 @@
50
50
  `depends-on:` and `manual-only:` functional tags are allowed; `Domain:` annotation tags are not.
51
51
  Technical details and AC go in `docs/features/`.
52
52
  A well-written BACKLOG description can be used directly as a CHANGELOG entry.
53
+ - **Convention layering**: project-level convention files extend the global SOT — see §9 below.
53
54
  - **Done**: Push + CI passes + deployed. Local-only is not done.
55
+ - **Post-push verification** (universal — applies to any push to main, regardless of which
56
+ skill drove the work):
57
+ - After every push, wait for the triggered CI run and verify status (`gh run watch`
58
+ or equivalent). Do not move on, switch tasks, or claim completion until CI is green.
59
+ - Before pushing any new code commit, verify the **previous** code-changing push's CI
60
+ is green. Never stack new code commits on top of a red CI (this is the failure
61
+ mode FIX-026 / `_loop_precheck_ci` exists to prevent for the loop — humans need
62
+ the same discipline). docs-only commits (matching CI `paths-ignore`) don't reset
63
+ the gate either way.
64
+ - If CI is red, the next action is **fix or revert**, not "queue something else".
54
65
  - **Commit message format**:
55
66
  - Format: `<type>: <description>` (Git Hook may auto-prepend type prefix)
56
67
  - Types: `Story N`, `Fix`, `Refactor`, `Docs`, `Chore`
@@ -93,3 +104,41 @@ Confirm each phase clean before proceeding to the next.
93
104
  - **Story details**: `docs/features/` — AC, implementation specs, dependencies
94
105
  - **Design decisions**: `docs/domain/` — DDD models, architecture records
95
106
  - When `docs/domain/` or `docs/features/` don't exist yet, run `$roll-doc` to bootstrap.
107
+
108
+ ## 9. Convention Architecture
109
+
110
+ Roll conventions form a two-layer hierarchy. The **global** layer is the single
111
+ source of truth for **cross-project rules** — rules that apply regardless of
112
+ stack, language, or domain (e.g., BACKLOG row format, identity, TCR rhythm,
113
+ commit message format, scope discipline). The **project** layer carries only
114
+ **project-specific** rules — stack, structure, build commands, domain
115
+ conventions, deploy targets.
116
+
117
+ **The contract:**
118
+
119
+ 1. Every project-level convention file **must declare it extends the global
120
+ counterpart** via a one-line foundation note at the top (e.g., "Extends
121
+ `~/.<agent>/AGENTS.md`" or "Extends AGENTS.md in this directory").
122
+ 2. Project-level files **never duplicate or re-state** cross-project rules.
123
+ When you find yourself wanting to copy a rule down, add a pointer instead.
124
+ 3. Cross-project rules go in `conventions/global/`. Project-specific rules go
125
+ in `conventions/templates/<type>/`. Anything that applies regardless of
126
+ stack belongs upstairs.
127
+
128
+ **Layered file pairs** — each global ↔ project pair must follow the contract:
129
+
130
+ | Global SOT | Project layer | Audience |
131
+ |---|---|---|
132
+ | `conventions/global/AGENTS.md` | `conventions/templates/<type>/AGENTS.md` | All agents |
133
+ | `conventions/global/CLAUDE.md` | `conventions/templates/<type>/CLAUDE.md` | Claude Code |
134
+ | `conventions/global/GEMINI.md` | `conventions/templates/<type>/GEMINI.md` | Gemini CLI |
135
+ | `conventions/global/project_rules.md` | `conventions/templates/<type>/project_rules.md` | Trae IDE |
136
+
137
+ The CLAUDE / GEMINI / project_rules global files themselves declare they
138
+ extend this AGENTS.md, so this section's rules apply transitively to all
139
+ four file families.
140
+
141
+ **Why it matters**: copies drift, pointers don't. When a rule must change, it
142
+ changes in one place and propagates; if it's duplicated, half the copies get
143
+ updated and the others silently lag — which is the failure mode that produced
144
+ this section in the first place.
@@ -1,6 +1,11 @@
1
1
  # Project Conventions — Backend Service
2
2
 
3
3
  > Reference for skills to infer backend project conventions.
4
+ >
5
+ > **Foundation**: extends the shared rules in `~/.<agent>/AGENTS.md`
6
+ > (installed by `roll setup`). For BACKLOG row format, identity, TCR
7
+ > rhythm, and other cross-project rules, see that file's §4. Only
8
+ > project-specific stack / structure / domain rules live below.
4
9
 
5
10
  ## 1. Design
6
11
  - **API**: RESTful `/api/{res}/{id}`. Structured JSON errors.
@@ -1,6 +1,11 @@
1
1
  # Project Conventions — CLI Tool
2
2
 
3
3
  > Reference for skills to infer CLI project conventions.
4
+ >
5
+ > **Foundation**: extends the shared rules in `~/.<agent>/AGENTS.md`
6
+ > (installed by `roll setup`). For BACKLOG row format, identity, TCR
7
+ > rhythm, and other cross-project rules, see that file's §4. Only
8
+ > project-specific stack / structure / domain rules live below.
4
9
 
5
10
  ## 1. Principles
6
11
  - **Lightweight**: No server/frontend.
@@ -1,6 +1,11 @@
1
1
  # Project Conventions — Frontend Only
2
2
 
3
3
  > Reference for skills to infer frontend project conventions.
4
+ >
5
+ > **Foundation**: extends the shared rules in `~/.<agent>/AGENTS.md`
6
+ > (installed by `roll setup`). For BACKLOG row format, identity, TCR
7
+ > rhythm, and other cross-project rules, see that file's §4. Only
8
+ > project-specific stack / structure / domain rules live below.
4
9
 
5
10
  ## 1. Stack
6
11
  - **Core**: React 18+ / TS / Vite / Tailwind / shadcn/ui.
@@ -1,6 +1,11 @@
1
1
  # Project Conventions — Fullstack Web
2
2
 
3
3
  > Reference for skills to infer fullstack project conventions.
4
+ >
5
+ > **Foundation**: extends the shared rules in `~/.<agent>/AGENTS.md`
6
+ > (installed by `roll setup`). For BACKLOG row format, identity, TCR
7
+ > rhythm, and other cross-project rules, see that file's §4. Only
8
+ > project-specific stack / structure / domain rules live below.
4
9
 
5
10
  ## 1. Stack
6
11
  - **Frontend**: React 18+ / TS / Vite / Tailwind / shadcn/ui.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seanyao/roll",
3
- "version": "2026.516.1",
3
+ "version": "2026.517.1",
4
4
  "description": "Roll — Roll out features with AI agents",
5
5
  "scripts": {
6
6
  "test": "bash tests/run.sh"
@@ -19,7 +19,7 @@ After successful Build & Deploy, extracts completed Stories from BACKLOG.md to g
19
19
 
20
20
  - Generating commit messages or PR descriptions — this skill only runs post-deploy
21
21
  - Recording dev diary / moments (use `$roll-notes`)
22
- - Bumping package version (use `$roll-release`)
22
+ - Bumping package version (use the project's release script, e.g. `scripts/release.sh`)
23
23
 
24
24
  ## Workflow
25
25
 
@@ -357,3 +357,75 @@ After successful deploy in `$roll-build` / `$roll-fix`:
357
357
  ```
358
358
 
359
359
  不需要 `**Added**` / `**Fixed**` 前缀,分组标题已经承担了语义分类的职责。
360
+
361
+ ## 8. features.md 重写模式(产品 SOT)
362
+
363
+ US-DOC-008 — `scripts/release.sh` 在 changelog/release-notes 生成完后会再
364
+ 调一次本 skill,请求"整体重写 `docs/features.md`"。这次调用的语义和上面
365
+ 两种完全不同:**不是基于本版 Story 增量**,而是基于**项目整体当前状态**。
366
+
367
+ ### 8.1 何时触发
368
+
369
+ release.sh 完成 changelog/release-notes 写盘后,喂一段以
370
+ `## 当前任务:重写 docs/features.md(Section 8)` 开头的 prompt。
371
+
372
+ ### 8.2 输入
373
+
374
+ prompt 会包含:
375
+ - 当前 `docs/features.md`(可能为空,可能上一版本的)
376
+ - 当前 `BACKLOG.md` 全文(Epic / Feature 分组结构)
377
+ - 当前 `docs/features/` 目录清单
378
+ - 当前版本号
379
+
380
+ ### 8.3 输出契约
381
+
382
+ 把整个 `docs/features.md` 写出来。结构固定为三段:
383
+
384
+ ```
385
+ # Roll — Features
386
+
387
+ > 说明段(保留原文)
388
+
389
+ ---
390
+
391
+ ## ✨ Core Highlights
392
+
393
+ - **<Feature 名>** — 1 句话产品级描述
394
+ - **<Feature 名>** — 1 句话产品级描述
395
+ - ...(3-5 条)
396
+
397
+ ---
398
+
399
+ ## Features by Epic
400
+
401
+ ### <Epic 名>
402
+ - [<Feature 名>](docs/features/<file>.md) — 1 句话描述
403
+ - <Feature 名> — 1 句话描述(缺 deep doc 时不加链接)
404
+
405
+ ### <Epic 名>
406
+ - ...
407
+
408
+ ---
409
+
410
+ ## 维护说明(保留原文)
411
+ ```
412
+
413
+ ### 8.4 规则
414
+
415
+ - **Catalog 必须列出 BACKLOG 中所有 `### Feature:` 出现的 Feature 名**
416
+ (即使没有 deep doc 也要列)
417
+ - Feature 名跟 `docs/features/<file>.md` 文件名一致时,加链接到该 md
418
+ - 没有对应 deep doc 的 Feature,**只写 plain text 不加链接**
419
+ - 描述写 1 句话 **产品视角**:用户能用它做什么,避免实现细节
420
+ - 分组用 BACKLOG 的 Epic 名,原序,不重排
421
+ - Core Highlights 从所有 Features 里挑 3-5 个最能代表产品定位的,
422
+ 描述用 bold 标 Feature 名后接说明;不照搬 catalog 文案
423
+ - **不**写 "Recent Activity" 类区块——features.md 是 SOT,全量当下状态
424
+ - **不**写版本号、不引用 changelog 条目
425
+ - 说明段(顶部 quote)和维护说明(尾部)原文保留,不要重新生成措辞
426
+
427
+ ### 8.5 失败安全
428
+
429
+ 如果 prompt 信息不足(BACKLOG 解析失败等),**不要部分写入** —— 输出原
430
+ 文件内容即可。release.sh 会捕获 stdout 后比较:内容未变就不 stage。
431
+
@@ -17,7 +17,7 @@ description: |
17
17
  > Common Sense defined in the project AGENTS.md.
18
18
 
19
19
  Owner-facing digest of autonomous agent activity. Gives the human everything
20
- needed to decide whether to run `roll-release` — without having to read every
20
+ needed to decide whether to cut a new release — without having to read every
21
21
  commit or diff.
22
22
 
23
23
  ## Distinct from roll-.changelog
@@ -7,7 +7,7 @@ description: |
7
7
  Actions), scans BACKLOG.md for 📋 Todo items, and routes each to the
8
8
  appropriate skill: US-XXX → $roll-build, FIX-XXX → $roll-fix,
9
9
  REFACTOR-XXX → $roll-build. Handles agent fallback on token/network failure.
10
- Never executes roll-release autonomously — release is always a human decision.
10
+ Never cuts a release autonomously — release is always a human decision.
11
11
  Triggers roll-brief when a Feature completes.
12
12
  ---
13
13
 
@@ -18,7 +18,7 @@ description: |
18
18
 
19
19
  Runs on a schedule. Picks up pending BACKLOG items and executes them without
20
20
  human intervention. The human stays informed via `roll-brief` and retains
21
- sole authority over `roll-release`.
21
+ sole authority over releases.
22
22
 
23
23
  ## Execution Boundary
24
24
 
@@ -28,7 +28,7 @@ sole authority over `roll-release`.
28
28
  - REFACTOR-XXX (Refactors) → `$roll-build`
29
29
 
30
30
  **What roll-loop never executes:**
31
- - `roll-release` — production deployment is always a human decision
31
+ - Releases — production deployment is always a human decision (requires 2FA in real terminal)
32
32
  - Any Story marked 🚫 Hold or flagged for human review
33
33
  - Destructive operations outside normal skill scope
34
34
 
@@ -37,6 +37,30 @@ sole authority over `roll-release`.
37
37
  故事评审等场景)。loop 通过 LOCK 和 `🔨 In Progress` 状态识别并跳过人正在做的故事,
38
38
  人机并行不会撞车(见 Concurrency Safety)。
39
39
 
40
+ ## Environment Constraints (autonomous loop)
41
+
42
+ You are running inside an autonomous cycle. No human is watching this turn.
43
+ Adapt commands to the constraints below — otherwise you will burn turns on
44
+ denied operations and the cycle will idle-exit.
45
+
46
+ - **No `AskUserQuestion`**: no human can answer. If you genuinely cannot
47
+ proceed without a decision, write an entry to `${HOME}/.shared/roll/loop/ALERT.md`
48
+ describing what's needed and exit cleanly.
49
+ - **Avoid compound bash**: each `Bash` call must run a single command.
50
+ No `cmd1 && cmd2`, no `cmd1 ; cmd2`, no pipes (`|`), no `$(...)` /
51
+ backtick subshells, no `bash -c '...'` with nested quoting. These are
52
+ rejected by static analysis before they run. Chain operations as
53
+ separate Bash calls and read intermediate output yourself.
54
+ - **Prefer Read/Edit over cat/sed**: use the `Read` tool for any file
55
+ lookup, `Edit` for modifications. They cross sandbox boundaries that
56
+ `cat` / `ls` / `sed` cannot.
57
+ - **CWD-relative paths first**: the cycle's CWD is the per-cycle worktree.
58
+ Files inside it (BACKLOG.md, bin/roll, tests/, docs/) are always
59
+ accessible. Files at `~/.shared/roll/...` are reachable via the `Read`
60
+ tool but not via shell commands.
61
+ - **Skill invocation is the work**: route US/REFACTOR via `$roll-build`,
62
+ FIX via `$roll-fix`. Do not try to re-implement those flows inline.
63
+
40
64
  ## Configuration
41
65
 
42
66
  ```yaml
@@ -208,12 +232,44 @@ After each item completes:
208
232
  it derives `owner/repo` from the git remote and uses `gh -R <slug>`, which
209
233
  is required to work through `~/.ssh/config` host rewrites that break gh's
210
234
  auto-detection.
211
- - CI passes → continue normally
212
- - CI fails / times out / `gh` call fails → keep story as `🔨 In Progress`
213
- (do NOT mark Done); write ALERT; skip to next story
235
+ - CI passes → call `_loop_clear_heal_state <story_id>` (idempotent) and
236
+ continue normally
237
+ - CI fails / times out / `gh` call fails → enter **CI self-heal** (US-AUTO-041)
214
238
  - `gh` binary not installed (`command -v gh` fails) → skip gracefully
215
239
  (return 0). Any other `gh` error is **not** "gh unavailable" — it is a
216
240
  hard failure and must block the gate.
241
+
242
+ **CI self-heal (US-AUTO-041)** — bounded auto-fix before ALERT:
243
+
244
+ ```
245
+ shell: _loop_self_heal_ci <story_id>
246
+ ├── exit 0 → heal attempt allowed (counter incremented)
247
+ │ 1. Capture failure summary:
248
+ │ gh run view --log-failed --repo <slug> $(gh run list --commit HEAD \
249
+ │ --json databaseId,conclusion -L 5 | jq -r '.[] | select(.conclusion=="failure") | .databaseId' | head -1) \
250
+ │ 2>/dev/null | head -200 > /tmp/roll-heal-<story_id>.log
251
+ │ 2. Invoke Skill("roll-fix") with brief:
252
+ │ "CI red after <story_id>. Failing run logs at /tmp/roll-heal-<story_id>.log.
253
+ │ Diagnose root cause, fix via TCR, commit, push. Do NOT change <story_id>'s
254
+ │ BACKLOG status — it stays ✅ Done. The fix is a follow-up."
255
+ │ 3. After roll-fix completes, return to step 2 (CI Gate) — re-run
256
+ │ `roll ci --wait`. The counter prevents infinite loops.
257
+
258
+ └── exit 1 → heal exhausted (>=ROLL_LOOP_HEAL_MAX, default 2) or disabled
259
+ (ROLL_LOOP_NO_HEAL=1):
260
+ 1. Keep story as ✅ Done (commits are already on main — CI red is a
261
+ follow-up problem, not a story-failure)
262
+ 2. Write ALERT to `~/.shared/roll/loop/ALERT.md` with:
263
+ - story ID, time, commit SHA
264
+ - heal attempts made (read counter from
265
+ `${ROLL_LOOP_DIR:-~/.shared/roll/loop}/heal/<story_id>.count`)
266
+ - last failure summary (head of /tmp/roll-heal-<story_id>.log)
267
+ - suggested actions: `$roll-fix` manually / inspect CI / `roll loop reset`
268
+ 3. Skip to next story.
269
+ ```
270
+
271
+ **Bypass for debugging / cost control:** set `ROLL_LOOP_NO_HEAL=1` to restore
272
+ pre-US-AUTO-041 fail-fast behaviour.
217
273
  3. Update state file: `status: idle`
218
274
  4. Check if a Feature is now fully complete (all its Stories ✅)
219
275
  5. If yes and `brief_on_feature_complete: true` → invoke `Skill("roll-brief")`
@@ -1,146 +0,0 @@
1
- ---
2
- name: roll-release
3
- license: MIT
4
- allowed-tools: "Read, Edit, Bash(git:*), Bash(npm:*), Bash(sed:*), Bash(date:*), Bash(gh:*)"
5
- description: "Release skill for roll maintainers. Calculates next version (YYYY.MMDD.N format, auto-increments N from today's git tags), updates VERSION in bin/roll and package.json, commits, tags, and pushes to trigger npm auto-publish via GitHub Actions. Trigger: release, publish, 发版, 发布新版本."
6
- ---
7
-
8
- # Release (roll-release)
9
-
10
- One-command publish flow for roll maintainers.
11
-
12
- ## When Not to Use
13
-
14
- - Non-maintainer users (this skill publishes the package defined in `package.json` — confirm scope before running)
15
- - Internal project releases — only for the `roll` CLI package itself
16
- - Hotfixing code without a version bump (use `$roll-fix`)
17
- - Generating user-facing release notes (use `$roll-.changelog`)
18
-
19
- ## Version Format
20
-
21
- `YYYY.MMDD.N` — e.g. `2026.419.1`
22
-
23
- - `YYYY.MMDD` = today's date, month has **no leading zero** (e.g. `420` not `0420`)
24
- - `N` = auto-incremented from existing git tags for today (starts at 1)
25
-
26
- ## Execution Steps
27
-
28
- ### Step 1: Calculate Version
29
-
30
- First, inspect recent tags to confirm the actual format in use:
31
-
32
- ```bash
33
- git tag | sort -V | tail -5
34
- ```
35
-
36
- Then calculate:
37
-
38
- ```bash
39
- today=$(date +%Y.%-m%d)
40
- last_n=$(git tag | grep "^v${today}\." | sed "s/^v${today}\.//" | sort -n | tail -1)
41
-
42
- # Reuse tag if latest today's tag already points to HEAD (e.g. npm publish failed, retrying)
43
- if [[ -n "$last_n" ]]; then
44
- last_tag="v${today}.${last_n}"
45
- if [[ "$(git rev-list -n1 "$last_tag")" == "$(git rev-parse HEAD)" ]]; then
46
- version="${today}.${last_n}"
47
- echo "♻️ Reusing ${last_tag} — same commit, skipping version bump"
48
- # Skip Steps 2-4, jump directly to Step 5 (GitHub Release) or Step 6 (npm publish)
49
- else
50
- n=$(( last_n + 1 ))
51
- version="${today}.${n}"
52
- fi
53
- else
54
- version="${today}.1"
55
- fi
56
- ```
57
-
58
- Show the proposed version to the user:
59
- ```
60
- Recent tags: v2026.419.1 v2026.419.2 v2026.420.3
61
- Proposed version: 2026.420.4 ← new version (code changed since last tag)
62
- — or —
63
- ♻️ Reusing v2026.420.3 ← same commit, retry after npm failure
64
- Proceed? [y/N]
65
- ```
66
-
67
- Wait for confirmation before continuing.
68
-
69
- ### Step 2: Update Version Fields
70
-
71
- **`bin/roll`** — update the VERSION line:
72
- ```bash
73
- sed -i '' "s/^VERSION=.*/VERSION=\"${version}\"/" bin/roll
74
- ```
75
-
76
- **`package.json`** — update the version field:
77
- ```bash
78
- npm version "${version}" --no-git-tag-version
79
- ```
80
-
81
- Verify both files show the new version before continuing.
82
-
83
- ### Step 3: Commit
84
-
85
- ```bash
86
- git add bin/roll package.json
87
- git commit -m "[release] v${version}"
88
- ```
89
-
90
- ### Step 4: Tag and Push
91
-
92
- ```bash
93
- git tag "v${version}"
94
- git push && git push --tags
95
- ```
96
-
97
- ### Step 5: Create GitHub Release
98
-
99
- **This step is mandatory.** Without it, `roll` update notifications will not work.
100
-
101
- Convert version to changelog date, extract notes, then create the release:
102
-
103
- ```bash
104
- # Convert version (2026.510.3) to changelog date (2026.05.10)
105
- _year=$(echo "${version}" | cut -d. -f1)
106
- _mmdd=$(echo "${version}" | cut -d. -f2)
107
- if [ ${#_mmdd} -eq 3 ]; then
108
- _cl_date="${_year}.0${_mmdd:0:1}.${_mmdd:1:2}"
109
- else
110
- _cl_date="${_year}.${_mmdd:0:2}.${_mmdd:2:2}"
111
- fi
112
-
113
- # Extract release notes from CHANGELOG.md
114
- notes=$(sed -n "/^## ${_cl_date}$/,/^## /{ /^## ${_cl_date}$/d; /^## /d; p; }" CHANGELOG.md | sed '/^[[:space:]]*$/d')
115
-
116
- gh release create "v${version}" \
117
- --title "v${version}" \
118
- --notes "${notes:-Release v${version}}"
119
- ```
120
-
121
- This enables the background update check in `bin/roll` (`_check_update_async`), which queries the GitHub Releases API.
122
-
123
- ### Step 6: Publish to npm
124
-
125
- ```bash
126
- npm publish --access public
127
- ```
128
-
129
- This will open a browser for 2FA verification. Wait for it to complete before continuing.
130
-
131
- ### Step 7: Confirm
132
-
133
- After publish, show:
134
- ```
135
- ✅ Released v{version}
136
- 🏷 Tag: v{version} pushed to origin
137
- 📦 npm published: {package_name}@{version} # package name read from package.json
138
- 🐙 GitHub Release: https://github.com/{owner}/{repo}/releases/tag/v{version}
139
- 🔗 https://www.npmjs.com/package/{package_name}
140
- ```
141
-
142
- ## Abort Conditions
143
-
144
- - Uncommitted changes in `bin/roll` or `package.json` → warn and abort
145
- - Tag already exists for today's N → increment N and re-propose
146
- - `git push` fails → show error, do not leave a dangling local tag (run `git tag -d v{version}`)