@seanyao/roll 2026.512.8 → 2026.514.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/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.512.8"
7
+ VERSION="2026.514.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"
@@ -609,6 +609,36 @@ cmd_setup() {
609
609
 
610
610
  echo ""
611
611
  info "Next: run ${BOLD}roll init${NC} inside a project to initialize it. 下一步:在项目目录运行 roll init"
612
+
613
+ echo ""
614
+ _print_pr_pipeline_hint
615
+ }
616
+
617
+ # ─── PR pipeline hint ────────────────────────────────────────────────────────
618
+ # US-AUTO-035: print the one-time branch-protection command that flips repo
619
+ # from path A (CI gate only) to path C (CI + AI review double gate). Reading
620
+ # this hint is opt-in; the command is destructive (changes branch protection)
621
+ # so it is never run automatically.
622
+ _print_pr_pipeline_hint() {
623
+ cat <<'HINT'
624
+
625
+ Optional — enable AI review as a hard merge gate (path C).
626
+ 可选 —— 启用 AI 评审作为合并双门(路径 C)。
627
+
628
+ Run once per repo (requires admin token), then claude-code-review.yml
629
+ approvals become a required merge gate alongside CI:
630
+ 每个仓库执行一次(需要管理员 token),之后 claude-code-review.yml 的
631
+ approve 将与 CI 一起成为合并必经的双门:
632
+
633
+ gh api -X PATCH repos/<owner>/<repo>/branches/main/protection \
634
+ -f required_pull_request_reviews.required_approving_review_count=1
635
+
636
+ Escape hatch: add [skip-ai-review] to a PR body, or include
637
+ SKIP_AI_REVIEW in any commit message, to bypass AI review for that PR.
638
+ 紧急通道:在 PR body 加 [skip-ai-review],或在任一 commit message
639
+ 里包含 SKIP_AI_REVIEW,可对该 PR 绕过 AI 评审。
640
+
641
+ HINT
612
642
  }
613
643
 
614
644
  # ═══════════════════════════════════════════════════════════════════════════════
