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
11 changes: 11 additions & 0 deletions .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,14 @@ Adding to this list requires explicit user approval and an unblock condition. Au
### Documentation Format

- All docs `.adoc` (AsciiDoc) except GitHub-required files (SECURITY.md, CONTRIBUTING.md, CODE_OF_CONDUCT.md, CHANGELOG.md).

---

## PR Workflow

This repo squash-merges PRs. Two consequences worth knowing before pushing follow-ups:

- **Don't pile follow-up commits onto a branch whose PR is in review.** When the PR is squash-merged, `main` gets a new commit with a new SHA. Any commits you pushed after the PR was opened are still on the feature branch, on top of a base that no longer matches `main`. GitHub will then mark the PR as `mergeable_state: "blocked"` and any rebase will produce ghost-conflicts — the conflicting hunks are the *same content*, but git can't tell because the SHAs differ. If you have follow-up work, open it as a new PR off the current `main`.
- **After a squash-merge, delete the feature branch.** It contains pre-squash commits with stale SHAs; reusing it for new work re-creates the ghost-conflict problem. `git checkout main && git pull && git branch -D <branch> && git push origin --delete <branch>`.

Diagnostic: if a PR shows `blocked` and `git diff origin/main HEAD` is empty, the PR's content is already on main via squash-merge — close the PR rather than trying to merge it.
67 changes: 67 additions & 0 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -139,3 +139,70 @@ jobs:
name: benchmark-results
path: ffi/zig/zig-out/bench*
retention-days: 30

# ─── Bench Bridge: mcp-bridge JS perf (path-claims) ────────────────
# Separate job from `benchmarks` above because the bridge has no Zig
# toolchain dependency — keeps logs untangled and lets the JS bench
# run in parallel on its own runner. Posts a sticky PR comment so
# deltas across pushes are visible inline.
bench-bridge:
name: Bench — mcp-bridge (path-claims)
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
# Per-job override: only this step needs to write PR comments.
# Workflow-level permissions stay read-all.
pull-requests: write

steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

- name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: '22'

- name: Run bridge bench
run: node mcp-bridge/tests/path_claims_bench.js | tee bench-bridge.txt

- name: Upload bridge bench artifact
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: bench-bridge-results
path: bench-bridge.txt
retention-days: 30

- name: Sticky PR comment with bench numbers
if: github.event_name == 'pull_request'
# Advisory — a comment failure must never gate the bench job.
# Same reasoning as the hypatia-scan PR-comment step.
continue-on-error: true
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v7
with:
script: |
const fs = require('fs');
const MARKER = '<!-- bench-bridge:path-claims -->';
const output = fs.readFileSync('bench-bridge.txt', 'utf8');
const body = `${MARKER}\n## 🏁 path-claims bench\n\nCommit \`${context.sha.slice(0, 7)}\`\n\n<details open><summary>Numbers</summary>\n\n\`\`\`\n${output}\n\`\`\`\n</details>\n\n*Host-dependent — compare deltas across commits, not absolute values.*`;

const { owner, repo } = context.repo;
const issue_number = context.issue.number;

const existing = await github.paginate(
github.rest.issues.listComments,
{ owner, repo, issue_number, per_page: 100 },
);
const prior = existing.find((c) => c.body && c.body.includes(MARKER));

if (prior) {
await github.rest.issues.updateComment({
owner, repo, comment_id: prior.id, body,
});
} else {
await github.rest.issues.createComment({
owner, repo, issue_number, body,
});
}
4 changes: 4 additions & 0 deletions Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,10 @@ bench:
@echo "Running benchmarks..."
cd ffi/zig && zig build bench

# Run the mcp-bridge JS benches (path-claims overlap scan, leaf primitives)
bench-bridge:
@node mcp-bridge/tests/path_claims_bench.js

# Run end-to-end integration tests
integration:
@echo "Running integration tests..."
Expand Down
80 changes: 45 additions & 35 deletions coord-tui/shell/coord-hooks.sh
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,27 @@ _coord_post() {
-d "$payload" 2>/dev/null
}

# Quiet claim — echoes the raw backend response, sets the return code
# (0 on success, 1 otherwise). Shared by coord-claim and coord-worktree
# so both interpret "granted" the same way.
_coord_claim_quiet() {
local task="$1"
local tok; tok="$(_coord_token)"
[ -z "$tok" ] && return 2
local resp
resp=$(_coord_post coord_claim_task \
"{\"token\":\"$tok\",\"task\":\"$task\"}" 2>/dev/null)
printf '%s' "$resp"
printf '%s' "$resp" | python3 -c "
import sys, json
try:
d = json.load(sys.stdin)
sys.exit(0 if d.get('success') else 1)
except Exception:
sys.exit(1)
" 2>/dev/null
}

