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
3 changes: 3 additions & 0 deletions .github/workflows/council_of_claudes.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ jobs:
NELLJERRAM_AGENT_TOKEN: ${{ secrets.NELLJERRAM_AGENT_TOKEN }}
CASEYDAVENPORT_AGENT_URL: ${{ secrets.CASEYDAVENPORT_AGENT_URL }}
CASEYDAVENPORT_AGENT_TOKEN: ${{ secrets.CASEYDAVENPORT_AGENT_TOKEN }}
# Orchestrator (cross-persona dedup); if unset, all comments are posted unfiltered.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛡️ Security — The workflow introduces new secrets (ORCHESTRATOR_AGENT_URL/TOKEN). If this job can run in contexts where repository secrets are available to untrusted fork PRs, an attacker could cause outbound calls to an attacker-controlled URL or probe for environment details through log messages. Mitigation: ensure the workflow only runs in trusted contexts (e.g., not on untrusted forks) or add an explicit guard like if: ${{ !github.event.pull_request.head.repo.fork }}. Alternatively, trigger via pull_request_target with extreme caution and strict input validation on all consumed data.

ORCHESTRATOR_AGENT_URL: ${{ secrets.ORCHESTRATOR_AGENT_URL }}
ORCHESTRATOR_AGENT_TOKEN: ${{ secrets.ORCHESTRATOR_AGENT_TOKEN }}
steps:
# pull_request checks out the merge ref by default, so the script comes
# from the PR's own tree — letting us iterate on the workflow via its PR.
Expand Down
132 changes: 119 additions & 13 deletions .github/workflows/scripts/council_of_claudes.js
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,90 @@ async function reviewWithAgent({ core, title, url, token, messageText, msgId })
return null;
}

// --- Orchestrator: cross-persona deduplication of inline comments ---

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧪 Maintainability & Tests — Consider extracting the “Orchestrator” block (parseClusters, applyClusters, orchestrateDedup and the ORCH_* constants) into a dedicated module (e.g., .github/workflows/scripts/orchestrator.js) and import it here. This reduces file size/complexity and makes the logic easier to unit test in isolation. If you prefer not to split files, minimally export the helpers for tests:

// near the bottom, before module.exports default:
module.exports._orchestratorTest = { parseClusters, applyClusters, orchestrateDedup };

This keeps production behavior unchanged while enabling direct imports in a small Node test.

const ORCH_URL_ENV = 'ORCHESTRATOR_AGENT_URL';
const ORCH_TOKEN_ENV = 'ORCHESTRATOR_AGENT_TOKEN';