@@ -1437,7 +1467,7 @@ _peer_auto_attach() {
1437
1467
  fi
1438
1468
  local launched=0
1439
1469
  if [[ "$terminal_pref" = "ghostty" || "$terminal_pref" = "Ghostty" ]]; then
1440
- open -na Ghostty.app --args -e "tmux attach -t $session" >/dev/null 2>&1 && launched=1 || true
1470
+ open -na Ghostty.app --args -e tmux attach -t "$session" >/dev/null 2>&1 && launched=1 || true
1441
1471
  fi
1442
1472
  if [[ $launched -eq 0 ]] && { [[ "$terminal_pref" = "iTerm2" || "$terminal_pref" = "iTerm" ]] || [[ -d "/Applications/iTerm.app" ]]; }; then
1443
1473
  osascript \
@@ -1447,7 +1477,7 @@ _peer_auto_attach() {
1447
1477
  && launched=1 || true
1448
1478
  fi
1449
1479
  if [[ $launched -eq 0 ]] && [[ -d "/Applications/Ghostty.app" ]]; then
1450
- open -na Ghostty.app --args -e "tmux attach -t $session" >/dev/null 2>&1 && launched=1 || true
1480
+ open -na Ghostty.app --args -e tmux attach -t "$session" >/dev/null 2>&1 && launched=1 || true
1451
1481
  fi
1452
1482
  if [[ $launched -eq 0 ]] && command -v osascript >/dev/null 2>&1; then
1453
1483
  osascript \
@@ -1666,6 +1696,8 @@ cmd_peer() {
1666
1696
  peer_session="roll-peer-${from_tool}-${to_tool}"
1667
1697
  if ! tmux has-session -t "$peer_session" 2>/dev/null; then
1668
1698
  tmux new-session -d -s "$peer_session" -x 200 -y 50
1699
+ fi
1700
+ if [ -z "$(tmux list-clients -t "$peer_session" 2>/dev/null)" ]; then
1669
1701
  _peer_auto_attach "$peer_session"
1670
1702
  fi
1671
1703
  fi
@@ -1707,7 +1739,7 @@ cmd_peer() {
1707
1739
 
1708
1740
  printf '%s\n' "$response" >> "$log_file"
1709
1741
 
1710
- local resolution
1742
+ local resolution=""
1711
1743
  resolution="$(_peer_parse_resolution "$response")"
1712
1744
 
1713
1745
  if [[ -z "$resolution" ]]; then
@@ -1732,7 +1764,7 @@ cmd_peer() {
1732
1764
  if [[ "$round" -ge 3 ]]; then
1733
1765
  warn "Max rounds reached. Escalating to user. 达到最大轮数,升级给用户。"
1734
1766
  else
1735
- info "Peer requests $resolution. Continue to round $((round + 1)). Peer 请求 $resolution,继续第 $((round + 1)) 轮。"
1767
+ info "Peer requests ${resolution}. Continue to round $((round + 1)). Peer 请求 ${resolution},继续第 $((round + 1)) 轮。"
1736
1768
  fi
1737
1769
  ;;
1738
1770
  ESCALATE|UNKNOWN)
@@ -2046,17 +2078,67 @@ _write_loop_runner_script() {
2046
2078
  # Use stream-json + formatter: --verbose alone does nothing in -p mode;
2047
2079
  # stream-json enables realtime streaming; loop-fmt.py humanizes the events.
2048
2080
  local fmt_script="${ROLL_PKG_DIR}/lib/loop-fmt.py"
2081
+ local roll_bin="${ROLL_PKG_DIR}/bin/roll"
2049
2082
  local cmd_verbose="${cmd/claude -p/claude -p --verbose --output-format stream-json}"
2083
+ # US-AUTO-037: strip leading `cd "<path>" && ` (callers like
2084
+ # _install_launchd_plists prepend it). The runner now manages cwd itself
2085
+ # — pointing at the worktree when isolation succeeds, project_path otherwise.
2086
+ local claude_cmd; claude_cmd="${cmd_verbose#cd \"*\" && }"
2087
+ local slug; slug=$(_project_slug "$project_path")
2050
2088
  cat > "$inner_path" << INNER
2051
2089
  #!/bin/bash -l
2052
2090
  set -o pipefail
2053
2091
  export PATH="/opt/homebrew/bin:\$PATH"
2092
+ # FIX-031: inner-level LOCK (PID + start-ts) — outer runner.sh LOCK can be
2093
+ # bypassed (recovery / retry / direct invocation); this guards the actual
2094
+ # claude invocation so a second session can't run under the same project.
2095
+ INNER_LOCK="\$(dirname "\$0")/.INNER-LOCK-\$(basename "\$0" -inner.sh | sed 's/^run-//')"
2096
+ if [ -f "\$INNER_LOCK" ]; then
2097
+ _prev_pid=""; _prev_ts=""
2098
+ IFS=: read -r _prev_pid _prev_ts < "\$INNER_LOCK" 2>/dev/null || true
2099
+ _now=\$(date -u +%s)
2100
+ if [ -n "\$_prev_pid" ] && [ -n "\$_prev_ts" ] \\
2101
+ && kill -0 "\$_prev_pid" 2>/dev/null \\
2102
+ && [ "\$((_now - _prev_ts))" -lt 14400 ]; then
2103
+ echo "[\$(date '+%Y-%m-%dT%H:%M:%S%z')] inner loop already running (PID \$_prev_pid), skipping"
2104
+ exit 0
2105
+ fi
2106
+ rm -f "\$INNER_LOCK"
2107
+ fi
2108
+ printf '%s:%s\n' "\$\$" "\$(date -u +%s)" > "\$INNER_LOCK"
2109
+ trap 'rm -f "\$INNER_LOCK"' EXIT
2110
+
2111
+ # US-AUTO-037: pull in worktree helpers (US-AUTO-036). Sourcing bin/roll is
2112
+ # safe — its main() only runs when invoked directly (BASH_SOURCE == \$0).
2113
+ # bin/roll's top-level \`set -euo pipefail\` infects us, so disable -e (the
2114
+ # retry loop relies on tolerating non-zero exits) while keeping pipefail.
2115
+ source "${roll_bin}"
2116
+ set +e
2117
+
2118
+ # Pre-claude: try to create a per-cycle isolated worktree on origin/main.
2119
+ # On any failure (no remote, no main, etc.) fall back to running in the
2120
+ # project's main tree (degraded — no isolation, like pre-037 behavior).
2121
+ CYCLE_ID="\$(date -u +%Y%m%d-%H%M%S)-\$\$"
2122
+ WT="\$(_worktree_path "${slug}" "cycle-\${CYCLE_ID}")"
2123
+ BRANCH="loop/cycle-\${CYCLE_ID}"
2124
+ _USE_WORKTREE=0
2125
+ cd "${project_path}" 2>/dev/null || true
2126
+ if _worktree_fetch_origin main \\
2127
+ && _worktree_create "\$WT" "\$BRANCH" "origin/main"; then
2128
+ _USE_WORKTREE=1
2129
+ _worktree_submodule_init "\$WT" 2>/dev/null || true
2130
+ echo "[loop] cycle \${CYCLE_ID}: worktree \$WT on \$BRANCH"
2131
+ else
2132
+ echo "[loop] cycle \${CYCLE_ID}: worktree setup failed; running in main tree (no isolation)"
2133
+ WT="${project_path}"
2134
+ fi
2135
+
2054
2136
  FMT="${fmt_script}"
2055
2137
  for _attempt in 1 2 3; do
2056
2138
  if [ -f "\$FMT" ]; then
2057
- ( cd "${project_path}" && ${cmd_verbose} ) | python3 "\$FMT"
2139
+ ( cd "\$WT" && ${claude_cmd} ) | python3 "\$FMT"
2058
2140
  else
2059
- ( cd "${project_path}" && ${cmd_verbose} )
2141
+ ( cd "\$WT" && ${claude_cmd} )
2060
2142
  fi
2061
2143
  _exit=\$?
2062
2144
  [ "\$_exit" -eq 0 ] && break
@@ -2065,10 +2147,41 @@ for _attempt in 1 2 3; do
2065
2147
  sleep 30
2066
2148
  fi
2067
2149
  done
2150
+
2151
+ # Post-claude: publish cycle branch. Doc-only changes (BACKLOG/docs) merge
2152
+ # immediately via --admin; code changes use auto-merge (CI gate required).
2153
+ # When \`gh\` is unavailable, fall back to the legacy ff-merge path.
2154
+ if [ "\$_USE_WORKTREE" = "1" ]; then
2155
+ if [ "\$_exit" -eq 0 ]; then
2156
+ if ( cd "\$WT" && _loop_is_doc_only_change ); then
2157
+ ( cd "\$WT" && _loop_publish_doc_pr "\$BRANCH" "doc: loop cycle \${CYCLE_ID}" )
2158
+ else
2159
+ ( cd "\$WT" && _loop_publish_pr "\$BRANCH" "loop cycle \${CYCLE_ID}" )
2160
+ fi
2161
+ _publish_status=\$?
2162
+ if [ "\$_publish_status" -eq 0 ]; then
2163
+ _worktree_cleanup "\$WT" "\$BRANCH"
2164
+ echo "[loop] cycle \${CYCLE_ID}: published; worktree cleaned"
2165
+ elif [ "\$_publish_status" -eq 2 ]; then
2166
+ if ( cd "${project_path}" && _worktree_merge_back "\$BRANCH" ); then
2167
+ _worktree_cleanup "\$WT" "\$BRANCH"
2168
+ echo "[loop] cycle \${CYCLE_ID}: gh unavailable; merged via ff and cleaned up"
2169
+ else
2170
+ _worktree_alert "cycle \${CYCLE_ID}: gh unavailable AND merge_back failed; worktree preserved at \$WT"
2171
+ echo "[loop] cycle \${CYCLE_ID}: gh+merge_back both failed; worktree preserved at \$WT"
2172
+ fi
2173
+ else
2174
+ _worktree_alert "cycle \${CYCLE_ID}: PR publish failed; worktree preserved at \$WT (branch \$BRANCH)"
2175
+ echo "[loop] cycle \${CYCLE_ID}: PR publish failed; worktree preserved at \$WT"
2176
+ fi
2177
+ else
2178
+ _worktree_alert "cycle \${CYCLE_ID}: claude exited \$_exit; worktree preserved at \$WT (branch \$BRANCH)"
2179
+ echo "[loop] cycle \${CYCLE_ID}: claude failed (exit \$_exit); worktree preserved at \$WT"
2180
+ fi
2181
+ fi
2068
2182
  INNER
2069
2183
  chmod +x "$inner_path"
2070
2184
 
2071
- local slug; slug=$(_project_slug "$project_path")
2072
2185
  cat > "$script_path" << SCRIPT
2073
2186
  #!/bin/bash -l
2074
2187
  # Active-window check — skipped when ROLL_LOOP_FORCE is set (manual 'roll loop now')
@@ -2218,8 +2331,13 @@ _install_launchd_plists() {
2218
2331
  if [[ "$before" != "$after" ]]; then
2219
2332
  updated=$((updated + 1))
2220
2333
  if _launchd_is_loaded "$label"; then
2221
- launchctl unload "$plist" 2>/dev/null || true
2222
- launchctl load "$plist" 2>/dev/null || true
2334
+ # FIX-027: use bootout/bootstrap so we don't disturb the label's
2335
+ # enabled flag in the launchd overrides db (which legacy
2336
+ # unload/load no-`-w` wipes on macOS Sonoma+, causing
2337
+ # `roll loop status` to falsely report off after `roll update`).
2338
+ local uid; uid=$(id -u)
2339
+ launchctl bootout "gui/${uid}/${label}" 2>/dev/null || true
2340
+ launchctl bootstrap "gui/${uid}" "$plist" 2>/dev/null || true
2223
2341
  fi
2224
2342
  fi
2225
2343
  done
@@ -2919,6 +3037,432 @@ EOF
2919
3037
  return 0
2920
3038
  }
2921
3039
 
3040
+ # FIX-032: dependency gate — parses BACKLOG inline tags so the loop SKILL
3041
+ # can enforce them at Step 2 (story pickup). Pure functions, no side effects.
3042
+ #
3043
+ # BACKLOG row format (relevant fragments):
3044
+ # | [US-AUTO-033](...) | desc `depends-on:US-AUTO-037` `manual-only:true` | 📋 Todo |
3045
+ # | FIX-100 | desc `depends-on:US-A,US-B` | 📋 Todo |
3046
+ #
3047
+ # Row matching is anchored on `^| [?<id>\b` so a story-id appearing in some
3048
+ # other row's depends-on list is not mistaken for the row that defines it.
3049
+
3050
+ # _loop_check_depends_on <story-id> [backlog-path]
3051
+ # Exit 0: all listed depends-on are ✅ Done, or no depends-on tag present.
3052
+ # Exit 1: any dep not ✅ Done, story-id not found, or backlog missing.
3053
+ # Stdout (on exit 1 due to unsatisfied deps): space-separated unsatisfied IDs.
3054
+ _loop_check_depends_on() {
3055
+ local id="$1"
3056
+ local backlog="${2:-BACKLOG.md}"
3057
+ [ -n "$id" ] || return 1
3058
+ [ -f "$backlog" ] || return 1
3059
+
3060
+ local row
3061
+ row=$(grep -E "^\| \[?${id}[]| ]" "$backlog" | head -1)
3062
+ [ -n "$row" ] || return 1
3063
+
3064
+ local deps
3065
+ deps=$(echo "$row" | grep -oE 'depends-on:[A-Z][A-Z0-9,-]+' | head -1 | sed 's/depends-on://')
3066
+ [ -n "$deps" ] || return 0
3067
+
3068
+ local unsatisfied=""
3069
+ local dep
3070
+ local IFS_save="$IFS"
3071
+ IFS=','
3072
+ for dep in $deps; do
3073
+ local dep_row
3074
+ dep_row=$(grep -E "^\| \[?${dep}[]| ]" "$backlog" | head -1)
3075
+ if [ -z "$dep_row" ] || ! echo "$dep_row" | grep -qF '✅ Done'; then
3076
+ unsatisfied="${unsatisfied:+$unsatisfied }${dep}"
3077
+ fi
3078
+ done
3079
+ IFS="$IFS_save"
3080
+
3081
+ if [ -n "$unsatisfied" ]; then
3082
+ echo "$unsatisfied"
3083
+ return 1
3084
+ fi
3085
+ return 0
3086
+ }
3087
+
3088
+ # _loop_is_manual_only <story-id> [backlog-path]
3089
+ # Exit 0: story's own row carries `manual-only:true`.
3090
+ # Exit 1: tag absent, story-id not found, or backlog missing.
3091
+ _loop_is_manual_only() {
3092
+ local id="$1"
3093
+ local backlog="${2:-BACKLOG.md}"
3094
+ [ -n "$id" ] || return 1
3095
+ [ -f "$backlog" ] || return 1
3096
+
3097
+ local row
3098
+ row=$(grep -E "^\| \[?${id}[]| ]" "$backlog" | head -1)
3099
+ [ -n "$row" ] || return 1
3100
+
3101
+ echo "$row" | grep -qE 'manual-only:true'
3102
+ }
3103
+
3104
+ # US-CL-004: changelog 风格守门 Phase 1 — mechanical linter.
3105
+ #
3106
+ # _changelog_lint_bullet <bullet-text>
3107
+ # Stdout: one violation tag per line; empty = bullet passes.
3108
+ # Exit: 0 always (callers read the output stream, not the exit code).
3109
+ #
3110
+ # Violation tags:
3111
+ # backtick-identifier `…` contains `_` or `()` (e.g. `_foo`, `bar()`)
3112
+ # file-suffix `.md`/`.sh`/`.yml`/`.ts`/`.bats` outside backticks
3113
+ # internal-word Phase N / Step N / Helper / Schema / Fixture / Refactor
3114
+ # over-length > 50 visible chars (UTF-8 codepoints; 中文按字符计)
3115
+ # path-fragment docs/ / bin/ / tests/ / scripts/ outside backticks
3116
+ #
3117
+ # Backticks are treated as the "user-quoted" zone — content there is assumed
3118
+ # to be a real user command (e.g. `roll edit notes.md`) and is excluded from
3119
+ # the file-suffix / path-fragment checks.
3120
+ _changelog_lint_bullet() {
3121
+ local bullet="$1"
3122
+ local stripped
3123
+ stripped=$(printf '%s' "$bullet" | sed -E 's/`[^`]*`//g')
3124
+
3125
+ if printf '%s' "$bullet" | grep -qE '`[^`]*(_|\(\))[^`]*`'; then
3126
+ echo "backtick-identifier"
3127
+ fi
3128
+ if printf '%s' "$stripped" | grep -qE '\.(md|sh|yml|ts|bats)([^A-Za-z0-9]|$)'; then
3129
+ echo "file-suffix"
3130
+ fi
3131
+ if printf '%s' "$bullet" | grep -qE '(Phase|Step)[[:space:]]+[0-9]+|Helper|Schema|Fixture|Refactor'; then
3132
+ echo "internal-word"
3133
+ fi
3134
+ local len
3135
+ len=$(printf '%s' "$bullet" | LC_ALL=C.UTF-8 wc -m | tr -d ' ')
3136
+ if [ "${len:-0}" -gt 50 ]; then
3137
+ echo "over-length"
3138
+ fi
3139
+ if printf '%s' "$stripped" | grep -qE '(^|[^A-Za-z0-9_])(docs|bin|tests|scripts)/'; then
3140
+ echo "path-fragment"
3141
+ fi
3142
+ return 0
3143
+ }
3144
+
3145
+ # US-CL-004: changelog few-shot style anchors — extract bullets from the
3146
+ # most recent 3 published `## v...` sections of CHANGELOG.md (skipping
3147
+ # `## Unreleased`). Cap at ~1500 chars so the agent's context stays lean.
3148
+ #
3149
+ # _changelog_style_anchors [changelog-path]
3150
+ # Stdout: concatenated bullet lines from the last 3 released versions.
3151
+ # Exit: 0 (empty output when no CHANGELOG.md or no released sections).
3152
+ _changelog_style_anchors() {
3153
+ local changelog="${1:-CHANGELOG.md}"
3154
+ [ -f "$changelog" ] || return 0
3155
+ awk '
3156
+ /^## v/ { ver++; if (ver > 3) exit; printing = 1; next }
3157
+ /^## / { printing = 0 }
3158
+ printing && /^- / { print }
3159
+ ' "$changelog" | head -c 1500
3160
+ }
3161
+
3162
+ # US-CL-005: changelog 风格守门 Phase 2 — self-audit gate.
3163
+ #
3164
+ # _changelog_audit_bullet <bullet>
3165
+ # Stricter than _changelog_lint_bullet: 5 boolean rules, 30-char cap.
3166
+ # Stdout: one failed-rule tag per line; empty = bullet passes.
3167
+ # Exit: 0 always.
3168
+ #
3169
+ # Rules:
3170
+ # over-length-30 visible chars > 30 AND no backtick (user-cmd escape hatch)
3171
+ # internal-id backtick content contains `_` or `()`
3172
+ # path-or-suffix .md/.sh/.yml/.ts/.bats or docs/bin/tests/scripts/ outside backticks
3173
+ # phase-step `Phase N` / `Step N` workflow vocabulary
3174
+ # bad-shape no `—` (em dash) AND no `不再` AND no `现在` keyword
3175
+ _changelog_audit_bullet() {
3176
+ local bullet="$1"
3177
+ local stripped
3178
+ stripped=$(printf '%s' "$bullet" | sed -E 's/`[^`]*`//g')
3179
+
3180
+ # Rule 1: length cap 30 (user-command in backticks bypasses this rule).
3181
+ if ! printf '%s' "$bullet" | grep -q '`'; then
3182
+ local len
3183
+ len=$(LC_ALL=en_US.UTF-8 printf '%s' "$bullet" | LC_ALL=en_US.UTF-8 wc -m | tr -d ' ')
3184
+ if [ "${len:-0}" -gt 30 ]; then
3185
+ echo "over-length-30"
3186
+ fi
3187
+ fi
3188
+
3189
+ # Rule 2: internal identifier inside backticks.
3190
+ if printf '%s' "$bullet" | grep -qE '`[^`]*(_|\(\))[^`]*`'; then
3191
+ echo "internal-id"
3192
+ fi
3193
+
3194
+ # Rule 3: file suffix / path fragment outside backticks.
3195
+ if printf '%s' "$stripped" | grep -qE '\.(md|sh|yml|ts|bats)([^A-Za-z0-9]|$)' \
3196
+ || printf '%s' "$stripped" | grep -qE '(^|[^A-Za-z0-9_])(docs|bin|tests|scripts)/'; then
3197
+ echo "path-or-suffix"
3198
+ fi
3199
+
3200
+ # Rule 4: workflow vocabulary.
3201
+ if printf '%s' "$bullet" | grep -qE '(Phase|Step)[[:space:]]+[0-9]+'; then
3202
+ echo "phase-step"
3203
+ fi
3204
+
3205
+ # Rule 5: required shape — em dash, 不再, or 现在.
3206
+ if ! printf '%s' "$bullet" | grep -qE '—|不再|现在'; then
3207
+ echo "bad-shape"
3208
+ fi
3209
+
3210
+ return 0
3211
+ }
3212
+
3213
+ # _changelog_audit_log <verdict> <round> <bullet> [<reason>...]
3214
+ # Append a JSONL record to the audit log. Path overridable via
3215
+ # ROLL_CHANGELOG_AUDIT_LOG (tests use this to stay out of $HOME).
3216
+ _changelog_audit_log() {
3217
+ local verdict="$1" round="$2" bullet="$3"
3218
+ shift 3
3219
+ local log="${ROLL_CHANGELOG_AUDIT_LOG:-${_SHARED_ROOT}/loop/changelog-audit.jsonl}"
3220
+ mkdir -p "$(dirname "$log")"
3221
+ local ts; ts=$(date -u +%Y-%m-%dT%H:%M:%SZ)
3222
+ local reasons_json='[]'
3223
+ if [ "$#" -gt 0 ]; then
3224
+ reasons_json=$(printf '%s\n' "$@" | jq -R . | jq -sc .)
3225
+ fi
3226
+ jq -nc \
3227
+ --arg ts "$ts" \
3228
+ --arg verdict "$verdict" \
3229
+ --argjson round "$round" \
3230
+ --arg bullet "$bullet" \
3231
+ --argjson reasons "$reasons_json" \
3232
+ '{ts:$ts, verdict:$verdict, round:$round, bullet:$bullet, reasons:$reasons}' \
3233
+ >> "$log"
3234
+ }
3235
+
3236
+ # _changelog_audit_gate <round1> [<round2> <round3>]
3237
+ # Run up to 3 candidate bullets through _changelog_audit_bullet.
3238
+ # First clean candidate wins: print bullet to stdout, exit 0.
3239
+ # All 3 failed: print ⚠️-prefixed last candidate, append ALERT, exit 1.
3240
+ # Each round writes a _changelog_audit_log record.
3241
+ _changelog_audit_gate() {
3242
+ local i=0 last=""
3243
+ for candidate in "$@"; do
3244
+ i=$((i + 1))
3245
+ last="$candidate"
3246
+ local viols
3247
+ # shellcheck disable=SC2207
3248
+ viols=( $(_changelog_audit_bullet "$candidate") )
3249
+ if [ "${#viols[@]}" -eq 0 ]; then
3250
+ _changelog_audit_log pass "$i" "$candidate"
3251
+ printf '%s\n' "$candidate"
3252
+ return 0
3253
+ fi
3254
+ _changelog_audit_log fail "$i" "$candidate" "${viols[@]}"
3255
+ [ "$i" -ge 3 ] && break
3256
+ done
3257
+ # All 3 rounds failed (or fewer if caller passed < 3).
3258
+ mkdir -p "$(dirname "$_LOOP_ALERT")" 2>/dev/null
3259
+ {
3260
+ echo ""
3261
+ echo "# ALERT — changelog audit failed after $i rounds"
3262
+ echo "**Time**: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
3263
+ echo "**Bullet**: $last"
3264
+ echo "**Action**: kept under \`## Unreleased\` with ⚠️ prefix; human review recommended."
3265
+ } >> "$_LOOP_ALERT"
3266
+ printf '⚠️ %s\n' "$last"
3267
+ return 1
3268
+ }
3269
+
3270
+ # US-AUTO-036: worktree helpers (loop-safe pure additions).
3271
+ #
3272
+ # Phase 1 of worktree isolation — these helpers are NOT yet called by
3273
+ # runner.sh. US-AUTO-037 (manual-only) wires them into
3274
+ # _write_loop_runner_script. Do not delete or inline; they are unit-tested
3275
+ # in tests/unit/roll_worktree.bats.
3276
+
3277
+ # _worktree_path <slug> <us-id>
3278
+ # Echoes the canonical worktree directory for a (project, story) pair.
3279
+ _worktree_path() {
3280
+ echo "${_SHARED_ROOT}/worktrees/${1}-${2}"
3281
+ }
3282
+
3283
+ # _worktree_alert <msg>
3284
+ # Append a timestamped line to $_LOOP_ALERT. Used by failure paths in
3285
+ # _worktree_merge_back to surface stuck worktrees.
3286
+ _worktree_alert() {
3287
+ mkdir -p "$(dirname "$_LOOP_ALERT")" 2>/dev/null
3288
+ printf '[%s] worktree: %s\n' "$(date -u +%FT%TZ)" "$1" >> "$_LOOP_ALERT"
3289
+ }
3290
+
3291
+ # _worktree_create <path> <branch> <base>
3292
+ # Create a worktree at <path> on a new branch <branch> rooted at <base>.
3293
+ # Idempotent: if <branch> already exists locally (from a prior failed
3294
+ # run) it is deleted first so `git worktree add -b` does not error.
3295
+ _worktree_create() {
3296
+ local path="$1" branch="$2" base="$3"
3297
+ mkdir -p "$(dirname "$path")"
3298
+ if [ -e "$path" ]; then
3299
+ git worktree remove --force "$path" 2>/dev/null || true
3300
+ rm -rf "$path" 2>/dev/null || true
3301
+ fi
3302
+ if git show-ref --verify --quiet "refs/heads/${branch}"; then
3303
+ git branch -D "$branch" >/dev/null 2>&1 || true
3304
+ fi
3305
+ git worktree add "$path" -b "$branch" "$base"
3306
+ }
3307
+
3308
+ # _worktree_cleanup <path> <branch>
3309
+ # Remove the worktree at <path> and delete <branch>. Tolerant when
3310
+ # either is already absent so retries / partial-failure rollback is safe.
3311
+ _worktree_cleanup() {
3312
+ local path="$1" branch="$2"
3313
+ git worktree remove --force "$path" 2>/dev/null || true
3314
+ rm -rf "$path" 2>/dev/null || true
3315
+ git branch -D "$branch" 2>/dev/null || true
3316
+ return 0
3317
+ }
3318
+
3319
+ # _worktree_fetch_origin <branch>
3320
+ # `git fetch origin <branch>` quietly. Lenient on failure: a missing
3321
+ # remote / network blip must not derail the loop, so we return 0 even
3322
+ # when fetch fails (the loop's later ff-only check is the strict gate).
3323
+ _worktree_fetch_origin() {
3324
+ local branch="$1"
3325
+ if ! git fetch origin "$branch" --quiet 2>/dev/null; then
3326
+ echo "[worktree] fetch origin ${branch} failed (lenient, continuing)" >&2
3327
+ fi
3328
+ return 0
3329
+ }
3330
+
3331
+ # _worktree_submodule_init <path>
3332
+ # Run `git submodule update --init --recursive` inside the worktree at
3333
+ # <path> so its working tree is materially complete. Runs in a subshell
3334
+ # (cd is local) so the caller's cwd and the parent worktree's submodule
3335
+ # state are untouched. Returns submodule update's exit code.
3336
+ _worktree_submodule_init() {
3337
+ local path="$1"
3338
+ ( cd "$path" && git submodule update --init --recursive --quiet )
3339
+ }
3340
+
3341
+ # _worktree_merge_back <branch>
3342
+ # Caller must be in the main worktree (cwd = main). Steps:
3343
+ # 1. git pull --ff-only origin main (sync local main with remote)
3344
+ # 2. git merge --ff-only <branch> (linear merge of loop branch)
3345
+ # 3. git push origin main (publish)
3346
+ # Any failure → write to $_LOOP_ALERT and return 1 (worktree is left
3347
+ # in place by the caller for human inspection, per US-AUTO-036 non-goal).
3348
+ _worktree_merge_back() {
3349
+ local branch="$1"
3350
+ if ! git pull --ff-only origin main --quiet 2>/dev/null; then
3351
+ _worktree_alert "pull --ff-only origin main failed (remote diverged?)"
3352
+ return 1
3353
+ fi
3354
+ if ! git merge --ff-only "$branch" --quiet 2>/dev/null; then
3355
+ _worktree_alert "merge --ff-only ${branch} failed (not fast-forwardable from main)"
3356
+ return 1
3357
+ fi
3358
+ if ! git push origin main --quiet 2>/dev/null; then
3359
+ _worktree_alert "push origin main failed after merging ${branch}"
3360
+ return 1
3361
+ fi
3362
+ return 0
3363
+ }
3364
+
3365
+ # US-AUTO-033: publish a loop cycle branch as a GitHub PR with auto-merge.
3366
+ #
3367
+ # _loop_publish_pr <branch> [title]
3368
+ # Caller's cwd: a tree where <branch> exists locally.
3369
+ # Steps:
3370
+ # 1. git push origin <branch>
3371
+ # 2. gh pr view <branch> → reuse if a PR is already open
3372
+ # 3. gh pr create --base main --head <branch> ...
3373
+ # 4. gh pr merge <branch> --auto --squash --delete-branch
3374
+ # Stdout: PR URL (always, even on idempotent reuse).
3375
+ # Exit 0 on success / idempotent reuse; non-zero on push or create failure.
3376
+ # On auto-merge failure: still returns 0 (PR exists; human can take over).
3377
+ # When `gh` is not installed: returns 2 — runner script's fallback path.
3378
+ _loop_publish_pr() {
3379
+ local branch="$1"
3380
+ local title="${2:-loop cycle ${branch#loop/}}"
3381
+ if ! command -v gh >/dev/null 2>&1; then
3382
+ _worktree_alert "_loop_publish_pr: gh not installed; cannot publish PR for ${branch}"
3383
+ return 2
3384
+ fi
3385
+ local slug; slug=$(_gh_repo_slug 2>/dev/null) || slug=""
3386
+ if [ -z "$slug" ]; then
3387
+ _worktree_alert "_loop_publish_pr: origin remote is not a github repo; cannot publish PR for ${branch}"
3388
+ return 2
3389
+ fi
3390
+ local _push_err
3391
+ _push_err=$(git push origin "$branch" 2>&1) || {
3392
+ _worktree_alert "_loop_publish_pr: push origin ${branch} failed: ${_push_err}"
3393
+ return 1
3394
+ }
3395
+ local pr_url
3396
+ pr_url=$(gh -R "$slug" pr view "$branch" --json url -q .url 2>/dev/null) || pr_url=""
3397
+ if [ -z "$pr_url" ]; then
3398
+ local body
3399
+ body=$(printf 'Auto-opened by roll-loop cycle.\n\n- Branch: %s\n- TCR micro-commits: %s\n\nThis PR will auto-merge once required checks pass.' \
3400
+ "$branch" "$(git rev-list --count origin/main.."$branch" 2>/dev/null || echo '?')")
3401
+ pr_url=$(gh -R "$slug" pr create --base main --head "$branch" \
3402
+ --title "$title" --body "$body" 2>/dev/null) || pr_url=""
3403
+ if [ -z "$pr_url" ]; then
3404
+ _worktree_alert "_loop_publish_pr: gh pr create failed for ${branch}"
3405
+ return 1
3406
+ fi
3407
+ fi
3408
+ gh -R "$slug" pr merge "$branch" --auto --squash --delete-branch >/dev/null 2>&1 \
3409
+ || _worktree_alert "_loop_publish_pr: gh pr merge --auto failed for ${branch} (PR ${pr_url} left open)"
3410
+ echo "$pr_url"
3411
+ return 0
3412
+ }
3413
+
3414
+ # _loop_is_doc_only_change
3415
+ # Returns 0 if every file changed since origin/main is doc-only
3416
+ # (BACKLOG.md, CHANGELOG.md, PROPOSALS.md, docs/, .claude/).
3417
+ # Returns 1 if any code file changed or there are no changes.
3418
+ _loop_is_doc_only_change() {
3419
+ local changed
3420
+ changed=$(git diff --name-only origin/main HEAD 2>/dev/null) || return 1
3421
+ [ -z "$changed" ] && return 1
3422
+ echo "$changed" | grep -qvE '^(BACKLOG\.md|CHANGELOG\.md|PROPOSALS\.md|docs/|\.claude/)' && return 1
3423
+ return 0
3424
+ }
3425
+
3426
+ # _loop_publish_doc_pr <branch> [title]
3427
+ # Like _loop_publish_pr but merges immediately with --admin (no CI wait).
3428
+ # For doc-only changes where CI is not meaningful.
3429
+ _loop_publish_doc_pr() {
3430
+ local branch="$1"
3431
+ local title="${2:-doc update ${branch#loop/}}"
3432
+ if ! command -v gh >/dev/null 2>&1; then
3433
+ _worktree_alert "_loop_publish_doc_pr: gh not installed; cannot publish PR for ${branch}"
3434
+ return 2
3435
+ fi
3436
+ local slug; slug=$(_gh_repo_slug 2>/dev/null) || slug=""
3437
+ if [ -z "$slug" ]; then
3438
+ _worktree_alert "_loop_publish_doc_pr: origin remote is not a github repo; cannot publish PR for ${branch}"
3439
+ return 2
3440
+ fi
3441
+ if ! git push origin "$branch" --quiet 2>/dev/null; then
3442
+ _worktree_alert "_loop_publish_doc_pr: push origin ${branch} failed"
3443
+ return 1
3444
+ fi
3445
+ local pr_url
3446
+ pr_url=$(gh -R "$slug" pr view "$branch" --json url -q .url 2>/dev/null) || pr_url=""
3447
+ if [ -z "$pr_url" ]; then
3448
+ local body
3449
+ body=$(printf 'Doc-only update by roll-loop cycle.\n\n- Branch: %s\n- Files: BACKLOG / docs only\n\nMerging immediately — no CI gate needed for doc-only changes.' "$branch")
3450
+ pr_url=$(gh -R "$slug" pr create --base main --head "$branch" \
3451
+ --title "$title" --body "$body" 2>/dev/null) || pr_url=""
3452
+ if [ -z "$pr_url" ]; then
3453
+ _worktree_alert "_loop_publish_doc_pr: gh pr create failed for ${branch}"
3454
+ return 1
3455
+ fi
3456
+ fi
3457
+ if ! gh -R "$slug" pr merge "$branch" --admin --squash --delete-branch >/dev/null 2>&1; then
3458
+ _worktree_alert "_loop_publish_doc_pr: gh pr merge --admin failed for ${branch} (PR ${pr_url} left open)"
3459
+ echo "$pr_url"
3460
+ return 1
3461
+ fi
3462
+ echo "$pr_url"
3463
+ return 0
3464
+ }
3465
+
2922
3466
  _loop_monitor() {
2923
3467
  local interval="${1:-3}"
2924
3468
  local project_path; project_path=$(pwd -P)
@@ -3416,83 +3960,378 @@ cmd_backlog() {
3416
3960
  fi
3417
3961
  }
3418
3962
 
3963
+ # ─────────────────────────────────────────────────────────────────────────────
3964
+ # DASHBOARD — 自治优先六块布局 (US-AUTO-029)
3419
3965
  # ─────────────────────────────────────────────────────────────────────────────
3420
3966
 
3967
+ # ① Identity — git working tree state.
3968
+ _dash_git_status() {
3969
+ git rev-parse --is-inside-work-tree &>/dev/null || { echo "—"; return; }
3970
+ if [[ -z "$(git status --porcelain 2>/dev/null)" ]]; then
3971
+ echo "✓"
3972
+ else
3973
+ echo "dirty"
3974
+ fi
3975
+ }
3976
+
3977
+ # ② Loop layer: extract in-progress story id|title|feature-link from BACKLOG.md.
3978
+ # Output empty if no row's *status column* is 🔨 In Progress (substring matches
3979
+ # anywhere on the row would catch description text that mentions the emoji).
3980
+ _dash_in_progress_story() {
3981
+ [[ -f "BACKLOG.md" ]] || return 0
3982
+ local row
3983
+ row=$(grep -F '| 🔨 In Progress |' BACKLOG.md | head -1) || return 0
3984
+ [[ -z "$row" ]] && return 0
3985
+ local id desc
3986
+ id=$(echo "$row" | grep -oE '(US|FIX|REFACTOR)-[A-Z]*-?[0-9]+' | head -1)
3987
+ desc=$(echo "$row" | awk -F'|' '{print $3}' | sed 's/^ *//;s/ *$//' | cut -c1-60)
3988
+ local link
3989
+ link=$(echo "$row" | grep -oE 'docs/features/[^)]+' | head -1 || true)
3990
+ printf '%s|%s|%s' "$id" "$desc" "$link"
3991
+ }
3992
+
3993
+ # ② Loop layer: minutes since last "tcr:" commit, or empty if none.
3994
+ _dash_last_tcr_minutes() {
3995
+ git rev-parse --is-inside-work-tree &>/dev/null || return 0
3996
+ local last_ts
3997
+ last_ts=$(git log --grep='^tcr:' -1 --format=%ct 2>/dev/null)
3998
+ [[ -z "$last_ts" ]] && return 0
3999
+ local now; now=$(date +%s)
4000
+ echo $(( (now - last_ts) / 60 ))
4001
+ }
4002
+
4003
+ # ② Loop layer: tcr: commits since midnight today.
4004
+ _dash_tcr_today_count() {
4005
+ git rev-parse --is-inside-work-tree &>/dev/null || { echo 0; return; }
4006
+ local since; since=$(date '+%Y-%m-%d 00:00:00')
4007
+ git log --since="$since" --grep='^tcr:' --oneline 2>/dev/null | grep -c '^' || echo 0
4008
+ }
4009
+
4010
+ # ② Dream layer: hours since last dream log entry on disk.
4011
+ _dash_last_dream_hours() {
4012
+ local dream_log="${HOME}/.shared/roll/dream/log.md"
4013
+ [[ -f "$dream_log" ]] || return 0
4014
+ local mod_time now
4015
+ mod_time=$(stat -c %Y "$dream_log" 2>/dev/null || stat -f %m "$dream_log" 2>/dev/null || echo 0)
4016
+ now=$(date +%s)
4017
+ echo $(( (now - mod_time) / 3600 ))
4018
+ }
4019
+
4020
+ # ② Dream layer: count of REFACTOR-XXX rows currently 📋 Todo in BACKLOG.
4021
+ _dash_refactor_pending() {
4022
+ [[ -f "BACKLOG.md" ]] || { echo 0; return; }
4023
+ grep -E '^\| REFACTOR-' BACKLOG.md 2>/dev/null | grep -F '| 📋 Todo |' | wc -l | tr -d ' '
4024
+ }
4025
+
4026
+ # ② Peer layer: last result + days ago from peer log, empty if no log.
4027
+ _dash_last_peer() {
4028
+ local peer_log_dir="${HOME}/.shared/roll/peer"
4029
+ local latest
4030
+ latest=$(ls "$peer_log_dir"/*.log 2>/dev/null | sort | tail -1 || true)
4031
+ [[ -z "$latest" || ! -f "$latest" ]] && return 0
4032
+ local result
4033
+ result=$(grep -oE '(AGREE|REFINE|OBJECT|ESCALATE)' "$latest" 2>/dev/null | tail -1 || true)
4034
+ local mod_time now days
4035
+ mod_time=$(stat -c %Y "$latest" 2>/dev/null || stat -f %m "$latest" 2>/dev/null || echo 0)
4036
+ now=$(date +%s)
4037
+ days=$(( (now - mod_time) / 86400 ))
4038
+ printf '%s|%s' "${result:-—}" "${days}"
4039
+ }
4040
+
4041
+ # ③ Pipeline counts → Idea Backlog Build (Verify/Release reserved).
4042
+ _dash_pipeline_counts() {
4043
+ [[ -f "BACKLOG.md" ]] || { echo "0 0 0 0 0"; return; }
4044
+ local idea backlog build
4045
+ idea=$(grep -E '^\| IDEA-' BACKLOG.md 2>/dev/null | grep -F '| 📋 Todo |' | wc -l | tr -d ' ')
4046
+ backlog=$(grep -E '^\| (\[?US-|FIX-|REFACTOR-)' BACKLOG.md 2>/dev/null | grep -F '| 📋 Todo |' | wc -l | tr -d ' ')
4047
+ build=$(grep -F '| 🔨 In Progress |' BACKLOG.md 2>/dev/null | wc -l | tr -d ' ')
4048
+ printf '%s %s %s 0 0' "$idea" "$backlog" "$build"
4049
+ }
4050
+
4051
+ # ④ DoD AC signal — read [x]/total checkboxes for a US section in feature doc.
4052
+ # Echoes "x/total"; "0/0" if no checkboxes found.
4053
+ _dash_ac_completion() {
4054
+ local feature_link="$1"
4055
+ [[ -z "$feature_link" ]] && { echo "0/0"; return; }
4056
+ local path="${feature_link%%#*}"
4057
+ local anchor="${feature_link##*#}"
4058
+ [[ ! -f "$path" ]] && { echo "0/0"; return; }
4059
+ # Extract the section from <a id="anchor"></a> or ## heading to next ## heading.
4060
+ local section
4061
+ section=$(awk -v anc="$anchor" '
4062
+ BEGIN{in_sec=0}
4063
+ /^<a id="/{
4064
+ gsub(/<a id="|"><\/a>/, "")
4065
+ if ($0 == anc) { in_sec=1; next }
4066
+ }
4067
+ in_sec && /^## /{ if(!started){ started=1; next } else { exit } }
4068
+ in_sec && started { print }
4069
+ in_sec { started_default=1 }
4070
+ ' "$path" 2>/dev/null)
4071
+ [[ -z "$section" ]] && {
4072
+ # Fallback: match heading line containing the anchor pattern directly.
4073
+ section=$(awk -v pat="$anchor" 'BEGIN{IGNORECASE=1}
4074
+ tolower($0) ~ pat && /^## /{p=1;next}
4075
+ p && /^## /{exit}
4076
+ p{print}' "$path" 2>/dev/null)
4077
+ }
4078
+ local done total
4079
+ done=$(echo "$section" | grep -cE '\[x\]' || echo 0)
4080
+ total=$(echo "$section" | grep -cE '\[[ x]\]' || echo 0)
4081
+ printf '%s/%s' "$done" "$total"
4082
+ }
4083
+
4084
+ # ④ DoD CI signal — query gh for HEAD's most-recent run conclusion.
4085
+ # Returns: success | pending | failure | none
4086
+ _dash_ci_status() {
4087
+ command -v gh &>/dev/null || { echo "none"; return; }
4088
+ local commit; commit=$(git rev-parse HEAD 2>/dev/null) || { echo "none"; return; }
4089
+ local slug; slug=$(_gh_repo_slug 2>/dev/null) || true
4090
+ local out
4091
+ if [[ -n "$slug" ]]; then
4092
+ out=$(gh -R "$slug" run list --commit "$commit" --json status,conclusion 2>/dev/null) || { echo "none"; return; }
4093
+ else
4094
+ out=$(gh run list --commit "$commit" --json status,conclusion 2>/dev/null) || { echo "none"; return; }
4095
+ fi
4096
+ [[ -z "$out" || "$out" == "[]" ]] && { echo "none"; return; }
4097
+ local concl status
4098
+ concl=$(echo "$out" | jq -r '.[0].conclusion // ""' 2>/dev/null)
4099
+ status=$(echo "$out" | jq -r '.[0].status // ""' 2>/dev/null)
4100
+ if [[ "$status" == "in_progress" || "$status" == "queued" ]]; then
4101
+ echo "pending"
4102
+ elif [[ "$concl" == "success" ]]; then
4103
+ echo "success"
4104
+ elif [[ -n "$concl" ]]; then
4105
+ echo "failure"
4106
+ else
4107
+ echo "pending"
4108
+ fi
4109
+ }
4110
+
4111
+ # ⑤ Active ALERT count (number of "# ALERT" headings in ALERT.md, 0 if absent).
4112
+ _dash_alert_count() {
4113
+ [[ -f "$_LOOP_ALERT" ]] || { echo 0; return; }
4114
+ grep '^# ALERT' "$_LOOP_ALERT" 2>/dev/null | wc -l | tr -d ' '
4115
+ }
4116
+
4117
+ # ⑤ Pending proposal count — "## PROPOSAL:" entries in PROPOSALS.md.
4118
+ _dash_proposal_count() {
4119
+ [[ -f "PROPOSALS.md" ]] || { echo 0; return; }
4120
+ grep '^## PROPOSAL' PROPOSALS.md 2>/dev/null | wc -l | tr -d ' '
4121
+ }
4122
+
4123
+ # ⑤ Release-ready signal — true iff there are releasable commits since the
4124
+ # latest tag AND the latest brief signals 可发版/Release ready. Releasable =
4125
+ # any commit since the latest tag whose subject does NOT start with the
4126
+ # release-irrelevant prefixes `docs:` or `chore:`. Prevents the flag from
4127
+ # sticking on after a release when only docs rewrites land on top of the tag
4128
+ # (FIX-033 symptom 2).
4129
+ _dash_release_ready() {
4130
+ local latest_tag
4131
+ latest_tag=$(git describe --tags --abbrev=0 2>/dev/null) || return 1
4132
+ local commits_with_code
4133
+ commits_with_code=$(git log "${latest_tag}..HEAD" --pretty=format:%s 2>/dev/null \
4134
+ | grep -cvE '^(docs|chore)(\([^)]*\))?:[[:space:]]' 2>/dev/null \
4135
+ || echo 0)
4136
+ [[ "${commits_with_code:-0}" -gt 0 ]] || return 1
4137
+ local latest
4138
+ latest=$(ls docs/briefs/*.md 2>/dev/null | sort | tail -1 || true)
4139
+ [[ -z "$latest" ]] && return 1
4140
+ grep -qE '✅ 可发版|Release ready' "$latest" 2>/dev/null
4141
+ }
4142
+
4143
+ # ⑥ Latest brief summary — first non-trivial line after frontmatter.
4144
+ _dash_brief_summary() {
4145
+ local latest="$1"
4146
+ [[ -z "$latest" || ! -f "$latest" ]] && return 0
4147
+ awk '
4148
+ NR==1 && /^#/ { next } # skip H1 title
4149
+ /^>/ { next } # skip blockquote
4150
+ /^---$/ { next }
4151
+ /^$/ { next }
4152
+ /^## /{ gsub(/^## */,""); print; exit }
4153
+ /^[^[:space:]]/{ print; exit }
4154
+ ' "$latest" 2>/dev/null | head -1 | cut -c1-60
4155
+ }
4156
+
3421
4157
  _dashboard() {
3422
4158
  local project_path; project_path=$(pwd -P)
3423
4159
  local project_name; project_name=$(basename "$project_path")
3424
4160
  local agent; agent=$(_project_agent)
4161
+ local git_state; git_state=$(_dash_git_status)
4162
+ local is_darwin=false
4163
+ [[ "$(uname)" == "Darwin" ]] && is_darwin=true
3425
4164
 
3426
- echo -e "\n ${BOLD}${CYAN}${project_name}${NC} ${YELLOW}v${VERSION}${NC}\n"
4165
+ # ── ① Identity ─────────────────────────────────────────────────────────────
4166
+ echo ""
4167
+ printf " ${BOLD}${CYAN}%s${NC} ${YELLOW}v%s${NC} ${BOLD}·${NC} agent ${CYAN}%s${NC} ${BOLD}·${NC} git " \
4168
+ "$project_name" "$VERSION" "$agent"
4169
+ case "$git_state" in
4170
+ ✓) printf "${GREEN}✓${NC}\n" ;;
4171
+ dirty) printf "${YELLOW}dirty${NC}\n" ;;
4172
+ *) printf "${YELLOW}%s${NC}\n" "$git_state" ;;
4173
+ esac
4174
+ echo ""
4175
+
4176
+ # ── ② AI 自治 — 三层 × 四道防线 (主视觉) ────────────────────────────────
4177
+ echo -e " ${BOLD}╔══ 🤖 AI 自治 — 三层 × 四道防线 ══════════════════════════╗${NC}"
3427
4178
 
4179
+ # Loop layer
4180
+ local loop_state="not-installed"
3428
4181
  local _dash_loop_paused=false
3429
4182
  [[ -f "$_LOOP_STATE" ]] && grep -q "^status: paused" "$_LOOP_STATE" 2>/dev/null && _dash_loop_paused=true
4183
+ if $is_darwin; then
4184
+ loop_state=$(_launchd_svc_state "loop" "$project_path")
4185
+ else
4186
+ crontab -l 2>/dev/null | grep -q "${_LOOP_TAG}:${project_path}" && loop_state="enabled"
4187
+ fi
4188
+ local active_start active_end loop_minute dream_hour dream_minute brief_hour brief_minute
4189
+ active_start=$(_config_read_int "loop_active_start" "10")
4190
+ active_end=$(_config_read_int "loop_active_end" "18")
4191
+ loop_minute=$(_config_read_int "loop_minute" "$(_loop_derive_minute "$project_path" 0)")
4192
+ dream_hour=$(_config_read_int "loop_dream_hour" "3")
4193
+ dream_minute=$(_config_read_int "loop_dream_minute" "$(_loop_derive_minute "$project_path" 2)")
4194
+ brief_hour=$(_config_read_int "loop_brief_hour" "9")
4195
+ brief_minute=$(_config_read_int "loop_brief_minute" "$(_loop_derive_minute "$project_path" 4)")
3430
4196
 
3431
- local _dash_loop_enabled=false
3432
- if [[ "$(uname)" == "Darwin" ]]; then
3433
- _launchd_is_loaded "$(_launchd_label "loop" "$project_path")" && _dash_loop_enabled=true
4197
+ local loop_badge loop_sched
4198
+ loop_sched=$(printf "every :%02d active %02d:00–%02d:00" "$loop_minute" "$active_start" "$active_end")
4199
+ case "$loop_state" in
4200
+ enabled) loop_badge="${GREEN}● enabled${NC}" ;;
4201
+ installed-off) loop_badge="${YELLOW}⚠ off${NC}" ;;
4202
+ *) loop_badge="${RED}○ missing${NC}" ;;
4203
+ esac
4204
+ $_dash_loop_paused && loop_badge="${YELLOW}⏸ paused${NC}"
4205
+ printf " Loop Layer %b %s\n" "$loop_badge" "$loop_sched"
4206
+
4207
+ # Loop "Now:" line — current in-progress story, if any.
4208
+ local in_prog; in_prog=$(_dash_in_progress_story)
4209
+ if [[ -n "$in_prog" ]]; then
4210
+ local p_id p_desc
4211
+ p_id=${in_prog%%|*}
4212
+ p_desc=$(echo "$in_prog" | awk -F'|' '{print $2}')
4213
+ printf " Now: ${BOLD}🔨 %s${NC} %s\n" "$p_id" "$p_desc"
3434
4214
  else
