From 5db738bc1189fabaa7dd67eedcc2190484384a81 Mon Sep 17 00:00:00 2001 From: shusann01116 <26602565+shusann01116@users.noreply.github.com> Date: Thu, 23 Jul 2026 12:07:07 +0900 Subject: [PATCH 1/7] feat: add author_type to all local review action events fude-watch tails the local review JSONL to react to new comments, and needs to mechanically filter events by writer (human vs agent). author_type previously existed only on comment/reply events; extend it to edit/move/resolve/reopen/delete/viewed so filtering is uniform across every action event. The session header stays untouched since it is session metadata, not a user action. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 2 +- README.md | 8 ++++++-- doc/fude.txt | 6 +++++- lua/fude/local/store.lua | 15 ++++++++++----- tests/fude/local_store_spec.lua | 15 +++++++++++++++ 5 files changed, 37 insertions(+), 9 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 8e18482..0de0c9e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,7 +33,7 @@ All plugin code lives under `lua/fude/`. The plugin entry point is `plugin/fude. - **`util.lua`** — Shared utility functions. `is_null(v)` checks for both `nil` and `vim.NIL` (JSON null compatibility across Neovim 0.11/0.12). `all_comments_resolved(comments)` checks whether every comment in a list is resolved (false for empty lists); shared by `comments/data.lua`, `ui/format.lua`, and `ui/extmarks.lua` so the "all resolved" rule stays consistent. - **`drafts.lua`** — Local on-disk storage for *unsubmitted* comment input ("drafts"), distinct from the in-session GitHub pending review (`state.pending_comments`). Persists to `stdpath("state")/fude/drafts.json` so input can be paused (jump back to the diff) and resumed later, across PR switches and Neovim restarts. Pure functions (no state/IO): `make_draft_key(repo, pr_number, kind, ...)` builds opaque keys (kind = `line`/`suggest`/`issue`/`reply`/`edit`), `repo_slug(pr_url)`, `serialize`/`deserialize` (corrupt JSON → `{}`), `prune(drafts, now, retention_days)`. IO/state helpers: `current_key(kind, ...)` (derives repo/PR from `config.state`), `enabled()` (reads `config.opts.drafts.enabled`), `load`/`save`/`get`/`set`/`remove` (file is the source of truth — no `config.state` field; `set` with empty body removes, `load` prunes by `config.opts.drafts.retention_days`), `file_markers(rel_path)` (one load → `{ lines, comment_ids }` used by `ui/extmarks.lua` to render the `draft` indicator: `line`/`suggest` drafts mark their own line, `reply`/`edit` drafts mark the targeted comment's line via the comment map), and `list_drafts()` (one load → array of `{ key, kind, body, saved_at (normalized to ISO), path?, start_line?, end_line?, comment_id? }` for the active PR, used by the comment browser; skips non-string bodies). `M._dir` overrides the storage dir for tests. Wired into comment creation/reply/edit in `comments.lua`, PR-level comments in `overview.lua`, and the `comment_browser` lower pane (prefill on entry navigation via `lower_key_for_entry`, save/discard on close, remove on submit) and list (draft rows + markers via `merge_draft_entries`). Comment input UIs receive `opts.allow_draft`/`initial_lines`/`on_save_draft`/`on_discard_draft` and report the close action (`submit`/`draft`/`discard`/`cancel`). - **`diff.lua`** — Local git operations (sync). Gets repo root, converts paths to repo-relative, retrieves base branch file content via `git show`, generates file diffs, and computes merge-base for gitsigns (avoids merge commit noise). Falls back to `origin/` when local ref isn't available. Also provides local-session helpers: `get_current_branch`, `get_head_sha`, `get_empty_tree` (empty-tree hash, diff base for zero-commit repos), `get_upstream_ref` (`@{upstream}`, diff base for the unpushed scope), `get_git_user`, `get_name_status`, `get_numstat`, `get_untracked`, `get_review_patch` (the last four take a `cwd` repo root so paths resolve from a subdirectory). -- **`local/store.lua`** — Append-only JSONL event store for local (pre-PR) review sessions, persisted inside the worktree (`.fude/reviews/.jsonl` + `.fude/current.json` pointer; `M._dir` overrides the base dir for tests). One JSON event per line; kinds: `session`/`comment`/`reply`/`edit`/`move`/`resolve`/`reopen`/`delete`/`viewed`. Pure functions: `generate_uuid`, `make_session_id`, `serialize_event`, `parse_event_line`/`parse_events` (corrupt lines skipped), `materialize(events)` (rebuilds GitHub-compatible comment objects + thread resolved state + a per-path viewed map; replies inherit the root's path/line including moves; deleted comments are hidden but stay on disk as audit trail), `apply_outdated(comments, line_counts)` (file gone or line > EOF → `is_outdated`), `reanchor(comments, file_lines_map)` (context-based re-anchor: moves a comment to the unique location of its saved `context` block, re-propagates to replies, returns the moves to persist), and `build_*_event` constructors. IO: `append_event`, `read_events`, and the current-session pointer helpers `write_current(root, branch, session)`/`read_current(root, branch)`/`clear_current(root, branch)`. `.fude/current.json` is a **branch-keyed map** (`{ [branch] = session }`) so reviewing several branches in the same worktree doesn't collide; a legacy flat single-session pointer is migrated on read. +- **`local/store.lua`** — Append-only JSONL event store for local (pre-PR) review sessions, persisted inside the worktree (`.fude/reviews/.jsonl` + `.fude/current.json` pointer; `M._dir` overrides the base dir for tests). One JSON event per line; kinds: `session`/`comment`/`reply`/`edit`/`move`/`resolve`/`reopen`/`delete`/`viewed`. Pure functions: `generate_uuid`, `make_session_id`, `serialize_event`, `parse_event_line`/`parse_events` (corrupt lines skipped), `materialize(events)` (rebuilds GitHub-compatible comment objects + thread resolved state + a per-path viewed map; replies inherit the root's path/line including moves; deleted comments are hidden but stay on disk as audit trail), `apply_outdated(comments, line_counts)` (file gone or line > EOF → `is_outdated`), `reanchor(comments, file_lines_map)` (context-based re-anchor: moves a comment to the unique location of its saved `context` block, re-propagates to replies, returns the moves to persist), and `build_*_event` constructors — every action-event constructor (`build_comment_event`/`build_reply_event`/`build_edit_event`/`build_move_event`/`build_status_event`/`build_delete_event`/`build_viewed_event`) stamps `author_type` (`opts.author_type or "human"`); `build_session_event` intentionally does not, since the session header is metadata, not a user action. IO: `append_event`, `read_events`, and the current-session pointer helpers `write_current(root, branch, session)`/`read_current(root, branch)`/`clear_current(root, branch)`. `.fude/current.json` is a **branch-keyed map** (`{ [branch] = session }`) so reviewing several branches in the same worktree doesn't collide; a legacy flat single-session pointer is migrated on read. - **`local/session.lua`** — Local review session lifecycle, parallel in shape to `init.lua` start/stop/reload. `start(base)` resolves the base ref (arg → `current.json` → default branch), computes the diff base from the scope, synthesizes `changed_files` from `git diff --name-status/--numstat` + untracked files (pure helpers: `status_word`, `resolve_rename_path`, `parse_name_status`, `parse_numstat`, `build_changed_files`), creates or resumes the session for the current branch (`.fude/current.json` is keyed by branch, so different branches in one worktree get separate sessions), sets state (`review_mode = "local"`, `local_session`), reuses `init.setup_review_autocmds`, registers local-only autocmds (BufWritePost re-anchor, BufEnter tracker sync), and starts the auto-reload timer. `stop()` tears down and clears `current.json` (the JSONL file is kept). `toggle(base)` stops an active local session or starts one (refuses while a GitHub review is active). `reload(silent)` re-reads git state and the JSONL synchronously. **Scope**: `local_session.scope` is `"base"` (merge-base with the base branch — whole branch diff), `"unpushed"` (the `@{upstream}` tracking ref — changes not yet pushed), or `"uncommitted"` (HEAD — staged + unstaged only). All three compare the working tree against a ref, so comments stay anchored (per-commit was deferred for exactly this reason — its right side is a commit, not the working tree). `resolve_scope_base(scope, base_ref, cwd)` returns the `git diff` base and the `git show` content ref (nil when the scope is unavailable). `scope_specs(session)` builds the adaptive list of available scopes with labels (base only on a non-base branch; unpushed only with an upstream; uncommitted always). `set_scope(scope)` re-derives the base, reloads changed files/comments, re-applies the gitsigns base and preview, and updates the statusline; `select_scope()` is the `vim.ui.select` picker. Comments are scope-independent (they anchor to the working tree). - **`local/tracker.lua`** — Extmark-based line tracking for local review comments (dedicated `fude_local_track` namespace so `refresh_extmarks` never clears it). `sync_buffer`/`sync_all` place invisible extmarks for root comments; `collect_moves(buf)` computes drifted positions from extmark rows; `on_buf_write(buf)` persists drift as `move` events via `local_sync.move_comments`. `teardown()` clears marks on session stop. Module-local registry, not `config.state`. - **`comments/local_sync.lua`** — Local comment backend mirroring `comments/sync.lua`'s external shape (`load_comments`, `reply_to_comment`, `edit_comment`, `delete_comment` with identical callback signatures) plus `create_comment` (with best-effort `context` capture), `move_comments` (batch re-anchor), `toggle_resolved`, and `set_viewed` (local viewed state, no GitHub round-trip). `load_comments` also populates `state.viewed_files` from the materialized viewed map, and runs `store.reanchor` (reading current file lines, preferring loaded buffers) to recover drifted comments, persisting confident re-anchors as `move` events before `apply_outdated`. After `apply_outdated` it normalizes the local thread-level `resolved` flag onto the display-facing `is_resolved`, gated by `resolved.show` (mirroring how `sync.lua` only sets `is_resolved` at fetch time), so the whole display layer reads `is_resolved` alone; `resolved` stays as the toggle source of truth. All operations are synchronous JSONL appends followed by one re-materialize into `state.comments`/`state.comment_map`; `comments.lua` dispatches to this backend when `state.review_mode == "local"`. diff --git a/README.md b/README.md index 8fdbfd9..fd440c0 100644 --- a/README.md +++ b/README.md @@ -326,8 +326,12 @@ Each line of `.fude/reviews/.jsonl` is one JSON event: Other event kinds: `edit` (body replacement), `move` (line re-anchor), `reopen`, `delete` (hides the comment; the log line remains as an audit -trail), and `viewed` (per-file viewed state). Agents should **append only** -— never rewrite existing lines. +trail), and `viewed` (per-file viewed state). Every action event — +`comment`/`reply`/`edit`/`move`/`resolve`/`reopen`/`delete`/`viewed` — +carries `author_type` (`"human"` or `"agent"`, default `"human"`), so a +watcher can mechanically filter events by who wrote them; the `session` +header is metadata, not a user action, and has no `author_type`. Agents +should **append only** — never rewrite existing lines. For a resident Claude Code session, `contrib/skills/fude-watch/` provides a skill scaffold that tails the active session file and responds to new diff --git a/doc/fude.txt b/doc/fude.txt index 03cb667..650f958 100644 --- a/doc/fude.txt +++ b/doc/fude.txt @@ -948,7 +948,11 @@ Add `.fude/` to your `.gitignore`. Event kinds: `session` (header), `comment` (thread root with path/start_line/end_line/body/author/ author_type/context), `reply`, `edit`, `move` (line re-anchor), `resolve`, `reopen`, `delete` (hides the comment from the view; the log line -remains as an audit trail), and `viewed` (per-file viewed state). +remains as an audit trail), and `viewed` (per-file viewed state). Every +action event (`comment`/`reply`/`edit`/`move`/`resolve`/`reopen`/`delete`/ +`viewed`) carries `author_type` (`"human"` or `"agent"`, default `"human"`) +so consumers can filter events by writer; the `session` header is metadata, +not a user action, and has no `author_type`. Behavior~ diff --git a/lua/fude/local/store.lua b/lua/fude/local/store.lua index a21ca93..5ebe07f 100644 --- a/lua/fude/local/store.lua +++ b/lua/fude/local/store.lua @@ -421,7 +421,7 @@ function M.build_reply_event(opts) end --- Build an edit event. ---- @param opts table { id, body, author, created_at } +--- @param opts table { id, body, author, author_type, created_at } --- @return table edit event function M.build_edit_event(opts) return { @@ -429,12 +429,13 @@ function M.build_edit_event(opts) id = opts.id, body = opts.body, author = opts.author, + author_type = opts.author_type or "human", created_at = opts.created_at, } end --- Build a move (line re-anchor) event. ---- @param opts table { id, path, start_line, end_line, created_at } +--- @param opts table { id, path, start_line, end_line, author_type, created_at } --- @return table move event function M.build_move_event(opts) return { @@ -443,13 +444,14 @@ function M.build_move_event(opts) path = opts.path, start_line = opts.start_line, end_line = opts.end_line, + author_type = opts.author_type or "human", created_at = opts.created_at, } end --- Build a resolve or reopen event. --- @param kind string "resolve"|"reopen" ---- @param opts table { id, thread_id, author, created_at } +--- @param opts table { id, thread_id, author, author_type, created_at } --- @return table event function M.build_status_event(kind, opts) return { @@ -457,24 +459,26 @@ function M.build_status_event(kind, opts) id = opts.id, thread_id = opts.thread_id, author = opts.author, + author_type = opts.author_type or "human", created_at = opts.created_at, } end --- Build a delete event (removes a comment from the materialized view). ---- @param opts table { id, author, created_at } +--- @param opts table { id, author, author_type, created_at } --- @return table delete event function M.build_delete_event(opts) return { event = "delete", id = opts.id, author = opts.author, + author_type = opts.author_type or "human", created_at = opts.created_at, } end --- Build a viewed event (marks a file viewed/unviewed). ---- @param opts table { id, path, viewed (boolean), author, created_at } +--- @param opts table { id, path, viewed (boolean), author, author_type, created_at } --- @return table viewed event function M.build_viewed_event(opts) return { @@ -483,6 +487,7 @@ function M.build_viewed_event(opts) path = opts.path, viewed = opts.viewed and true or false, author = opts.author, + author_type = opts.author_type or "human", created_at = opts.created_at, } end diff --git a/tests/fude/local_store_spec.lua b/tests/fude/local_store_spec.lua index 098a4c0..ea11772 100644 --- a/tests/fude/local_store_spec.lua +++ b/tests/fude/local_store_spec.lua @@ -20,6 +20,21 @@ describe("store.make_session_id", function() end) end) +describe("store event builders author_type", function() + it("defaults author_type to human when not given", function() + assert.equals("human", store.build_edit_event({ id = "c1", body = "x" }).author_type) + assert.equals("human", store.build_move_event({ id = "c1", end_line = 1 }).author_type) + assert.equals("human", store.build_status_event("resolve", { id = "e1", thread_id = "c1" }).author_type) + assert.equals("human", store.build_delete_event({ id = "c1" }).author_type) + assert.equals("human", store.build_viewed_event({ id = "v1", path = "a.lua", viewed = true }).author_type) + end) + + it("passes through an explicit author_type", function() + local event = store.build_viewed_event({ id = "v1", path = "a.lua", viewed = true, author_type = "agent" }) + assert.equals("agent", event.author_type) + end) +end) + describe("store.parse_event_line", function() it("parses a valid comment event", function() local line = vim.json.encode({ event = "comment", id = "abc", path = "f.lua", body = "hi" }) From c13b7b05cefb5f37df89d7043fbec83bb82f329c Mon Sep 17 00:00:00 2001 From: shusann01116 <26602565+shusann01116@users.noreply.github.com> Date: Thu, 23 Jul 2026 12:12:31 +0900 Subject: [PATCH 2/7] feat: add mechanical event filter and reply helper to fude-watch skill Bundle two scripts with the contrib fude-watch skill so the fragile parts of the watch loop are handled mechanically instead of by LLM judgment: - fude-watch-filter.sh: piped after tail -f, drops agent-authored lines (author_type "agent") and non-actionable event kinds (viewed/move/edit/delete/session), fixing the echo of file-viewed events and the agent's own replies into the watch session. - fude-watch-reply.sh: appends a reply event from a body file, generating the UUID and timestamp and serializing with jq -c so the appended line is always single-line compact JSON (the format the line-based parser and the filter rely on), then verifies the append. Co-Authored-By: Claude Fable 5 --- contrib/skills/fude-watch/SKILL.md | 46 +++++++++++-------- .../skills/fude-watch/fude-watch-filter.sh | 14 ++++++ contrib/skills/fude-watch/fude-watch-reply.sh | 36 +++++++++++++++ 3 files changed, 78 insertions(+), 18 deletions(-) create mode 100644 contrib/skills/fude-watch/fude-watch-filter.sh create mode 100644 contrib/skills/fude-watch/fude-watch-reply.sh diff --git a/contrib/skills/fude-watch/SKILL.md b/contrib/skills/fude-watch/SKILL.md index 3f21d87..4fd008f 100644 --- a/contrib/skills/fude-watch/SKILL.md +++ b/contrib/skills/fude-watch/SKILL.md @@ -41,15 +41,22 @@ REVIEW_FILE = .fude/reviews/.jsonl `comment` が thread root、`reply` は `in_reply_to` で root を指す。`resolve` 済みの thread は対応不要)。未対応の open コメントがあれば、この時点で Step 4 の対応を行う。 -### 3. Monitor を張る +### 3. 同梱フィルタを挟んで Monitor を張る -Monitor ツールで新規イベントを待ち受ける: +tail の生出力には agent 自身が追記した行や `viewed` / `move` などの非対象イベントも +流れてくる。これらを LLM の判断で無視するのではなく、スキルに同梱の +`fude-watch-filter.sh`(この SKILL.md と同じディレクトリ。スキル起動時に通知される +base directory 配下)をパイプに挟んで機械的に落とす: -- command: `tail -n 0 -f ` +- command: `tail -n 0 -f | bash <スキルの base directory>/fude-watch-filter.sh` - description: `fude local review comments` - persistent: true -各 stdout 行が 1 イベントとして通知される。 +通知される stdout 行は「human が書いた comment / reply / resolve / reopen」だけになる。 +`viewed` / `move` / `edit` / `delete` / `session` の各イベントと、`author_type` が +`agent` の行(自分の追記の echo)はフィルタで落ちる。fude.nvim は全アクション +イベントに `author_type`(デフォルト `"human"`)を付与するので、この2軸 +(イベント種別・書き手)のフィルタで過不足なく絞れる。 ### 4. イベントへの対応 @@ -62,20 +69,23 @@ Monitor ツールで新規イベントを待ち受ける: - **`reply`**(人間からの追い返信): スレッド文脈を読み直して同様に対応する - **`reopen`**: そのスレッドの対応を再開する - **`resolve`**: そのスレッドはクローズ。対応中なら打ち切ってよい -- 自分(agent)が追記したイベントの echo は無視する(`author_type` が `agent`) - -### 5. 返信の追記ルール - -`REVIEW_FILE` に **1行の JSON を append する**(既存行の書き換え禁止): - -```json -{"event":"reply","id":"<新規UUID>","thread_id":"","in_reply_to":"","body":"対応内容の説明","author":"claude","author_type":"agent","created_at":""} -``` - -- `id` は新規 UUID v4 を生成する -- `thread_id` / `in_reply_to` は **root コメントの id**(reply への reply でも root を指す) -- `author_type` は必ず `"agent"` -- 追記は `printf '%s\n' '' >> ` のようにアトミックな1行 append で行う +- 上記以外のイベント(`viewed` / `move` / `edit` / `delete` / `session`)や + `author_type` が `agent` の行は Step 3 のフィルタで届かないはずだが、 + 万一届いた場合は黙って無視する(返信も報告もしない) + +### 5. 返信の追記 + +返信は同梱の `fude-watch-reply.sh` で `REVIEW_FILE` に append する(既存行の +書き換え禁止)。UUID・タイムスタンプ・`author_type: "agent"` の付与、1行の +compact JSON への正規化(Step 3 のフィルタが echo を遮断できる形式)、 +追記後の検証はスクリプトが保証する: + +1. 返信本文だけを scratchpad のテキストファイルに Write する(Markdown 可) +2. `bash <スキルの base directory>/fude-watch-reply.sh <本文ファイル>` を実行する + - 第2引数は **root コメントの id**(reply への reply でも root を指す) + - 成功すると追記したイベントの 1 行 JSON を stdout に出力する。非 0 で + 終了した場合は追記が壊れている可能性があるので、REVIEW_FILE の末尾を + 確認してユーザーに報告する コード修正を伴う場合は、修正 → テスト/lint 確認 → reply 追記の順で行い、 reply の body には何をどう変えたかを簡潔に書く。 diff --git a/contrib/skills/fude-watch/fude-watch-filter.sh b/contrib/skills/fude-watch/fude-watch-filter.sh new file mode 100644 index 0000000..e2aa11c --- /dev/null +++ b/contrib/skills/fude-watch/fude-watch-filter.sh @@ -0,0 +1,14 @@ +#!/bin/bash +# fude-watch: pass through only actionable, human-authored review events. +# Reads JSONL lines on stdin (from tail -f) and prints only lines the watch +# session should react to: comment / reply / resolve / reopen written by a +# human. Agent-authored lines (author_type "agent") and non-actionable kinds +# (viewed / move / edit / delete / session) are dropped. Matching relies on +# the compact (no-space) JSON that fude.nvim's serialize_event emits. +while IFS= read -r line; do + case $line in + *'"author_type":"agent"'*) continue ;; + *'"event":"comment"'* | *'"event":"reply"'* | *'"event":"resolve"'* | *'"event":"reopen"'*) + printf '%s\n' "$line" ;; + esac +done diff --git a/contrib/skills/fude-watch/fude-watch-reply.sh b/contrib/skills/fude-watch/fude-watch-reply.sh new file mode 100644 index 0000000..51bc89d --- /dev/null +++ b/contrib/skills/fude-watch/fude-watch-reply.sh @@ -0,0 +1,36 @@ +#!/bin/bash +# fude-watch: append an agent reply event to a local review JSONL. +# +# Usage: fude-watch-reply.sh +# +# The body is passed as a file to avoid shell quoting issues. The event is +# serialized with jq -c, which guarantees a single compact (no-space) line — +# the format fude.nvim's line-based parser and fude-watch-filter.sh both +# rely on. After appending, the last line of the review file is compared to +# the generated event; on mismatch the script exits non-zero. +# On success the appended line is printed to stdout. +set -eu + +review_file=$1 +thread_id=$2 +body_file=$3 + +id=$(uuidgen | tr 'A-Z' 'a-z') +created_at=$(date -u +%Y-%m-%dT%H:%M:%SZ) + +line=$(jq -cn \ + --arg id "$id" \ + --arg thread "$thread_id" \ + --rawfile body "$body_file" \ + --arg ts "$created_at" \ + '{event:"reply",id:$id,thread_id:$thread,in_reply_to:$thread,body:($body|sub("\n+$";"")),author:"claude",author_type:"agent",created_at:$ts}') + +printf '%s\n' "$line" >> "$review_file" + +last=$(tail -n 1 "$review_file") +if [ "$last" != "$line" ]; then + echo 'fude-watch-reply: append verification failed (last line differs)' >&2 + exit 1 +fi + +printf '%s\n' "$line" From cd07f4c6809cde4c9685e32e7e395a7b8b923621 Mon Sep 17 00:00:00 2001 From: shusann01116 <26602565+shusann01116@users.noreply.github.com> Date: Thu, 23 Jul 2026 13:26:12 +0900 Subject: [PATCH 3/7] test: verify author_type passthrough for all local store event builders The passthrough assertion previously only covered build_viewed_event, while the PR description claimed all five action-event constructors were verified. Extend it to build_edit_event/build_move_event/ build_status_event/build_delete_event as well, with fuller opts so the intent of each assertion is clear. Co-Authored-By: Claude Fable 5 --- tests/fude/local_store_spec.lua | 42 +++++++++++++++++++++++++++++---- 1 file changed, 37 insertions(+), 5 deletions(-) diff --git a/tests/fude/local_store_spec.lua b/tests/fude/local_store_spec.lua index ea11772..23f9320 100644 --- a/tests/fude/local_store_spec.lua +++ b/tests/fude/local_store_spec.lua @@ -23,15 +23,47 @@ end) describe("store event builders author_type", function() it("defaults author_type to human when not given", function() assert.equals("human", store.build_edit_event({ id = "c1", body = "x" }).author_type) - assert.equals("human", store.build_move_event({ id = "c1", end_line = 1 }).author_type) - assert.equals("human", store.build_status_event("resolve", { id = "e1", thread_id = "c1" }).author_type) - assert.equals("human", store.build_delete_event({ id = "c1" }).author_type) + assert.equals( + "human", + store.build_move_event({ id = "c1", path = "a.lua", start_line = 1, end_line = 1 }).author_type + ) + assert.equals( + "human", + store.build_status_event("resolve", { id = "e1", thread_id = "c1", author = "shusann" }).author_type + ) + assert.equals("human", store.build_delete_event({ id = "c1", author = "shusann" }).author_type) assert.equals("human", store.build_viewed_event({ id = "v1", path = "a.lua", viewed = true }).author_type) end) it("passes through an explicit author_type", function() - local event = store.build_viewed_event({ id = "v1", path = "a.lua", viewed = true, author_type = "agent" }) - assert.equals("agent", event.author_type) + assert.equals("agent", store.build_edit_event({ id = "c1", body = "x", author_type = "agent" }).author_type) + assert.equals( + "agent", + store.build_move_event({ + id = "c1", + path = "a.lua", + start_line = 1, + end_line = 1, + author_type = "agent", + }).author_type + ) + assert.equals( + "agent", + store.build_status_event("resolve", { + id = "e1", + thread_id = "c1", + author = "claude", + author_type = "agent", + }).author_type + ) + assert.equals( + "agent", + store.build_delete_event({ id = "c1", author = "claude", author_type = "agent" }).author_type + ) + assert.equals( + "agent", + store.build_viewed_event({ id = "v1", path = "a.lua", viewed = true, author_type = "agent" }).author_type + ) end) end) From dd6b5a0fad4aa41c4271fc25dd62117e2b835a51 Mon Sep 17 00:00:00 2001 From: shusann01116 <26602565+shusann01116@users.noreply.github.com> Date: Thu, 23 Jul 2026 13:26:37 +0900 Subject: [PATCH 4/7] fix: remove racy post-append verification from fude-watch-reply.sh The last-line comparison could fail spuriously when another process (e.g. a human editing in fude.nvim) appends to the same JSONL right after the script does, even though the script's own append succeeded. The check was also redundant: jq -c already guarantees the generated event is a single compact-JSON line, and a failed append (permission, missing directory, etc.) already exits non-zero via set -eu. Drop the check instead of widening its matching window. Co-Authored-By: Claude Fable 5 --- contrib/skills/fude-watch/SKILL.md | 6 +++--- contrib/skills/fude-watch/fude-watch-reply.sh | 10 +--------- 2 files changed, 4 insertions(+), 12 deletions(-) diff --git a/contrib/skills/fude-watch/SKILL.md b/contrib/skills/fude-watch/SKILL.md index 4fd008f..785a579 100644 --- a/contrib/skills/fude-watch/SKILL.md +++ b/contrib/skills/fude-watch/SKILL.md @@ -77,14 +77,14 @@ base directory 配下)をパイプに挟んで機械的に落とす: 返信は同梱の `fude-watch-reply.sh` で `REVIEW_FILE` に append する(既存行の 書き換え禁止)。UUID・タイムスタンプ・`author_type: "agent"` の付与、1行の -compact JSON への正規化(Step 3 のフィルタが echo を遮断できる形式)、 -追記後の検証はスクリプトが保証する: +compact JSON への正規化(Step 3 のフィルタが echo を遮断できる形式)は +スクリプトが保証する: 1. 返信本文だけを scratchpad のテキストファイルに Write する(Markdown 可) 2. `bash <スキルの base directory>/fude-watch-reply.sh <本文ファイル>` を実行する - 第2引数は **root コメントの id**(reply への reply でも root を指す) - 成功すると追記したイベントの 1 行 JSON を stdout に出力する。非 0 で - 終了した場合は追記が壊れている可能性があるので、REVIEW_FILE の末尾を + 終了した場合は追記が行われていない可能性が高いので、REVIEW_FILE の末尾を 確認してユーザーに報告する コード修正を伴う場合は、修正 → テスト/lint 確認 → reply 追記の順で行い、 diff --git a/contrib/skills/fude-watch/fude-watch-reply.sh b/contrib/skills/fude-watch/fude-watch-reply.sh index 51bc89d..bb322ec 100644 --- a/contrib/skills/fude-watch/fude-watch-reply.sh +++ b/contrib/skills/fude-watch/fude-watch-reply.sh @@ -6,9 +6,7 @@ # The body is passed as a file to avoid shell quoting issues. The event is # serialized with jq -c, which guarantees a single compact (no-space) line — # the format fude.nvim's line-based parser and fude-watch-filter.sh both -# rely on. After appending, the last line of the review file is compared to -# the generated event; on mismatch the script exits non-zero. -# On success the appended line is printed to stdout. +# rely on. On success the appended line is printed to stdout. set -eu review_file=$1 @@ -27,10 +25,4 @@ line=$(jq -cn \ printf '%s\n' "$line" >> "$review_file" -last=$(tail -n 1 "$review_file") -if [ "$last" != "$line" ]; then - echo 'fude-watch-reply: append verification failed (last line differs)' >&2 - exit 1 -fi - printf '%s\n' "$line" From b7877cdf35e6f5c29134b152261c15a3093d545f Mon Sep 17 00:00:00 2001 From: shusann01116 <26602565+shusann01116@users.noreply.github.com> Date: Thu, 23 Jul 2026 13:30:45 +0900 Subject: [PATCH 5/7] =?UTF-8?q?docs:=20PR=20#169=20=E3=81=AE=E3=83=AC?= =?UTF-8?q?=E3=83=93=E3=83=A5=E3=83=BC=E7=9F=A5=E8=A6=8B=E3=82=92=20review?= =?UTF-8?q?-lessons.md=20=E3=81=AB=E8=A8=98=E9=8C=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit テストの網羅性(N個の並列コンストラクタへの検証漏れ)と、依存プリミティブ の保証と重複する検証コードの2パターンを追記。前者は pj-checklist の ドキュメント整合性項目と同じ原則のため、PR固有の情報を外して汎用化した。 Co-Authored-By: Claude Fable 5 --- .claude/review-lessons.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.claude/review-lessons.md b/.claude/review-lessons.md index d6ce5b3..44ce7f2 100644 --- a/.claude/review-lessons.md +++ b/.claude/review-lessons.md @@ -29,3 +29,13 @@ - **対策**: 実時刻(`os.time()`)と比較される経路を通るfixtureのタイムスタンプは、固定の十分未来の日付(例: `2126-01-01T00:00:00Z`)を使う。過去日付のハードコードは時限爆弾、現在時刻の動的生成は再現性低下。時刻を注入できる純粋関数(`prune(t, now, days)`等)のテストは固定`now`を渡して書く - **該当箇所**: tests/fude/drafts_spec.lua +### テスト: N個の並列実装に対する代表1件だけの検証 +- **問題**: 複数の並列関数(同種のbuilder、同種のvariant実装等)に同じ変更を加えたとき、検証は代表1件だけに書き、残りは未検証のまま「全て検証した」とPR説明やコミットメッセージに書いてしまうことがある +- **対策**: 変更を加えた関数・variantの数だけ検証を書く。「〜を全て確認/検証」と書く前に、実際のアサーション数・チェック対象数が変更対象の数と一致しているか数え直す。pj-checklistの「全 variant を Grep で列挙」原則(ドキュメント整合性)と同じ考え方をテスト網羅性にも適用する +- **該当箇所**: 汎用パターン(個別事例は git log 参照) + +### コード品質: 依存プリミティブの保証と重複する検証コード (PR #169, 2026-07-23) +- **問題**: シェルスクリプトで`jq -c`によるJSON生成後、生成した行が実際にファイル末尾に追記されたかを`tail -n 1`で再読み込みして比較検証していたが、(1) `jq -c`は出力が単一行のcompact JSONになることを既に保証しており、(2) 追記コマンド自体の失敗は`set -eu`が既に検知するため、この検証は実質どの失敗も追加で捕捉していなかった。むしろ同時書き込み(人間側の操作等)が割り込むと、追記自体は成功しているのに検証だけ失敗する誤検知を生んでいた +- **対策**: 検証コードを書く前に「この検証は、依存しているプリミティブ(jqのフォーマット保証、`set -e`のfail-fast等)が既にカバーしていない失敗モードを捕捉しているか」を確認する。捕捉対象が存在しないなら、検証の精度を上げる(マッチ窓を広げる等)のではなく検証自体を削除する +- **該当箇所**: contrib/skills/fude-watch/fude-watch-reply.sh + From 67a4a3a92df5a0445c63bcd0cb627fcf9c6499c5 Mon Sep 17 00:00:00 2001 From: shusann01116 <26602565+shusann01116@users.noreply.github.com> Date: Thu, 23 Jul 2026 14:12:53 +0900 Subject: [PATCH 6/7] fix: parse events structurally in fude-watch-filter.sh instead of string matching The previous case-based substring match had two failure modes: it would silently drop every actionable event if the JSON ever contained spaces (matching depended on exact compact formatting), and it could misclassify events whose free-text body happened to contain a literal "event":"comment" or "author_type":"agent" substring (a human reply discussing this very filter, for instance). Extract .event and .author_type with jq instead, so classification is structural and independent of formatting or body content. Co-Authored-By: Claude Fable 5 --- contrib/skills/fude-watch/SKILL.md | 7 +++++-- .../skills/fude-watch/fude-watch-filter.sh | 19 ++++++++++++++----- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/contrib/skills/fude-watch/SKILL.md b/contrib/skills/fude-watch/SKILL.md index 785a579..9377671 100644 --- a/contrib/skills/fude-watch/SKILL.md +++ b/contrib/skills/fude-watch/SKILL.md @@ -46,7 +46,10 @@ thread は対応不要)。未対応の open コメントがあれば、この tail の生出力には agent 自身が追記した行や `viewed` / `move` などの非対象イベントも 流れてくる。これらを LLM の判断で無視するのではなく、スキルに同梱の `fude-watch-filter.sh`(この SKILL.md と同じディレクトリ。スキル起動時に通知される -base directory 配下)をパイプに挟んで機械的に落とす: +base directory 配下)をパイプに挟んで機械的に落とす。判定は `jq` で `.event` / +`.author_type` を構造的に抽出して行う(文字列の部分一致ではないので、JSON の +空白の有無や、コメント本文にたまたま `"event":"comment"` 等の文字列が含まれる +ケースの誤判定を避けられる): - command: `tail -n 0 -f | bash <スキルの base directory>/fude-watch-filter.sh` - description: `fude local review comments` @@ -77,7 +80,7 @@ base directory 配下)をパイプに挟んで機械的に落とす: 返信は同梱の `fude-watch-reply.sh` で `REVIEW_FILE` に append する(既存行の 書き換え禁止)。UUID・タイムスタンプ・`author_type: "agent"` の付与、1行の -compact JSON への正規化(Step 3 のフィルタが echo を遮断できる形式)は +compact JSON への正規化(fude.nvim の行単位パーサが前提とする JSONL の形式)は スクリプトが保証する: 1. 返信本文だけを scratchpad のテキストファイルに Write する(Markdown 可) diff --git a/contrib/skills/fude-watch/fude-watch-filter.sh b/contrib/skills/fude-watch/fude-watch-filter.sh index e2aa11c..fb963a0 100644 --- a/contrib/skills/fude-watch/fude-watch-filter.sh +++ b/contrib/skills/fude-watch/fude-watch-filter.sh @@ -3,12 +3,21 @@ # Reads JSONL lines on stdin (from tail -f) and prints only lines the watch # session should react to: comment / reply / resolve / reopen written by a # human. Agent-authored lines (author_type "agent") and non-actionable kinds -# (viewed / move / edit / delete / session) are dropped. Matching relies on -# the compact (no-space) JSON that fude.nvim's serialize_event emits. +# (viewed / move / edit / delete / session) are dropped. +# +# Fields are extracted with jq rather than string-matched, so formatting +# (spaces) and free-text fields (e.g. a comment body that happens to contain +# the literal text `"event":"comment"`) can't cause a false match. while IFS= read -r line; do - case $line in - *'"author_type":"agent"'*) continue ;; - *'"event":"comment"'* | *'"event":"reply"'* | *'"event":"resolve"'* | *'"event":"reopen"'*) + event=$(printf '%s' "$line" | jq -r '.event // empty' 2>/dev/null) || continue + author_type=$(printf '%s' "$line" | jq -r '.author_type // empty' 2>/dev/null) + + if [ "$author_type" = "agent" ]; then + continue + fi + + case $event in + comment | reply | resolve | reopen) printf '%s\n' "$line" ;; esac done From 6afe3727624861ac39d3b41a7c880725bab46e3c Mon Sep 17 00:00:00 2001 From: shusann01116 <26602565+shusann01116@users.noreply.github.com> Date: Thu, 23 Jul 2026 14:43:19 +0900 Subject: [PATCH 7/7] fix: fail loudly instead of silently on missing uuidgen or jq Two independent adversarial reviews of this PR empirically confirmed the same high-severity bug: fude-watch-reply.sh used `set -eu` without pipefail, so `id=$(uuidgen | tr 'A-Z' 'a-z')` masked a missing uuidgen behind tr's exit code, appending a reply event with id:"" and exiting 0. store.lua's materialize() dedups replies by id, so a second such reply silently disappears from the thread with no error surfaced anywhere. Add pipefail so this now fails before any append happens, matching the exit-code contract SKILL.md already documents. fude-watch-filter.sh had a parallel silent-failure mode: without jq in PATH, every event's `jq -r` calls fail via `|| continue` / stderr suppression, so the watch session goes quiet forever with no diagnostic. Add an explicit jq presence check at startup. Co-Authored-By: Claude Fable 5 --- contrib/skills/fude-watch/fude-watch-filter.sh | 5 +++++ contrib/skills/fude-watch/fude-watch-reply.sh | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/contrib/skills/fude-watch/fude-watch-filter.sh b/contrib/skills/fude-watch/fude-watch-filter.sh index fb963a0..da6c365 100644 --- a/contrib/skills/fude-watch/fude-watch-filter.sh +++ b/contrib/skills/fude-watch/fude-watch-filter.sh @@ -8,6 +8,11 @@ # Fields are extracted with jq rather than string-matched, so formatting # (spaces) and free-text fields (e.g. a comment body that happens to contain # the literal text `"event":"comment"`) can't cause a false match. +command -v jq >/dev/null 2>&1 || { + echo 'fude-watch-filter: jq is required but not found in PATH' >&2 + exit 1 +} + while IFS= read -r line; do event=$(printf '%s' "$line" | jq -r '.event // empty' 2>/dev/null) || continue author_type=$(printf '%s' "$line" | jq -r '.author_type // empty' 2>/dev/null) diff --git a/contrib/skills/fude-watch/fude-watch-reply.sh b/contrib/skills/fude-watch/fude-watch-reply.sh index bb322ec..415c54f 100644 --- a/contrib/skills/fude-watch/fude-watch-reply.sh +++ b/contrib/skills/fude-watch/fude-watch-reply.sh @@ -7,7 +7,7 @@ # serialized with jq -c, which guarantees a single compact (no-space) line — # the format fude.nvim's line-based parser and fude-watch-filter.sh both # rely on. On success the appended line is printed to stdout. -set -eu +set -euo pipefail review_file=$1 thread_id=$2