// Robustly extract the orchestrator's { clusters: [...] } JSON from its response
// (tolerates a ```json fence — single- or multi-line — or stray prose). Returns
// the parsed object or null.
function parseClusters(text) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧪 Maintainability & Tests — parseClusters has several tolerant fallback paths (fenced json, substring braces, raw text). Add unit tests covering:

  • Pure JSON object, fenced single-line and multi-line ```json blocks, and responses with leading/trailing prose.
  • Multiple fences present (ensure the right candidate is chosen).
  • Invalid JSON and non-matching shapes (returns null).
    A minimal node:test or assert-based test file under .github/workflows/scripts/ can exercise these without any build tooling.

if (!text) return null;
const tries = [text.trim()];
const fence = text.match(/```[a-zA-Z]*\s*([\s\S]*?)```/); // handles single-line fences too

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Nell Nell — parseClusters only checks the first fenced block. If the model emits multiple fences (preamble, then JSON), should we scan all fenced blocks and try each? Not a blocker, but might make it a touch more robust.

if (fence) tries.push(fence[1].trim());
const first = text.indexOf('{'), last = text.lastIndexOf('}');

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Casey Casey — I think this lenient “first { … last }” slice might be too permissive — it could grab the wrong substring if prose contains braces or multiple JSON-ish snippets. Since you already (a) try the whole message and (b) prefer a fenced block, can we drop this fallback (or at least only use it when there’s exactly one well-formed object between balanced backticks)?

Suggested change
const first = text.indexOf('{'), last = text.lastIndexOf('}');
const fence = text.match(/```[a-zA-Z]*\s*([\s\S]*?)```/);
if (fence) tries.push(fence[1].trim());
// Drop the brace-slice fallback to avoid accidental mis-parses; rely on fenced or plain-JSON only.

Reason: simpler and less error-prone; the agent instructions can enforce JSON-only or a fenced block.

if (first !== -1 && last > first) tries.push(text.slice(first, last + 1));
for (const t of tries) {
try {
const obj = JSON.parse(t);
if (obj && Array.isArray(obj.clusters)) return obj;
} catch { /* try next candidate */ }
}
return null;
}

// Pure decision logic: given the inline comments and the orchestrator's parsed

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Nell Nell — The comment says “Pure decision logic … No env / network — directly unit testable.” but the function logs via core.info/core.warning. So it isn’t pure anymore. Soften the comment, or move the logging into the caller and keep this one side‑effect free?

Suggested change
// Pure decision logic: given the inline comments and the orchestrator's parsed
// Decision logic: given the inline comments and the orchestrator's parsed
// clusters, return the SET of ids to drop. Defensive against arbitrary LLM
// output: honor a cluster only if its survivor is a known id; drop only known
// duplicate ids; keep-wins (an id that survives anywhere is never dropped);
// anything not mentioned is implicitly kept.

// clusters, return the SET of ids to drop. No env / network — directly unit
// testable. Defensive against arbitrary LLM output: honor a cluster only if its
// survivor is a known id; drop only known duplicate ids; keep-wins (an id that
// survives anywhere is never dropped); anything not mentioned is implicitly kept.
function applyClusters({ core, allInline, parsed }) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧪 Maintainability & Tests — applyClusters is pure and central to safety (omission=keep, keep-wins, unknown-id ignore). Add unit tests for:

  • Unknown ids in survivor/duplicates are ignored (drop set empty).
  • Conflicting listing (an id appears as survivor in one cluster and duplicate in another) → keep-wins triggers; drop set excludes that id and a warning is logged.
  • Survivor with empty duplicates is ignored (no-op).
  • Multiple clusters applied with disjoint ids.
    Codify these to prevent future regressions in the safety guarantees.

const known = new Set(allInline.map(c => c.id));
const survivors = new Set();
const candidates = new Set();
for (const cl of parsed.clusters) {
// Every field here is LLM-controlled / possibly garbage — guard each access.
if (!cl || typeof cl.survivor !== 'string' || !known.has(cl.survivor)) continue;
survivors.add(cl.survivor);
const dups = Array.isArray(cl.duplicates) ? cl.duplicates.filter(d => known.has(d)) : [];
dups.forEach(d => candidates.add(d));
if (dups.length) core.info(` orchestrator: keep ${cl.survivor}, drop [${dups.join(', ')}] — ${safe(cl.reason)}`);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Nell Nell — safe(cl.reason): OK to assume reason is always a string? If the field is missing or non‑string, does safe() handle that? (I think it does, but just checking.)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Casey Casey — nit: This log can get a bit long if reasons are verbose. Maybe cap reason length to keep the action log tidy?

Suggested change
if (dups.length) core.info(` orchestrator: keep ${cl.survivor}, drop [${dups.join(', ')}] — ${safe(cl.reason)}`);
const reason = safe(cl.reason || '');
const trimmed = reason.length > 200 ? `${reason.slice(0, 200)}…` : reason;
if (dups.length) core.info(` orchestrator: keep ${cl.survivor}, drop [${dups.join(', ')}] — ${trimmed}`);

Totally optional; up to you.

}
// The prompt says each id appears at most once; if the model listed an id as
// both a survivor and a duplicate, keep-wins silently saves it — warn so a
// misbehaving orchestrator is visible rather than silently absorbed.
const conflicts = [...candidates].filter(id => survivors.has(id));
if (conflicts.length) core.warning(`Orchestrator: ${conflicts.length} id(s) listed as both survivor and duplicate (kept via keep-wins): ${conflicts.join(', ')}`);
const drop = new Set([...candidates].filter(id => !survivors.has(id)));
core.info(`Orchestrator: dropping ${drop.size} duplicate comment(s) of ${allInline.length} inline`);
return drop;
}

// Ask the orchestrator which inline comments are redundant duplicates and return
// the SET of comment ids to drop. Lossless by construction: returns an empty set
// (drop nothing → post everything) if the orchestrator is unconfigured, errors,
// times out, or returns anything we can't safely apply. The try/catch makes the
// "post everything on failure" guarantee robust even if a callee starts throwing.
async function orchestrateDedup({ core, allInline }) {
const empty = new Set();
const url = process.env[ORCH_URL_ENV];

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛡️ Security — The orchestrator URL is read from the environment and used for an outbound request without validating the scheme or host. A misconfigured URL could downgrade to plaintext HTTP or redirect to an unintended host, leaking the dedup payload and the bearer token. Mitigation: before calling reviewWithAgent, validate the URL:

  • Require HTTPS: new URL(url).protocol === 'https:'
  • Optionally enforce an allowlist for hostnames (e.g., agents.tigera.ai)
  • Reject/skip orchestration (post everything) if validation fails, and log a warning without printing the raw URL.

Example:

const u = new URL(url);
if (u.protocol !== 'https:' || !['agents.tigera.ai'].includes(u.hostname)) {
  core.warning('Orchestrator URL failed validation — skipping dedup and posting all comments.');
  return empty;
}

const token = process.env[ORCH_TOKEN_ENV];
if (!url || !token) {
core.info(`Orchestrator not configured (${ORCH_URL_ENV}/${ORCH_TOKEN_ENV} unset) — posting all comments`);
return empty;
}
if (allInline.length < 2) return empty; // nothing to dedupe (need ≥2 comments)

try {
const msgId = `council-orchestrator-${process.env.GITHUB_RUN_ID}`;
const payload = JSON.stringify(allInline.map(c =>
({ id: c.id, persona: c.persona, file: c.file, line: c.line, body: c.body })));
const messageText =
`Here are ${allInline.length} inline review comments from one pull request, as a JSON array. ` +

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Nell Nell — We send the raw JSON array inline in messageText. Do we want to fence it as JSON to help the orchestrator’s parser? Something like:

Suggested change
`Here are ${allInline.length} inline review comments from one pull request, as a JSON array. ` +
const messageText =
`Here are ${allInline.length} inline review comments from one pull request, as a JSON array. ` +
`Identify redundant duplicate groups and return the clusters JSON per your instructions.\n\n` +
"```json\n" + payload + "\n```";

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Casey Casey — I wonder if we should be more prescriptive here to reduce parser flexibility later — can we explicitly ask for “only the JSON object, no prose” in the message? That’d let us rely on the fence/plain-JSON path and avoid the broad brace-slice.

Suggested change
`Here are ${allInline.length} inline review comments from one pull request, as a JSON array. ` +
const messageText =
`Here are ${allInline.length} inline review comments from one pull request, as a JSON array. ` +
`Identify redundant duplicate groups and return ONLY the JSON object described (no prose). ` +
`A fenced \`\`\`json block is acceptable.\n\n${payload}`;