3435
- crontab -l 2>/dev/null | grep -q "${_LOOP_TAG}:${project_path}" && _dash_loop_enabled=true
4215
+ printf " Now: ${DIM:-}idle${NC}\n"
3436
4216
  fi
3437
- if $_dash_loop_paused; then
3438
- echo -e " Loop ${YELLOW}⏸ paused${NC} run: roll loop resume"
3439
- elif $_dash_loop_enabled; then
3440
- echo -e " Loop ${GREEN}● on${NC} Agent: ${CYAN}${agent}${NC}"
3441
- if [[ "$(uname)" == "Darwin" ]]; then
3442
- local active_start active_end loop_minute dream_hour dream_minute brief_hour brief_minute
3443
- active_start=$(_config_read_int "loop_active_start" "10")
3444
- active_end=$(_config_read_int "loop_active_end" "18")
3445
- loop_minute=$(_config_read_int "loop_minute" "$(_loop_derive_minute "$project_path" 0)")
3446
- dream_hour=$(_config_read_int "loop_dream_hour" "3")
3447
- dream_minute=$(_config_read_int "loop_dream_minute" "$(_loop_derive_minute "$project_path" 2)")
3448
- brief_hour=$(_config_read_int "loop_brief_hour" "9")
3449
- brief_minute=$(_config_read_int "loop_brief_minute" "$(_loop_derive_minute "$project_path" 4)")
3450
- local loop_sched dream_sched brief_sched
3451
- loop_sched=$(printf "every hour :%02d active %02d:00–%02d:00" "$loop_minute" "$active_start" "$active_end")
3452
- dream_sched=$(printf "%02d:%02d" "$dream_hour" "$dream_minute")
3453
- brief_sched=$(printf "%02d:%02d" "$brief_hour" "$brief_minute")
3454
- local svcs=("loop" "dream" "brief")
3455
- local scheds=("$loop_sched" "$dream_sched" "$brief_sched")
3456
- for i in "${!svcs[@]}"; do
3457
- local svc="${svcs[$i]}" schedule="${scheds[$i]}"
3458
- local state; state=$(_launchd_svc_state "$svc" "$project_path")
3459
- case "$state" in
3460
- enabled) printf " ${GREEN}%-8s ● enabled${NC} (%s)\n" "$svc" "$schedule" ;;
3461
- installed-off) printf " ${YELLOW}%-8s ⚠ off${NC} (%s) run: roll loop on\n" "$svc" "$schedule" ;;
3462
- not-installed) printf " ${RED}%-8s missing${NC} (%s) run: roll setup\n" "$svc" "$schedule" ;;
3463
- esac
3464
- done
3465
- fi
4217
+
4218
+ # last TCR + today count
4219
+ local last_tcr_min today_tcr
4220
+ last_tcr_min=$(_dash_last_tcr_minutes)
4221
+ today_tcr=$(_dash_tcr_today_count)
4222
+ if [[ -n "$last_tcr_min" ]]; then
4223
+ printf " last TCR ${CYAN}%smin${NC} ago · ${CYAN}%s${NC} micro-commits today\n" "$last_tcr_min" "$today_tcr"
4224
+ else
4225
+ printf " no tcr commits yet\n"
4226
+ fi
4227
+
4228
+ # Dream layer
4229
+ local dream_state="not-installed"
4230
+ $is_darwin && dream_state=$(_launchd_svc_state "dream" "$project_path")
4231
+ local dream_badge
4232
+ case "$dream_state" in
4233
+ enabled) dream_badge="${GREEN}● enabled${NC}" ;;
4234
+ installed-off) dream_badge="${YELLOW}⚠ off${NC}" ;;
4235
+ *) dream_badge="${RED}○ missing${NC}" ;;
4236
+ esac
4237
+ printf " Dream Layer %b %02d:%02d\n" "$dream_badge" "$dream_hour" "$dream_minute"
4238
+ local dream_hours refac_pending
4239
+ dream_hours=$(_dash_last_dream_hours)
4240
+ refac_pending=$(_dash_refactor_pending)
4241
+ if [[ -n "$dream_hours" ]]; then
4242
+ printf " Last scan ${CYAN}%sh${NC} ago ${CYAN}%s${NC} REFACTOR queued\n" "$dream_hours" "$refac_pending"
3466
4243
  else
