Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion devloop/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "devloop",
"version": "0.2.2",
"version": "0.2.3",
"description": "Dev-loop workflow, native-first rebuild: git/PR (GitHub + GitLab) + cwd-aware enter + Board-managed context delivery + lint/test gates. Built on native Claude Code events (CwdChanged / PostCompact / FileChanged / monitors).",
"author": {
"name": "qiankunli",
Expand Down
2 changes: 1 addition & 1 deletion devloop/.codex-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "devloop",
"version": "0.2.2",
"version": "0.2.3",
"description": "Dev-loop workflow: git/PR (GitHub + GitLab) + Board-managed workspace/repo context delivery + lint/test gates + execution-level hard intercepts for Claude Code and Codex.",
"author": {
"name": "qiankunli",
Expand Down
3 changes: 2 additions & 1 deletion devloop/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,8 @@ devloop/
│ ├── posttool_git_refresh.py # PostToolUse:git 状态命令后刷新 branch 段
│ ├── sessionend_release.py # SessionEnd:释放本 session 的 owner 锁(正常退出路径)
│ └── pretool_*.py # 命令/编辑硬拦截(guard harness;含 owner 锁与裸 worktree add 拦截)
├── scripts/ # 工作流驱动 adapter:enter / commit_flow + smart_* / pr / release / lint/test/review / init_*
├── ui/board/ # Board UI adapter:稳定状态 projection + Claude status line / Codex tmux sidecar
├── scripts/ # 工作流驱动 adapter:enter / commit_flow + smart_* / pr / release / lint/test/review / init_* / Claude Board setup
├── monitors/monitors.json # ★PR-sweep 后台轮询(替代 hook 心跳 scheduler)
├── commands/ # slash:enter / gcam / gcamp / gcampr(lint/test 归 skill,gate 自动触发)
├── skills/ # git-ops / gcam / gcamp / gcampr / fix-lint / run-test
Expand Down
20 changes: 19 additions & 1 deletion devloop/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ devloop 通过两个控制杠杆落实这条生命周期:**Board 上下文投

- **PR/MR 生命周期入口**:`/enter` 选择 repo/branch;多 session 并发时,`--worktree <tag>` 将 branch 以隔离 checkout 形态展开,并统一处理基线、依赖准备和安全清理。`/gcam`、`/gcamp`、`/gcampr` 依次覆盖 commit、push 与创建/复用 PR/MR;已有 PR/MR 的冲突处理走可恢复的 `smart_rebase.sh start/continue/finish`,以 rebase 前保存的远端 SHA 做精确 `force-with-lease`。新工作从目标分支建立干净基线,最终 merge 始终留给人。
- **component 感知**(多代码目录仓):一个 git 仓可能有多个自带工具链、可独立 lint/test 的目录——`server/` + `cli/`、`packages/*`、`cmd/*`。devloop 按**本次改动**决定跑哪些:改了 `cli/**` 就只跑 `cli` 的 lint/test,不静默回落仓根或 `server/`;改动跨多个 component 就都跑;clean tree 从仓根发起时枚举**全部** component(绝不替你猜一个)。验证戳也按 component 记——「A 过 B 挂」不会被记成整仓已验。术语见 [`CONCEPTS.md`](./CONCEPTS.md)。
- **Board 与 PR/MR 感知**:workspace/repo 的 `.devloop/` 保存当前 branch、工作区、近期 PR/MR、验证和 session 归属等结构化运行态;hook 与 monitor 持续刷新事实,Board 按相关性选择紧凑内容并决定 session/turn 投递。状态与投递游标都自动排除在 git 提交之外。
- **Board 与 PR/MR 感知**:workspace/repo 的 `.devloop/` 保存当前 branch、工作区、近期 PR/MR、验证和 session 归属等结构化运行态;hook 与 monitor 持续刷新事实,Board 按相关性选择紧凑内容并决定 session/turn 投递。Claude 可用原生 status line 常驻展示工作上下文与健康状态;Codex 在 tmux 中使用三行 sidecar。状态与投递游标都自动排除在 git 提交之外。
- **硬拦截**(PreToolUse deny):保护分支 commit/push、`git add -A`、直接 `git worktree add`、过期分支(PR 已 merged/closed)改文件、别的 session 占用的 checkout 上切分支或改文件(引导 worktree)、工作区根跑子项目命令、裸 `pytest`、uv 项目 `pip install`、编辑 `requirements.txt`、`lifecycle.pre_commit` 含 lint 时 lint 过期的裸 `git commit` gate。
- **自动进项目**:`cd` 进子项目时(`CwdChanged`)自动刷新上下文、浮现 AGENTS.md References,无需手动 `/enter`。
- **生命周期 hook**:`pre_commit / post_commit / pre_mr / post_mr` 四相位可挂 hook,挂哪相位由 config 决定;两类——**inline 门禁**(失败挡 commit/MR)与 **signal hook**(advisory、后台跑、不挡)。当前内置三个:`lint`、`test`(门禁),`review`(signal——后台跑 [ocr](https://github.com/alibaba/open-code-review) 审全量改动、结果回流会话、有开放 MR 时发评论)。机制见 [`docs/lifecycle-hooks.md`](./docs/lifecycle-hooks.md);code-review 细节见 [`docs/code-review.md`](./docs/code-review.md)。
Expand All @@ -28,6 +28,7 @@ devloop 通过两个控制杠杆落实这条生命周期:**Board 上下文投
| `/gcam "<msg>"` | 只 commit |
| `/gcamp "<msg>"` | commit + push |
| `/gcampr "<msg>" [--branch <name>]` | commit + push + 建/复用 PR/MR |
| `/devloop:setup-board` | 为 Claude Code 安装原生 Board status line |

lint / test 无独立 slash 命令:正常由 gcam* 的 `pre_commit` gate 自动触发;手动跑走 fix-lint / run-test skill(自然语言"修下 lint"/"跑下测试")。两条路径**共用同一套 component 选择**,所以 gate 替你跑的和你手动跑的永远是同一批 component;每次执行会自述本轮选了哪些 component、为什么(`changed files under: cli` / `clean tree, all components: …`),选错一眼可见,不用等错的测试跑完再猜。

Expand All @@ -52,11 +53,25 @@ bash <PLUGIN_ROOT>/scripts/smart_rebase.sh finish --repo <name|path>

运行时要求:**Python 3.10+**。devloop launcher 会从 PATH 自动选择首个满足版本的 `python3`、
`python` 或带版本号的 `python3.x`;需要固定解释器时设置 `DEVLOOP_PYTHON`。
Claude Board HUD 使用 Claude Code 原生 status line,不依赖 tmux。安装 plugin 后运行一次
`/devloop:setup-board`;安装器会备份 `~/.claude/settings.json`,发现已有非 devloop status line
时不会覆盖,只有得到明确确认后才允许替换。

Codex 暂无自定义 status line provider,当前 sidecar 依赖
[tmux](https://github.com/tmux/tmux)。devloop 会自动检测:未安装 tmux 或 CLI 不在 tmux
会话中时不启动 sidecar,其余能力不受影响。macOS 如需 Codex HUD,从 tmux 内启动:

```bash
brew install tmux
tmux
# 然后在 tmux 会话中启动 codex
```

```
# Claude Code 内
/plugin marketplace add https://github.com/qiankunli/devloop.git
/plugin install devloop@devloop
/devloop:setup-board
```

Codex:
Expand Down Expand Up @@ -109,6 +124,9 @@ devloop 对外部的依赖(连哪个 forge、用什么 token)+ 工作区注
// 这里一般留空;也可用 init_workspace.py 显式补充。
"workspaces": [],

// Board HUD 总开关:Claude 原生 status line 与 Codex tmux sidecar 共用。
"board": { "hud": { "enabled": true } },

// 代码评审平台,按 repo 的 origin host 索引。PR/MR 创建与状态注入需要 token;
// 没有匹配 token 时相关功能静默跳过,其余照常。provider 由 host 推断,type 可覆写。
"forges": {
Expand Down
18 changes: 18 additions & 0 deletions devloop/commands/setup-board.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
description: Configure Claude Code's native devloop Board status line
---

Configure the native Claude Code Board status line using the plugin's installer:

```bash
"${CLAUDE_PLUGIN_ROOT}/scripts/python" "${CLAUDE_PLUGIN_ROOT}/scripts/setup_claude_board.py" \
--plugin-root "${CLAUDE_PLUGIN_ROOT}"
```

If the installer exits with `CONFLICT`, an existing non-devloop `statusLine` was found.
Ask the user whether to replace it. Only after explicit confirmation, rerun the same
command with `--replace`. Never edit or overwrite the existing status line manually.

On success, tell the user Claude reloads settings automatically and the Board should
appear after the next interaction. The installer prints a backup path when it changes
an existing settings file.
2 changes: 2 additions & 0 deletions devloop/config/config.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@

"worktree": { "keep_recent": 5 },

"board": { "hud": { "enabled": true } },

"lifecycle": {
"default": { "pre_commit": [], "post_commit": [], "pre_mr": [], "post_mr": [] },
"repos": {
Expand Down
25 changes: 21 additions & 4 deletions devloop/docs/board.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,11 @@ focus,把事实投影为共享 `Board`,再得到相关 `BoardView`。每次
已经变化的部分。

UI 读取 `BoardRuntime.snapshot()` 得到 JSON-ready 的结构化 view;读取不经过 prompt
renderer,也不改变 delivery receipt。下一阶段的 Board UI 只消费这个 read surface,
不直接拼接各状态源。
renderer,也不改变 delivery receipt。Board HUD 的两个 adapter 消费同一个 snapshot:
Claude 原生 `statusLine.command` 每次从官方 stdin 取得当前 `cwd + session_id`,展示
focus 与健康状态两行;Codex 在 tmux 中启动时,`SessionStart` 创建固定三行只读
sidecar,前两行相同,第三行展示 watcher 观察到的最近一次 snapshot 变化。HUD 不直接
拼接各状态源,也不参与 prompt receipt。

投递游标按 session 存在 `.devloop/board/sessions/`。`PostCompact` 会让状态条目
在下一轮重放;已经消费的 event 不会因压缩再次触发。只有 UI channel 的条目不经过
Expand All @@ -47,8 +50,8 @@ Board 只投递当前工作所需信息。当前分支的 PR 会进入 branch
token。每个条目独立去重,某个事实变化不会捎带未变化的整块上下文。

相关性属于 `BoardView`,投递节奏属于 `DeliveryPolicy`;事实生产者不选择 channel、
prompt scope 或重放行为。Requirement 当前仅以兼容卡片加入 Board其独立 provider
与领域抽象留到 Board UI 完成后再推进
prompt scope 或重放行为。Requirement 当前仅以兼容卡片加入 Board其独立 provider
与领域抽象留到 requirement 域推进时处理,不以某个 UI adapter 是否完成为前置

### 投递状态不是业务事实

Expand All @@ -62,3 +65,17 @@ validation 等事实仍由原 owner 的 segment 提供。
branch、dirty、validation 等描述“现在在哪里”,compaction 后必须重放;review
结果和待办提醒描述“发生过什么 / 请做什么”,按身份限次投递,避免 agent
重复处理同一件事。

### HUD 是展示面,不是第二条状态总线

HUD 固定保留工作上下文与当前健康状态;失败或阻塞事实始终留在健康状态行,不能只作为
会被覆盖的实时消息出现。Codex sidecar 的第三行由常驻 watcher 比较前后两帧 Board item
revision 得出,仅在进程内保留最新一条,不另建事件 ledger;HUD 重启后从 “watching
Board”重新开始。Claude 每次 status line 刷新都会启动新进程,因此只渲染两条稳定状态,
不为凑齐第三行额外持久化一套 UI 事件游标。

Claude 原生 status line 通过一次性 `/devloop:setup-board` 配置;安装器备份 settings,
发现其它 status line 时必须经用户确认才替换。tmux 只补 Codex 尚未开放给 plugin 的
自定义展示位置:HUD pane 以 CLI session 与 leader pane 标识,重复 SessionStart 复用同一
pane,leader 回到 shell 后自动退出。非 tmux Codex 会话安静降级,不影响 Board 的 prompt
投递;两端均可通过 `board.hud.enabled=false` 显式关闭。
5 changes: 3 additions & 2 deletions devloop/domain/board/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,9 +173,10 @@ def signature(self) -> str:
return hashlib.sha1(raw.encode("utf-8")).hexdigest()

def to_dict(self) -> dict[str, object]:
"""Return the presentation-neutral shape consumed by the future Board UI."""
"""Return the presentation-neutral shape consumed by Board UI adapters."""
return {
"id": self.id,
"revision": self.signature,
"type": self.type.value,
"kind": self.kind.value,
"scope": _json_ready(self.scope),
Expand Down Expand Up @@ -228,7 +229,7 @@ def to_dict(self) -> dict[str, object]:

@dataclass(frozen=True)
class Board:
"""Shared structured facts for prompt delivery today and collaborative UI later."""
"""Shared structured facts for prompt delivery and UI projections."""

root: str
items: tuple[BoardItem, ...]
Expand Down
19 changes: 19 additions & 0 deletions devloop/hooks/board_hud_start.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
#!/usr/bin/env python3
"""SessionStart side effect: best-effort automatic Board HUD inside tmux."""
from __future__ import annotations

import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent.parent))

from hooks import hook_io # noqa: E402
from ui.board.tmux import ensure_hud_pane # noqa: E402


def handle(inp: hook_io.HookInput) -> None:
ensure_hud_pane(inp.cwd, inp.session_id)


if __name__ == "__main__":
raise SystemExit(hook_io.observe(handle))
6 changes: 6 additions & 0 deletions devloop/hooks/hooks.codex.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@
"command": "\"${CLAUDE_PLUGIN_ROOT}/scripts/python\" \"${CLAUDE_PLUGIN_ROOT}/hooks/sessionstart_codex_init.py\"",
"timeout": 10,
"statusMessage": "Initializing devloop session"
},
{
"type": "command",
"command": "\"${CLAUDE_PLUGIN_ROOT}/scripts/python\" \"${CLAUDE_PLUGIN_ROOT}/hooks/board_hud_start.py\"",
"timeout": 5,
"statusMessage": "Starting Board HUD"
}
]
}
Expand Down
12 changes: 11 additions & 1 deletion devloop/lib/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@
# load() 的距离分层覆盖——repo 的 .devloop/config.json 写一份 worktree.keep_recent 即覆盖全局。
# 任何规范入口建 worktree 时都消费它(worktree.create_or_reuse)。
"worktree": {"keep_recent": 5},
# Board HUD is a read-only display surface. It starts automatically only when the
# current CLI already runs inside tmux; false is an explicit opt-out.
"board": {"hud": {"enabled": True}},
}

_LOCAL_NAME = ".devloop"
Expand Down Expand Up @@ -98,7 +101,7 @@ def plugin_root() -> Path:
env_root = os.environ.get("PLUGIN_ROOT") or os.environ.get("CLAUDE_PLUGIN_ROOT")
if env_root:
return Path(env_root)
return Path(__file__).resolve().parent.parent.parent
return Path(__file__).resolve().parent.parent


# ── read / write ─────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -201,6 +204,13 @@ def worktree(repo_dir: str | Path | None = None) -> dict:
return load(repo_dir).get("worktree") or {}


def board_hud(repo_dir: str | Path | None = None) -> dict:
"""Resolved Board HUD settings shared by native and sidecar UI adapters."""
board = load(repo_dir).get("board") or {}
hud = board.get("hud") if isinstance(board, dict) else None
return hud if isinstance(hud, dict) else {}


def notify(repo_dir: str | Path | None = None) -> dict:
"""已解析的 `notify` section。`notify should-arm` 决议器读 `channels`(bool):本会话是否已有
常驻 `channel all` 覆盖——true → should-arm 报 skip(交给 channel、不 arm waiter),默认 false
Expand Down
141 changes: 141 additions & 0 deletions devloop/scripts/board_hud.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
#!/usr/bin/env python3
"""Render Board state for native status lines and the tmux sidecar."""
from __future__ import annotations

import argparse
import json
import os
import shutil
import signal
import sys
import time
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent.parent))

from domain.board import BoardRuntime # noqa: E402
from lib import config # noqa: E402
from ui.board.hud import ( # noqa: E402
HudPulseTracker,
frame_from_snapshot,
render_frame,
render_statusline,
)
from ui.board.tmux import LEADER_ENV, pane_command # noqa: E402


def _runtime(cwd: str, session_id: str) -> BoardRuntime | None:
return BoardRuntime.resolve(cwd, session_id)


def _snapshot(cwd: str, session_id: str) -> dict:
runtime = _runtime(cwd, session_id)
return runtime.snapshot() if runtime else {"root": cwd, "focus": None, "items": []}


def _watch_text(cwd: str, session_id: str, tracker: HudPulseTracker) -> str | None:
"""Keep the last visible frame when a transient Board read is unavailable."""
try:
snapshot = _snapshot(cwd, session_id)
frame = frame_from_snapshot(snapshot, tracker)
return render_frame(frame, shutil.get_terminal_size((120, 3)).columns, True)
except (OSError, ValueError):
return None


def _shell_commands(environ: dict[str, str] | None = None) -> set[str]:
environ = os.environ if environ is None else environ
commands = {"bash", "dash", "fish", "sh", "zsh"}
configured = Path(environ.get("SHELL", "")).name
if configured:
commands.add(configured)
return commands


def _args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Render devloop's Board HUD")
parser.add_argument("--watch", action="store_true")
parser.add_argument("--json", action="store_true")
parser.add_argument("--claude-statusline", action="store_true")
parser.add_argument("--cwd", default=str(Path.cwd()))
parser.add_argument("--session-id", default=os.environ.get("DEVLOOP_HUD_SESSION", ""))
parser.add_argument("--leader-pane", default=os.environ.get(LEADER_ENV, ""))
return parser.parse_args()


def main() -> int:
args = _args()
if args.claude_statusline:
try:
payload = json.loads(sys.stdin.read() or "{}")
except json.JSONDecodeError:
return 0
workspace = payload.get("workspace") if isinstance(payload, dict) else None
cwd = (
workspace.get("current_dir")
if isinstance(workspace, dict) and workspace.get("current_dir")
else payload.get("cwd") if isinstance(payload, dict) else None
) or args.cwd
session_id = (
str(payload.get("session_id") or args.session_id)
if isinstance(payload, dict)
else args.session_id
)
if not config.board_hud(cwd).get("enabled", True):
return 0
runtime = _runtime(cwd, session_id)
if runtime is None:
return 0
columns = os.environ.get("COLUMNS", "")
width = int(columns) if columns.isdigit() else shutil.get_terminal_size((120, 2)).columns
frame = frame_from_snapshot(runtime.snapshot())
print(render_statusline(frame, max(1, width - 4), not os.environ.get("NO_COLOR")))
return 0
if args.json:
print(json.dumps(_snapshot(args.cwd, args.session_id), indent=2, ensure_ascii=False))
return 0
if not args.watch:
frame = frame_from_snapshot(_snapshot(args.cwd, args.session_id))
print(render_frame(frame, shutil.get_terminal_size((120, 3)).columns, sys.stdout.isatty()))
return 0

stopped = False

def stop(_signum=None, _frame=None):
nonlocal stopped
stopped = True

signal.signal(signal.SIGINT, stop)
signal.signal(signal.SIGTERM, stop)
tracker = HudPulseTracker()
inactive_leader_ticks = 0
shell_commands = _shell_commands()
sys.stdout.write("\x1b[?25l\x1b[2J\x1b[H")
sys.stdout.flush()
try:
while not stopped:
if args.leader_pane:
leader_command = pane_command(args.leader_pane)
if leader_command is None:
break
inactive_leader_ticks = (
inactive_leader_ticks + 1
if Path(leader_command).name in shell_commands
else 0
)
if inactive_leader_ticks >= 3:
break
text = _watch_text(args.cwd, args.session_id, tracker)
if text is not None:
lines = "\n".join("\x1b[2K" + line for line in text.splitlines())
sys.stdout.write("\x1b[H" + lines + "\x1b[J")
sys.stdout.flush()
time.sleep(1)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 devloop code-review · seed-2.1-pro

The watch loop does not catch exceptions from _snapshot(), frame_from_snapshot(), or render_frame(). If BoardRuntime.resolve() raises (e.g., due to corrupted state files, permission errors, or transient I/O failures), the HUD process crashes and the dashboard disappears. The finally block does restore the cursor, but the service still stops unexpectedly. Consider wrapping the snapshot + render logic in a try/except that falls back to a minimal error display and continues the loop.

ccr:fp=cfd64e2511ae

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ccr:label=minor — HUD sidecar 是只读观察面;状态暂时不可读会让 pane 退出。已在 scripts/board_hud.py::_watch_text 捕获 OSError/ValueError 并保留上一帧。

finally:
sys.stdout.write("\x1b[?25h")
sys.stdout.flush()
return 0


if __name__ == "__main__":
raise SystemExit(main())
Loading