Skip to content
Merged
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 .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
{
"name": "code-walkthrough",
"description": "code-walkthrough: turn a diff, branch, PR, or existing code into a top-down walkthrough page: overview, story map, call graph, annotated diff, and line comments you hand back to the agent",
"version": "1.2.1",
"version": "1.3.0",
"source": "./",
"author": {
"name": "crisperity"
Expand Down
2 changes: 2 additions & 0 deletions skills/code-walkthrough/assets/diff-review-template.html
Original file line number Diff line number Diff line change
Expand Up @@ -5106,6 +5106,8 @@
note.body=String(c.body||'');
saveNote(note);
made=true;
const old=c.replaces&&noteById(c.replaces);
if(old&&old.origin==='local'&&old.state==='draft') deleteNote(old.id);
});
if(made) renderNotes();
if(ids.length&&!dcPosted.has(o.oid)){
Expand Down
4 changes: 3 additions & 1 deletion skills/code-walkthrough/prompts/agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ http(s) links only.
- `reload_walkthrough` shows your edits as a new walkthrough of the cw/<id>/agent commit, opened
beside this one; it never rebuilds the current page. Call it once, after your last edit.
- `post_draft_comments` puts review comments on diff lines as local drafts; nothing reaches GitHub
until the user submits the review.
until the user submits the review. To revise a draft (the user dislikes the wording), pass
`replaces: "<draft id>"` on the new comment (ids are in your context under drafts); never post a
second draft for the same point.
- When the user's message names a slash command (`/check-pr`, `/fix-pr`) or a skill, that is a request
to run it, and it overrides "Reply by default". Call the Skill tool for it first and follow it.
Anything the skill would post to GitHub goes through the cw tools instead: review comments via
Expand Down
30 changes: 28 additions & 2 deletions skills/code-walkthrough/scripts/cw_mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,8 @@ def rpc(tool, args, *, timeout=300):
"keep or remove. Nothing reaches GitHub until the user submits the review. Each comment "
"needs a path in the diff and a line (and optional end_line) inside one contiguous run "
"of diff lines on that side. suggestion is replacement code for RIGHT-side lines only. "
"To revise a draft, set replaces to the id of that local draft (listed in your context): it is "
"swapped for the new comment, so never post a second draft for the same point. "
"One call per turn; any invalid comment rejects the whole call."),
"inputSchema": {
"type": "object", "required": ["comments"],
Expand All @@ -318,6 +320,7 @@ def rpc(tool, args, *, timeout=300):
"side": {"enum": ["RIGHT", "LEFT"]},
"body": {"type": "string", "maxLength": DRAFT_BODY_CAP},
"suggestion": {"type": "string", "maxLength": DRAFT_BODY_CAP},
"replaces": {"type": "string"},
},
},
}},
Expand Down Expand Up @@ -476,16 +479,33 @@ def _check_draft_comments(anchor_doc, args, already):
ranges = notes.postable_ranges((Path(anchor_doc["dir"]) / "raw.diff").read_text(errors="replace"))
except (KeyError, OSError):
return "the diff is unavailable, so no comment can be drafted"
drafts = _local_draft_ids(anchor_doc["dir"])
used = set()
for i, item in enumerate(items):
error = _draft_item_error(item, ranges)
error = _draft_item_error(item, ranges, drafts)
if error:
return f"comments[{i}]: {error}"
replaces = item.get("replaces")
if replaces is not None:
if replaces in used:
return f"comments[{i}]: replaces {replaces} is already replaced by an earlier comment in this call"
used.add(replaces)
if "draft_comments" in already:
return "already posted draft comments in this turn"
return None


def _draft_item_error(item, ranges):
def _local_draft_ids(d):
ids = set()
for name in ("state.json", "page-notes.json"):
doc = cw_store.read_json(Path(d) / name, default=None)
for n in doc.get("notes", []) if isinstance(doc, dict) else []:
if isinstance(n, dict) and n.get("origin") == "local" and n.get("state") == "draft":
ids.add(n.get("id"))
return ids