3467
- echo -e " Loop ${YELLOW}○ off${NC} run: roll loop on"
4244
+ printf " no scan yet → ${CYAN}%s${NC} REFACTOR queued\n" "$refac_pending"
4245
+ fi
4246
+
4247
+ # Peer layer
4248
+ local peer; peer=$(_dash_last_peer)
4249
+ printf " Peer Layer ${GREEN}● ready${NC} on complexity=large\n"
4250
+ if [[ -n "$peer" ]]; then
4251
+ local peer_res peer_days
4252
+ peer_res=${peer%%|*}
4253
+ peer_days=${peer##*|}
4254
+ printf " Last call ${CYAN}%sd${NC} ago · %s\n" "$peer_days" "$peer_res"
4255
+ else
4256
+ printf " Last call —\n"
4257
+ fi
4258
+
4259
+ # 四道防线
4260
+ echo -e " ${BOLD}─ 四道防线 ─${NC}"
4261
+ local def_tcr="${RED}○${NC}" def_review="${GREEN}●${NC}" def_spar="${YELLOW}○${NC}" def_sentinel="${YELLOW}○ off${NC}"
4262
+ if [[ -n "$last_tcr_min" ]]; then
4263
+ def_tcr="${GREEN}● ${last_tcr_min}min${NC}"
3468
4264
  fi
3469
- [[ -f "$_LOOP_ALERT" ]] && echo -e " ${RED}⚠ ALERT — run: roll alert${NC}"
4265
+ printf " TCR %b Spar %b Auto Review %b Sentinel %b\n" \
4266
+ "$def_tcr" "$def_spar" "$def_review" "$def_sentinel"
4267
+ echo -e " ${BOLD}╚══════════════════════════════════════════════════════════╝${NC}"
4268
+ echo ""
4269
+
4270
+ # ── ③ Pipeline 全景 ────────────────────────────────────────────────────────
4271
+ read -r pl_idea pl_backlog pl_build pl_verify pl_release <<< "$(_dash_pipeline_counts)"
4272
+ local build_color="${DIM:-}"
4273
+ (( pl_build > 0 )) && build_color="${BOLD}${YELLOW}"
4274
+ printf " ${BOLD}📦 Pipeline${NC} Idea %s ▸ Backlog %s ▸ Build %b%s🔨${NC} ▸ Verify %s ▸ Release %s\n" \
4275
+ "$pl_idea" "$pl_backlog" "$build_color" "$pl_build" "$pl_verify" "$pl_release"
4276
+ echo ""
3470
4277
 
3471
- # Backlog summary
3472
- if [[ -f "BACKLOG.md" ]]; then
3473
- local pending_count
3474
- pending_count=$(grep -cF '| 📋 Todo |' "BACKLOG.md" 2>/dev/null) || pending_count=0
3475
- if (( pending_count > 0 )); then
3476
- echo -e " Backlog ${YELLOW}${pending_count} pending${NC} run: roll backlog"
4278
+ # ── ④ Current Focus · DoD (仅当 Build > 0) ──────────────────────────────
4279
+ if [[ -n "$in_prog" && "$pl_build" -gt 0 ]]; then
4280
+ local p_id p_desc p_link
4281
+ p_id=${in_prog%%|*}
4282
+ p_desc=$(echo "$in_prog" | awk -F'|' '{print $2}')
4283
+ p_link=$(echo "$in_prog" | awk -F'|' '{print $3}')
4284
+ local ac_ratio; ac_ratio=$(_dash_ac_completion "$p_link")
4285
+ local ac_done="${ac_ratio%%/*}" ac_total="${ac_ratio##*/}"
4286
+ local ac_badge ci_badge
4287
+ if [[ "$ac_total" != "0" && "$ac_done" == "$ac_total" ]]; then
4288
+ ac_badge="${GREEN}✓ AC${NC}"
3477
4289
  else
3478
- echo -e " Backlog ${GREEN} clear${NC}"
4290
+ ac_badge="${YELLOW} AC ${ac_done}/${ac_total}${NC}"
3479
4291
  fi
4292
+ local ci_state; ci_state=$(_dash_ci_status)
4293
+ case "$ci_state" in
4294
+ success) ci_badge="${GREEN}✓ CI${NC}" ;;
4295
+ pending) ci_badge="${YELLOW}… CI${NC}" ;;
4296
+ failure) ci_badge="${RED}✗ CI${NC}" ;;
4297
+ *) ci_badge="${YELLOW}○ CI${NC}" ;;
4298
+ esac
4299
+ printf " ${BOLD}📊 Current Focus · DoD${NC}\n"
4300
+ printf " 🔨 ${BOLD}%s${NC} %s\n" "$p_id" "$p_desc"
4301
+ printf " [%b] [%b]\n" "$ac_badge" "$ci_badge"
4302
+ printf " ${YELLOW}其余 4 项 DoD 信号源待接入:see US-AUTO-030/031, IDEA-013/014${NC}\n"
4303
+ echo ""
3480
4304
  fi