`Identify redundant duplicate groups and return the clusters JSON per your instructions.\n\n${payload}`;

const out = await reviewWithAgent({ core, title: 'Orchestrator', url, token, messageText, msgId });

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧪 Maintainability & Tests — Please verify reviewWithAgent already has a bounded poll/timeout. If not, consider adding an explicit timeout via AbortController/fetch timeout or a max poll duration passed into reviewWithAgent, so a stuck orchestrator can’t stall posting indefinitely. Add a brief comment here pointing to the timeout semantics to aid future maintainers.

if (!out) { core.warning('Orchestrator: no response — posting all comments unfiltered'); return empty; }
const parsed = parseClusters(out);
if (!parsed) { core.warning('Orchestrator: unparseable response — posting all comments unfiltered'); return empty; }
return applyClusters({ core, allInline, parsed });
} catch (e) {
core.warning(`Orchestrator: unexpected error (${safe(e.message)}) — posting all comments unfiltered`);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛡️ Security — On error, the code logs safe(e.message). Depending on the underlying HTTP client, error messages can include request URLs or other connection details. While tokens aren’t in the URL here, exposing internal hostnames in public logs may still be undesirable. Mitigation: redact/omit URLs/hostnames in error messages (e.g., replace them with a constant tag) or map exceptions to a generic message. Ensure reviewWithAgent also avoids logging tokens/URLs on failures.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Casey Casey — Do we know if reviewWithAgent ever throws a non-Error (e.g., a string)? safe(e.message) will be undefined in that case — might be safer to stringify the whole error.

Suggested change
core.warning(`Orchestrator: unexpected error (${safe(e.message)}) — posting all comments unfiltered`);
core.warning(`Orchestrator: unexpected error (${safe(e && (e.message || String(e)))}) — posting all comments unfiltered`);

No big deal though if safe() already handles undefined cleanly.

return empty;
}
}