def _draft_item_error(item, ranges, drafts):
if not isinstance(item, dict):
return "must be an object"
path, line, end_line = item.get("path"), item.get("line"), item.get("end_line")
Expand All @@ -509,6 +529,10 @@ def _draft_item_error(item, ranges):
return "body is required"
if len(body.strip()) > DRAFT_BODY_CAP:
return f"body must be at most {DRAFT_BODY_CAP} characters"
replaces = item.get("replaces")
if replaces is not None and (not isinstance(replaces, str) or not notes.VALID_ID_RE.fullmatch(replaces)
or replaces not in drafts):
return "replaces must be the id of an existing local draft (only drafts that already exist can be replaced, not ones created in this call)"
suggestion = item.get("suggestion")
if suggestion is not None:
if not isinstance(suggestion, str) or len(suggestion) > DRAFT_BODY_CAP:
Expand All @@ -530,6 +554,8 @@ def _draft_comments_payload(args, anchor_doc=None):
row = {"path": item["path"], "line": item["line"],
"end_line": end_line if end_line != item["line"] else None,
"side": item.get("side", "RIGHT"), "body": body}
if item.get("replaces"):
row["replaces"] = item["replaces"]
key = json.dumps(row, sort_keys=True)
if key not in seen:
seen.add(key)
Expand Down
107 changes: 79 additions & 28 deletions skills/code-walkthrough/scripts/cw_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import threading
import time
from collections import defaultdict
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path

sys.path.insert(0, str(Path(__file__).parent))
Expand Down Expand Up @@ -979,45 +980,95 @@ def _run_visuals(d, meta, config, on_event, force=False):
_emit_step(d, on_event, "visuals", "running")
system_prompt = (SKILL_DIR / "prompts" / "visuals.md").read_text() + DELIVERY_SUFFIX.format(
tool="submit_visuals")
messages = [{"role": "system", "content": system_prompt},
{"role": "user", "content": _visuals_user_text(d, meta, analysis, symdelta, need_calls)}]
tools, handlers = read_tools(d)
state: dict = {"attempt": 0}
ctx = visuals.diff_ctx((d / "raw.diff").read_text(errors="replace"), symdelta)
on_usage = _make_on_usage(d, role, on_event, config)

def handler(args):
state["attempt"] += 1
clean, problems = visuals.check_visuals(args, n_groups, ctx)
calls = []
if need_calls:
calls, call_problems = visuals.check_calls(args.get("calls") if isinstance(args, dict) else None, ctx)
problems = problems + call_problems
if problems and state["attempt"] < 3:
return "gate: " + "\n".join(problems) + "\nfix and call submit_visuals again"
state["clean"], state["problems"], state["calls"] = clean, problems, calls
return cw_llm.Done({"ok": True})
def author(*job):
"""Never raises: a failed call is an empty result plus a problem, so the others still land."""
try:
return _author(*job)
except Exception as e:
return {"pr": None, "overview": None, "groups": {}}, [f"{job[0]}: {e}"], []

def _author(name, scope, group=None):
"""One scoped submit_visuals conversation -> (clean, problems, calls)."""
messages = [{"role": "system", "content": system_prompt},
{"role": "user", "content": _visuals_user_text(
d, meta, analysis, symdelta, need_calls and group is None) + "\n\n" + scope}]
state: dict = {"attempt": 0}

def handler(args):
state["attempt"] += 1
args = args if isinstance(args, dict) else {}
if group is None:
scoped = {**args, "groups": []}
else:
gs = args.get("groups")
scoped = {"groups": [g for g in gs if isinstance(g, dict) and g.get("index") == group]
if isinstance(gs, list) else []}
clean, problems = visuals.check_visuals(scoped, n_groups, ctx)
if group is not None and group not in clean["groups"] and not problems:
problems = [f"visuals.groups: no picture for group {group}"]
calls = []
if group is None and need_calls:
calls, call_problems = visuals.check_calls(args.get("calls"), ctx)
problems = problems + call_problems
if problems and state["attempt"] < 3:
return "gate: " + "\n".join(problems) + "\nfix and call submit_visuals again"
state["clean"], state["problems"], state["calls"] = clean, problems, calls
return cw_llm.Done({"ok": True})

local_handlers = dict(handlers)
local_handlers["submit_visuals"] = handler
try:
cw_llm.run_tools(
profile, messages, tools + [_visuals_submit_tool(need_calls)], local_handlers, max_rounds=8,
max_tokens=config["max_conversation_tokens"], timeout=config["timeout_s"],
on_usage=_make_on_usage(d, role, on_event, config), cwd=d / "head")
finally:
_write_transcript(d, "visuals", profile, messages, result="clean" in state)
clean = state["clean"]
if state["calls"]:
_write_model_symdelta(d, meta, symdelta, state["calls"])
local_handlers = dict(handlers)
local_handlers["submit_visuals"] = handler
error = None
try:
cw_llm.run_tools(
profile, messages, tools + [_visuals_submit_tool(need_calls and group is None)],
local_handlers, max_rounds=8, max_tokens=config["max_conversation_tokens"],
timeout=config["timeout_s"], on_usage=on_usage, cwd=d / "head")
except Exception as e:
error = f"{name}: {e}"
finally:
_write_transcript(d, f"visuals-{name}", profile, messages, result="clean" in state, error=error)
if "clean" not in state:
return {"pr": None, "overview": None, "groups": {}}, [error or f"{name}: nothing submitted"], []
return state["clean"], state["problems"] + ([error] if error else []), state["calls"]