3481
4305
 
3482
- local briefs_dir="docs/briefs"
3483
- local latest; latest=$(ls "${briefs_dir}"/*.md 2>/dev/null | sort | tail -1 || true)
3484
- if [[ -n "$latest" ]]; then
3485
- local mod_time now age
3486
- mod_time=$(stat -c %Y "$latest" 2>/dev/null || stat -f %m "$latest" 2>/dev/null || echo 0)
3487
- now=$(date +%s); age=$(( (now - mod_time) / 3600 ))
3488
- local readiness; readiness=$(awk '/^## Release Readiness/{found=1;next} found && /[^[:space:]]/{gsub(/\*\*/,""); print; exit}' "$latest" 2>/dev/null || true)
3489
- local done_count; done_count=$(grep -c '| Story\|| Fix\|| Refactor' "$latest" 2>/dev/null) || done_count=0
3490
- echo -e "\n Brief (${age}h ago) ${readiness:+${CYAN}${readiness}${NC}}"
3491
- [[ "$done_count" -gt 0 ]] && echo -e " This cycle: ${done_count} items completed"
4306
+ # ── ⑤ Human × AI 介入区 ───────────────────────────────────────────────────
4307
+ local alerts proposals release_ready
4308
+ alerts=$(_dash_alert_count); alerts=${alerts//[^0-9]/}; alerts=${alerts:-0}
4309
+ proposals=$(_dash_proposal_count); proposals=${proposals//[^0-9]/}; proposals=${proposals:-0}
4310
+ release_ready=false; _dash_release_ready && release_ready=true
4311
+ printf " ${BOLD}👤 需要你介入${NC}\n"
4312
+ if (( alerts == 0 )) && (( proposals == 0 )) && ! $release_ready; then
4313
+ printf " ${GREEN}✓ AI 自驱中 无需介入${NC}\n"
3492
4314
  else
3493
- echo -e "\n No brief yet run: roll brief"
4315
+ (( alerts > 0 )) && printf " ${RED}⚠ %s ALERT${NC} run: roll alert\n" "$alerts"
4316
+ (( proposals > 0 )) && printf " ${YELLOW}📋 %s PROPOSAL${NC} see: PROPOSALS.md\n" "$proposals"
4317
+ $release_ready && printf " ${GREEN}✓ Release ready${NC} run: roll release\n"
3494
4318
  fi
4319
+ echo ""
3495
4320
 
4321
+ # ── ⑥ Schedules & Last Brief ──────────────────────────────────────────────
4322
+ printf " ${BOLD}⏰ Schedules & Last Brief${NC}\n"
4323
+ printf " loop :%02d · dream %02d:%02d · brief %02d:%02d\n" \
4324
+ "$loop_minute" "$dream_hour" "$dream_minute" "$brief_hour" "$brief_minute"
4325
+ local latest_brief; latest_brief=$(ls docs/briefs/*.md 2>/dev/null | sort | tail -1 || true)
4326
+ if [[ -n "$latest_brief" ]]; then
4327
+ local mod_time now age summary
4328
+ mod_time=$(stat -c %Y "$latest_brief" 2>/dev/null || stat -f %m "$latest_brief" 2>/dev/null || echo 0)
4329
+ now=$(date +%s); age=$(( (now - mod_time) / 3600 ))
4330
+ summary=$(_dash_brief_summary "$latest_brief")
4331
+ printf " Brief ${CYAN}%sh${NC} ago — %s\n" "$age" "${summary:-—}"
4332
+ else
4333
+ printf " Brief: ${YELLOW}none yet${NC} — run: roll brief\n"
4334
+ fi
3496
4335
  echo ""
3497
4336
  }
3498
4337