_coord_auto_register() {
local kind="$1"
# Registers silently, writes ~/.cache/coord-tui/peer.env, sets window title.
Expand Down Expand Up @@ -98,25 +119,27 @@ else:
# Claim a task: coord-claim hypatia/my-task
coord-claim() {
local task="${1:?Usage: coord-claim <task-name>}"
local tok; tok="$(_coord_token)"
if [ -z "$tok" ]; then
echo "Not registered — run: coord-tui --id --kind claude" >&2; return 1
local resp rc
resp="$(_coord_claim_quiet "$task")"; rc=$?
if [ $rc -eq 2 ]; then
echo "Not registered — run: coord-tui --id --kind claude" >&2
return 1
fi
local result
result=$(_coord_post coord_claim_task \
"{\"token\":\"$tok\",\"task\":\"$task\"}" 2>/dev/null)
echo "$result" | python3 -c "
import sys, json
if [ -z "$resp" ]; then
echo " ✗ Failed (adapter not running?)"
return 1
fi
printf '%s' "$resp" | TASK="$task" python3 -c "
import sys, os, json
d = json.load(sys.stdin)
task = os.environ.get('TASK', '')
if d.get('success'):
msg = d.get('message','')
if msg == 'granted':
print(f' ✓ Claimed: $task')
else:
print(f' ✗ {msg}')
print(f' ✓ Claimed: {task}' if msg == 'granted' else f' ✗ {msg}')
else:
print(f' ✗ {d.get(\"error\",\"unknown error\")}')
" 2>/dev/null || echo " ✗ Failed (adapter not running?)"
" 2>/dev/null
return $rc
}

# Claim a task AND provision an isolated git worktree for it.
Expand Down Expand Up @@ -149,28 +172,15 @@ coord-worktree() {
local branch="agent/${peer}/${safe}"

# Claim first — if the backend says no, don't touch the working tree.
local tok; tok="$(_coord_token)"
if [ -n "$tok" ]; then
local claim
claim=$(_coord_post coord_claim_task \
"{\"token\":\"$tok\",\"task\":\"$task\"}" 2>/dev/null)
local ok
ok=$(echo "$claim" | python3 -c "
import sys, json
try:
d = json.load(sys.stdin)
print('yes' if d.get('success') else 'no')
except Exception:
print('no')
" 2>/dev/null)
if [ "$ok" != "yes" ]; then
echo " ✗ Claim refused — not provisioning worktree." >&2
echo "$claim" >&2
return 1
fi
else
echo " ! No coord token — provisioning worktree without claim." >&2
fi
local resp rc
resp="$(_coord_claim_quiet "$task")"; rc=$?
case $rc in
0) ;; # granted
2) echo " ! No coord token — provisioning worktree without claim." >&2 ;;
*) echo " ✗ Claim refused — not provisioning worktree." >&2
[ -n "$resp" ] && echo "$resp" >&2
return 1 ;;
esac

mkdir -p "$(dirname "$wt_dir")"
if [ -d "$wt_dir" ]; then
Expand Down
42 changes: 22 additions & 20 deletions mcp-bridge/lib/dispatcher.js
Original file line number Diff line number Diff line change
Expand Up @@ -220,16 +220,7 @@ async function dispatchLocalCoord(toolName, args) {
}
}

// Advisory path-claims are bridge-layer only — strip from the
// payload forwarded to the verified Zig backend so its schema stays
// unchanged. Backend stays the source of truth for ownership.
let declaredPaths;
let forwarded = args || {};
if (toolName === "coord_claim_task" && args && Array.isArray(args.paths)) {
declaredPaths = args.paths;
const { paths, ...rest } = args;
forwarded = rest;
}
const { forwarded, ctx } = pathClaimsBefore(toolName, args);

try {
const res = await fetch(`${LOCAL_COORD_URL}/tools/${toolName}`, {
Expand All @@ -243,7 +234,7 @@ async function dispatchLocalCoord(toolName, args) {
} catch {
return { success: false, error: "local-coord-mcp backend returned non-JSON" };
}
return annotatePathClaims(toolName, args, data, declaredPaths);
return pathClaimsAfter(toolName, args, data, ctx);
} catch (e) {
return {
success: false,
Expand All @@ -253,27 +244,38 @@ async function dispatchLocalCoord(toolName, args) {
}
}

function annotatePathClaims(toolName, args, data, declaredPaths) {
// Path-claims live entirely at the bridge layer. The verified backend
// has no `paths` field on coord_claim_task, so we strip it before
// forwarding and stash it for the post-response annotate step.
function pathClaimsBefore(toolName, args) {
const a = args || {};
if (toolName === "coord_claim_task" && Array.isArray(a.paths)) {
const { paths, ...rest } = a;
return { forwarded: rest, ctx: { declaredPaths: paths } };
}
return { forwarded: a, ctx: {} };
}

function pathClaimsAfter(toolName, args, data, ctx) {
if (!data || typeof data !== "object") return data;
const task = args?.task;
switch (toolName) {
case "coord_claim_task": {
if (!declaredPaths || !task || data.success === false) return data;
const holder = data.holder || "(unknown)";
const ttl_s = typeof data.ttl_s === "number" ? data.ttl_s : undefined;
if (!ctx.declaredPaths || !task || data.success === false) return data;
const { paths, overlaps } = pathClaims.register({
task, holder, paths: declaredPaths, ttl_s,
task,
holder: data.holder || "(unknown)",
paths: ctx.declaredPaths,
ttl_s: typeof data.ttl_s === "number" ? data.ttl_s : undefined,
});
return { ...data, declared_paths: paths, path_overlap: overlaps };
}
case "coord_progress": {
case "coord_progress":
if (task) pathClaims.refresh(task, data.ttl_s);
return data;
}
case "coord_report_outcome": {
case "coord_report_outcome":
if (task) pathClaims.release(task);
return data;
}
default:
return data;
}
Expand Down
Loading
Loading