jobs = []
if visuals.symbol_digest(symdelta) or need_calls:
jobs.append(("overview", "Author only the overview" + (" (and `calls`)." if need_calls else "."), None))
jobs += [(f"group-{i}", f"Author only group {i}: submit one entry in `groups` with index {i}, "
"and no overview.", i) for i in range(min(n_groups, visuals.MAX_GROUPS))]
with ThreadPoolExecutor(max_workers=4) as pool:
results = list(pool.map(lambda j: author(*j), jobs))
clean = {"pr": None, "overview": None, "groups": {}}
problems, calls = [], []
if n_groups > visuals.MAX_GROUPS:
problems.append(f"visuals.groups: at most {visuals.MAX_GROUPS} pictures, "
f"{n_groups - visuals.MAX_GROUPS} dropped")
for c, probs, cs in results:
clean["pr"] = clean["pr"] or c["pr"]
clean["overview"] = clean["overview"] or c["overview"]
clean["groups"].update(c["groups"])
problems += probs
calls += cs
size = {i: len(h.encode("utf-8", "replace")) for i, h in clean["groups"].items()}
while sum(size.values()) > visuals.TOTAL_MAX:
big = max(size, key=size.get)
del size[big], clean["groups"][big]
problems.append(f"visuals.groups: picture {big} dropped, over the {visuals.TOTAL_MAX // 1024} KB total")
if calls:
_write_model_symdelta(d, meta, symdelta, calls)
else:
(d / "symdelta.model.json").unlink(missing_ok=True)
if not clean["overview"] and not clean["pr"] and not clean["groups"]:
raise RuntimeError("no usable pictures; dropped: " + "; ".join(state["problems"])[:500])
raise RuntimeError("no usable pictures; dropped: " + "; ".join(problems)[:500])
cw_store.write_json(out, {
"overview": clean["overview"],
"pr": clean["pr"],
"groups": [{"index": i, "html": h} for i, h in sorted(clean["groups"].items())]})
_emit_step(d, on_event, "visuals", "ok")
_emit_step(d, on_event, "visuals", "ok", **({"lines": problems} if problems else {}))
return True
except Exception as e:
_emit_step(d, on_event, "visuals", "failed", error=str(e), remedy=getattr(e, "remedy", None))
Expand Down
23 changes: 23 additions & 0 deletions skills/code-walkthrough/scripts/test_cw_mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -556,6 +556,29 @@ def check(*comments, already=()):
assert body.endswith(f"{fence}suggestion\n{text}\n{fence}")


def test_check_draft_comments_replaces():
d = _diff_dir()
doc = {**_page_doc(), "dir": d}
drafts = [{"id": "n-1", "origin": "local", "state": "draft"},
{"id": "n-2", "origin": "local", "state": "posted"},
{"id": "n-3", "origin": "github", "state": "draft"}]
(Path(d) / "page-notes.json").write_text(json.dumps({"notes": drafts}))

def check(replaces):
return cw_mcp.check_outcome(doc, "post_draft_comments", {"comments": [_dc(replaces=replaces)]}, ())

assert check("n-1") is None
for bad in ("n-2", "n-3", "n-9", "bad id", 5):
assert "replaces must be" in check(bad), bad
assert "already replaced" in cw_mcp.check_outcome(
doc, "post_draft_comments", {"comments": [_dc(replaces="n-1"), _dc(line=3, replaces="n-1")]}, ())
(Path(d) / "page-notes.json").unlink()
(Path(d) / "state.json").write_text(json.dumps({"notes": drafts}))
assert check("n-1") is None
row = cw_mcp._draft_comments_payload({"comments": [_dc(replaces="n-1"), _dc(line=3)]})["comments"]
assert row[0]["replaces"] == "n-1" and "replaces" not in row[1]


def test_outcomes_dispatch_rejects_tools_outside_the_mode(tmp_path):
req = {"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "propose_task", "arguments": {}}}
called = []
Expand Down
Loading
Loading