module.exports = async ({ github, context, core }) => {
const { owner, repo } = context.repo;
const pr = context.payload.pull_request;
Expand Down Expand Up @@ -296,26 +380,47 @@ module.exports = async ({ github, context, core }) => {
const existingComments = await github.paginate(github.rest.pulls.listReviewComments, { owner, repo, pull_number });
const repliedTo = new Set(existingComments.map(c => c.in_reply_to_id).filter(id => id != null));

let summaries = 0;
let inlineTotal = 0;
// 4a. Parse + classify each persona's findings. Accumulate the anchorable
// inline findings across ALL personas (each with a stable id) so the
// orchestrator can dedupe the whole set; keep summary/folded per persona.
const perPersona = [];
const allInline = [];
for (const res of results) {
if (!res) continue;
const p = res.persona;
// Persona badge: a custom avatar image if provided, else the emoji.
const badge = p.image ? `<img src="${p.image}" width="20" align="top" alt="${p.title}">` : p.emoji;
const inlineMarker = `<!-- council-of-claudes:inline:${p.key} -->`;
const { summary, findings } = parseFindings(res.review);

// Split findings into anchorable vs. unanchorable (folded into summary).
const folded = [];
const anchorable = [];
for (const f of findings) {
if (anchors.get(f.file)?.has(f.line)) anchorable.push(f);
else folded.push(f);
}
findings.forEach((f, i) => {
if (anchors.get(f.file)?.has(f.line)) {
// Run-scoped ephemeral id: generated and consumed within this single

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧪 Maintainability & Tests — Great inline rationale for ephemeral ids. For maintainability, add a brief note that these ids are intentionally not correlated with GitHub review comment IDs and exist only within a single run to drive orchestrator decisions. This helps future refactors avoid accidentally persisting or reusing them across runs.

// invocation (sent to the orchestrator, matched against its drop set).
// `i` is the index into `findings` (gaps where entries were folded are
// harmless — ids only need per-persona uniqueness); never persisted, so
// it must not be correlated across runs or against existingComments.
const id = `${p.key}-${i}`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Casey Casey — I think using i is fine as an ephemeral id, but would it help debuggability to include an easy-to-eye-catch hint (e.g., file:line) in the id or (maybe better) in the “keep/drop” logs? The id never persists, so this is just about making the action logs easier to correlate. Fine to leave as-is, though.

Suggested change
const id = `${p.key}-${i}`;
const id = `${p.key}-${i}`; // consider `${p.key}-${f.file}:${f.line}-${i}` for easier log correlation

anchorable.push({ id, ...f });
allInline.push({ id, persona: p.title, file: f.file, line: f.line, body: f.body });
} else {
folded.push(f); // no valid line anchor → goes to the summary
}
});
perPersona.push({ p, badge, inlineMarker, summary, folded, anchorable });
}

// 4a. Inline: delete this persona's prior comments that have NO replies
// (preserve any thread with discussion), then post the fresh set.
// 4b. Orchestrator: dedupe the combined inline set → ids to drop. Lossless on
// any failure / unconfigured (returns empty → post everything).
const drop = await orchestrateDedup({ core, allInline });

// 4c. Post per persona: delete prior no-reply inline, post surviving inline

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧪 Maintainability & Tests — Now that dedup happens globally, consider logging a short per-persona summary in addition to the global dropped count (e.g., “Correctness: posted X, dropped Y”), to make it easier to spot if one persona is consistently being deduped away. This is optional, but it can help tune prompt-level levers later.

// (skipping orchestrator-dropped duplicates), then the sticky summary.
let summaries = 0;
let inlineTotal = 0;
let dropped = 0;
for (const { p, badge, inlineMarker, summary, folded, anchorable } of perPersona) {
const priorInline = existingComments.filter(c => (c.body || '').includes(inlineMarker));
for (const c of priorInline) {
if (repliedTo.has(c.id)) continue; // keep — has a discussion thread
Expand All @@ -327,6 +432,7 @@ module.exports = async ({ github, context, core }) => {
}
let inline = 0;
for (const f of anchorable) {
if (drop.has(f.id)) { dropped++; continue; } // redundant — removed by orchestrator
const body = `${badge} **${p.title}** — ${f.body}\n\n${inlineMarker}`;
try {
await github.rest.pulls.createReviewComment({
Expand All @@ -341,8 +447,8 @@ module.exports = async ({ github, context, core }) => {
}
inlineTotal += inline;

// 4b. Summary: sticky review, updated in place. Folded findings (no valid
// line anchor) are listed here so nothing is lost.
// Summary: sticky review, updated in place. Folded findings (no valid line
// anchor) are listed here so nothing is lost.
const marker = `<!-- council-of-claudes:${p.key} -->`;
let body =
`### ${badge} Council of Claudes — ${p.title}\n\n` +
Expand All @@ -368,5 +474,5 @@ module.exports = async ({ github, context, core }) => {
core.warning(`${p.title}: failed to post/update summary (${safe(e.message)})`);
}
}
core.info(`Done: ${summaries}/${active.length} summaries, ${inlineTotal} inline comment(s) on PR #${pull_number}`);
core.info(`Done: ${summaries}/${active.length} summaries, ${inlineTotal} inline posted, ${dropped} duplicate(s) dropped on PR #${pull_number}`);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧪 Maintainability & Tests — Nice addition to the final summary. If you add unit tests, also assert the log string format in at least one test to detect accidental changes (people often grep CI logs for this).

};
67 changes: 67 additions & 0 deletions .github/workflows/scripts/orchestrator.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# Review Orchestrator — Council of Claudes

Paste this as the `systemMessage` when creating the **orchestrator** agent in the

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧪 Maintainability & Tests — Add a simple version tag and cross-link to the JS that consumes this schema to prevent drift:

  • At the top: “Schema: v1 (keep in sync with .github/workflows/scripts/council_of_claudes.js parseClusters/applyClusters)”
  • If the output shape changes, the code must be updated alongside this file in the same PR.

kagent portal (https://agents.tigera.ai). Model: `claude-opus-4-8`.

This agent is NOT a code reviewer — it does not read the diff or generate review comments. It is a deduplication layer that runs *after* the review personas and decides which of their comments are redundant restatements of each other.

---

You are the **deduplication orchestrator** for a multi-agent code-review system. Several independent reviewer personas have each left inline comments on one pull request. Personas frequently make the **same point** as one another (and sometimes repeat themselves), in different words. Your only job is to find those redundant groups so the system can keep one comment per point and drop the rest.

You do **not** judge whether a comment is correct, valuable, or nit-picky. You do **not** rewrite, summarize, or generate any comment text. You only group genuine duplicates and pick which one to keep.

## Input
A JSON array of inline review comments, each:
```json
{ "id": "<id — opaque per-request token; echo it back exactly>", "persona": "<persona name>", "file": "<path>", "line": <number>, "body": "<markdown>" }
```

## What counts as a duplicate
Two (or more) comments are duplicates **only when they make essentially the same actionable point** — the same underlying issue AND the same suggested fix/direction — even if:
- worded differently, or
- authored by different personas (or the same persona twice), or
- anchored a line or two apart.

These are **NOT** duplicates (keep them separate):
- Same file / function / topic but **different issues** (e.g. two distinct bugs in one function).
- A **general** observation vs. a **specific instance** of it.
- The same kind of issue (e.g. "naming") raised about **different** identifiers/locations.
- Comments that merely *relate* to each other but ask for different things.

When in doubt, **do not group them** — leave them separate. It is much worse to wrongly merge two distinct findings (losing one) than to leave a borderline pair unmerged. Precision over recall.

## Choosing the survivor
For each duplicate group, choose exactly one **survivor**: the single **clearest, most actionable, best-articulated** comment — the one that would most help the PR author understand and fix the issue.
Choose on clarity/usefulness alone, **regardless of which persona authored it**. The other comments in the group are the duplicates to drop.

## Output
Respond with **only** a JSON object (a fenced ```json block is acceptable), in exactly this shape:
```json
{
"clusters": [
{ "survivor": "<id>", "duplicates": ["<id>", "<id>"], "reason": "<one-line gist of the shared point>" }
]
}
```
Rules for the output:
- Include a cluster **only** if it has at least one duplicate (group size ≥ 2). A comment with no duplicate must **not** appear anywhere in the output — anything you don't mention is kept.
- Use the **exact** `id` values from the input. Never invent ids or return ids that weren't provided.
- Each id may appear **at most once** across the whole output (as a survivor or a duplicate, not both, and not in two clusters).
- Never include comment bodies, rewrites, or any prose outside the JSON object.
- If there are no duplicates at all, return `{ "clusters": [] }`.

## Example
Input (abbreviated):
```json
[
{"id":"a1","persona":"Correctness","file":"x.go","line":42,"body":"This `==` is case-sensitive; use strings.EqualFold or the feature silently disables."},
{"id":"b2","persona":"Nell","file":"x.go","line":42,"body":"Comparison is case-sensitive — should this use EqualFold?"},
{"id":"c3","persona":"Security","file":"y.go","line":10,"body":"Interval has no lower bound; a tiny value could hammer the dataplane."}
]
```
Output:
```json
{ "clusters": [ { "survivor": "a1", "duplicates": ["b2"], "reason": "case-sensitive compare should use EqualFold" } ] }
```
(`c3` is unique, so it is absent from the output and will be kept.)
3 changes: 3 additions & 0 deletions hack/council-of-claudes/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ The idea: take a real upstream PR that humans reviewed, reproduce **the exact co
reviewers first saw**, open it as a PR in this fork so the Council reviews it, then compare the
Council's feedback to the original human review.

**Design:** the orchestrator (cross-persona dedup) layer and the review-quality decisions are
documented in [`orchestrator-design.md`](./orchestrator-design.md).

## Tools

### `gen-benchmark-pr.sh <upstream-PR-number>`
Expand Down
10 changes: 7 additions & 3 deletions hack/council-of-claudes/gen-benchmark-pr.sh
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,12 @@ echo " author: @$author human top-level review comments: $humans"
echo " base : ${base:0:12}"
echo " head : ${head:0:12} (earliest human-reviewed commit)"

baseBr="coc-sample-$N-base"
headBr="coc-sample-$N-head"
# Optional iteration label (ITER=v2) → parallel duplicate PRs for the same

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧪 Maintainability & Tests — The ITER suffix is a helpful addition. Consider adding a one-liner to hack/council-of-claudes/README.md under the Tools section documenting usage (e.g., “Use ITER=v2 to create parallel duplicate PRs for the same sample: ITER=v2 ./gen-benchmark-pr.sh ”). This makes the knob discoverable without having to read the design doc.

# original, so a new run doesn't clobber an earlier labeled baseline. Empty by
# default (branches stay coc-sample-<N>-base/head).
SUFFIX="${ITER:+-$ITER}"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Casey Casey — Small portability question: this uses bash parameter expansion (:+). I don’t remember the shebang offhand — is this file bash-scripted already? If not, we should add/update it to bash to avoid sh quirks. WDYT?

Suggested change
SUFFIX="${ITER:+-$ITER}"
# At top of file:
#!/usr/bin/env bash

If it’s already there, ignore me.

baseBr="coc-sample-$N$SUFFIX-base"
headBr="coc-sample-$N$SUFFIX-head"

# Resolve the fork point (merge-base) and the as-reviewed diff via the GitHub
# compare API rather than local git. This works even when the PR was force-pushed
Expand Down Expand Up @@ -147,7 +151,7 @@ fi

echo "Opening PR ..."
gh pr create --repo "$FORK" --base "$baseBr" --head "$headBr" \
--title "[sample #$N] $title" \
--title "[sample #$N]${ITER:+ [$ITER]} $title" \
--body "Benchmark sample reproducing the **as-first-reviewed** state of [$UPREPO#$N](https://github.com/$UPREPO/pull/$N) (by @$author).

This PR's diff equals what the human reviewers first saw: fork point \`${fork:0:12}\` → earliest human-reviewed commit \`${head:0:12}\`. The original PR drew **$humans** human top-level review comments — the ground truth to compare the Council's feedback against.
Expand Down
Loading
Loading