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
16 changes: 6 additions & 10 deletions SKILL.md
Original file line number Diff line number Diff line change
@@ -1,16 +1,11 @@
---
name: github-delivery
description: >
Primary skill for the complete GitHub issue and pull-request lifecycle:
create PRDs, break down and triage issues, run QA intake, prepare agent briefs
and refactor plans, research issues, create linked PRs, list repository open
work, deliver external tracker work items, manage stacked PRs, watch and make
PRs merge-ready, resolve conflicts, run full bug/security/spec review, simplify
safely, supersede obsolete PRs, take over unresponsive PRs, report status,
merge with thanks, and close linked issues. Prefer this over thin babysit/watcher
skills. Watch MUST run scripts/ship-gate.mjs every wake. Default mutation mode
is read-only. Do not use for local pre-PR debugging, non-GitHub product planning,
or skill authoring.
GitHub issue/PR lifecycle skill: PRDs, triage/QA, research, linked/open-work
PRs, external tracker delivery, competing PR analysis, stacks, review/fix/
simplify/security, conflicts, watch/status, supersede/overtake, merge/closure.
Watch MUST run scripts/ship-gate.mjs every wake. Default mode is read-only.
Not for local pre-PR debugging, non-GitHub planning, or skill authoring.
---

# GitHub Delivery
Expand Down Expand Up @@ -50,6 +45,7 @@ simplify preparation first, then enter the merge workflow.
| Create PR for issue #N (bounded preflight → implement → pre-open bug/security gate); link + merge-ready | `references/create-pr-for-issue.md` |
| List my open PRs / what’s in review / repository open-work standup (read-only overview) | `references/open-work-status.md` |
| Inspect or deliver external work item ENG-42 through the GitHub lifecycle | `references/work-item-delivery.md` |
| Find competing / overlapping PR implementations (analysis only) | `references/consolidate-prs.md` |
| Full review on PR #N (or a list); babysit to green + verdict | `references/full-review-pr.md` |
| Spec and Standards review on PR #N | `references/spec-standards-review.md` |
| Simplify / clean up / deduplicate PR #N without behavior changes | `references/simplify-pr.md` |
Expand Down
71 changes: 71 additions & 0 deletions references/consolidate-prs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
<!-- policy-modules:start -->
Policy modules:
- policy-kernel
- evidence
- reviews
- publication
<!-- policy-modules:end -->

# Consolidate competing PRs

**Trigger:** “triage competing PRs”, “find overlapping PR implementations”, “which PRs duplicate each other?”, “consolidate these PRs”, or equivalent requests to identify competing implementations.

## Goal

Identify PRs that may represent competing implementations without guessing a canonical winner or closing anything during analysis.

This workflow is read-only. If the user later selects a canonical PR and authorizes superseding the others, delegate each network-visible change to `references/supersede-pr.md` and its existing mutation gates.

## Evidence

Collect complete PR identity and changed-file evidence for the requested repository/scope. Use `scripts/lib/pr-consolidation.mjs` to form candidate clusters.

High-confidence competing-implementation evidence:

- the same durable work-item identity plus substantial overlap of non-noise changed files on PRs targeting the same repository and base.

Medium-confidence candidate evidence:

- the same durable work-item identity without substantial implementation overlap; or
- substantial overlap of non-noise changed files on PRs targeting the same repository and base.

A shared work-item key alone means the PRs are related. It does not prove one replaces the other. One ticket may intentionally ship through multiple complementary PRs.

Do not treat shared README/lockfile/changelog changes alone as competing implementation evidence. Do not cluster across different bases; those may be legitimate ports/backports.

Title similarity, author identity, branch-name similarity, and AI-generated semantic guesses are leads only. They are not sufficient to close or supersede a PR.

## Canonical selection

The analyser deliberately returns `canonicalPr: null` and `selectionRequired: true` for every candidate cluster.

A consolidation plan is valid only when:

1. the cluster was proven by the current analysis;
2. the canonical PR is explicitly selected and belongs to that cluster;
3. every PR proposed for supersede has direct substantial implementation-overlap evidence with the selected canonical PR;
4. current live PR identity still matches the analysed repository/base/head evidence.

A transitive A-B-C cluster is not enough to let A supersede C when A and C lack direct supersede-grade evidence.

The plan may then identify the other PRs as candidates for `delegate_supersede_pr`. It does not perform those mutations itself.

## Failure rules

Fail closed when PR enumeration is incomplete, repository/base identity is missing, the requested cluster is not present in the current analysis, canonical selection is absent/invalid, or direct supersede-grade evidence is missing for any proposed replacement.

Do not infer that a PR is obsolete merely because another PR is newer, greener, authored by a maintainer, shares a tracker item, or has more reviews.

## Output

Report candidate clusters with:

- PR numbers and links;
- confidence (`high` or `medium`);
- exact evidence that connected each pair;
- whether an edge is strong enough for supersede planning;
- `canonical: not selected` until the user or an already-authorized workflow provides one.

## Provenance

The clustering concept was informed by `OutThisLife/brooklyn-skills` `pr-triage` (MIT, copyright Brooklyn Nicholson). GitHub Delivery keeps analysis separate from its existing supersede mutation workflow and uses deterministic repository/base/work-item/file evidence rather than copying Brooklyn's workflow text.
10 changes: 10 additions & 0 deletions scripts/lib/delivery-workflow-profiles.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,15 @@ const WORK_ITEM_GRAPH = Object.freeze({
...TERMINAL,
});

const CONSOLIDATE_GRAPH = Object.freeze({
ROUTE: ["PREFLIGHT"],
PREFLIGHT: ["COLLECT", "DONE"],
COLLECT: ["ANALYZE"],
ANALYZE: ["REPORT"],
REPORT: ["DONE"],
...TERMINAL,
});

const MERGE_GRAPH = Object.freeze({
ROUTE: ["PREFLIGHT"],
PREFLIGHT: ["PREPARE", "DONE"],
Expand Down Expand Up @@ -170,6 +179,7 @@ const PROFILE_DEFINITIONS = Object.freeze({
"create-pr-for-issue": { graph: CREATE_PR_GRAPH, mutation: "maintainer" },
"open-work-status": { graph: OPEN_WORK_GRAPH, mutation: "read-only" },
"work-item-delivery": { graph: WORK_ITEM_GRAPH, mutation: "profile-dependent" },
"consolidate-prs": { graph: CONSOLIDATE_GRAPH, mutation: "read-only" },
"full-review-pr": { graph: REVIEW_GRAPH, mutation: "review" },
"spec-standards-review": { graph: REVIEW_GRAPH, mutation: "review" },
"simplify-pr": { graph: REVIEW_GRAPH, mutation: "maintainer" },
Expand Down
182 changes: 182 additions & 0 deletions scripts/lib/pr-consolidation.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
const NOISE_PATH_RE = /(^|\/)(?:package-lock\.json|pnpm-lock\.yaml|yarn\.lock|CHANGELOG\.md|README\.md)$/i;

function positiveInteger(value, name) {
const number = Number(value);
if (!Number.isInteger(number) || number <= 0) throw new Error(`${name}_invalid`);
return number;
}

function normalizedRepo(value, name) {
const text = String(value ?? "").trim();
if (!/^[^/\s]+\/[^/\s]+$/.test(text)) throw new Error(`${name}_invalid`);
return text.toLowerCase();
}

function normalizeWorkItemKey(value) {
const text = String(value ?? "").trim().toUpperCase();
return /^[A-Z][A-Z0-9]*-\d+$/.test(text) ? text : null;
}

function normalizePullRequest(raw = {}) {
const number = positiveInteger(raw.number, "pr_number");
const repository = normalizedRepo(raw.repository, "pr_repository");
const base = String(raw.base ?? "").trim();
const headRepository = normalizedRepo(raw.headRepository ?? raw.repository, "pr_head_repository");
const head = String(raw.head ?? "").trim();
if (!base) throw new Error("pr_base_required");
if (!head) throw new Error("pr_head_required");
const files = [...new Set((raw.files || []).map((entry) => String(entry).trim()).filter(Boolean))].sort();
return {
number,
repository,
base,
headRepository,
head,
title: String(raw.title ?? ""),
workItemKey: normalizeWorkItemKey(raw.workItemKey),
files,
};
}

function overlap(left, right) {
const leftFiles = new Set(left.files.filter((path) => !NOISE_PATH_RE.test(path)));
const rightFiles = new Set(right.files.filter((path) => !NOISE_PATH_RE.test(path)));
const shared = [...leftFiles].filter((path) => rightFiles.has(path));
const denominator = Math.min(leftFiles.size, rightFiles.size);
return {
shared,
ratio: denominator === 0 ? 0 : shared.length / denominator,
};
}

function substantialImplementationOverlap(files) {
return files.shared.length >= 2 && files.ratio >= 0.6;
}

function pairEvidence(left, right) {
if (left.repository !== right.repository || left.base !== right.base) return null;
const files = overlap(left, right);
if (left.workItemKey && left.workItemKey === right.workItemKey) {
const supersedeGrade = substantialImplementationOverlap(files);
return {
kind: "same_work_item",
confidence: supersedeGrade ? "high" : "medium",
workItemKey: left.workItemKey,
sharedFiles: files.shared,
overlapRatio: files.ratio,
supersedeGrade,
};
}
if (substantialImplementationOverlap(files)) {
return {
kind: "changed_file_overlap",
confidence: "medium",
workItemKey: null,
sharedFiles: files.shared,
overlapRatio: files.ratio,
supersedeGrade: true,
};
}
return null;
}

function connectedComponents(pulls, edges) {
const adjacency = new Map(pulls.map((pull) => [pull.number, new Set()]));
for (const edge of edges) {
adjacency.get(edge.left).add(edge.right);
adjacency.get(edge.right).add(edge.left);
}
const visited = new Set();
const components = [];
for (const pull of pulls) {
if (visited.has(pull.number) || adjacency.get(pull.number).size === 0) continue;
const stack = [pull.number];
const numbers = [];
visited.add(pull.number);
while (stack.length) {
const number = stack.pop();
numbers.push(number);
for (const next of adjacency.get(number)) {
if (visited.has(next)) continue;
visited.add(next);
stack.push(next);
}
}
components.push(numbers.sort((a, b) => a - b));
}
return components;
}

function supersedeEvidenceBetween(cluster, left, right) {
return cluster.evidence.some((edge) =>
edge.evidence.supersedeGrade === true &&
((edge.left === left && edge.right === right) ||
(edge.left === right && edge.right === left)),
);
}

export function analyzePrConsolidation(rawPulls = []) {
const pulls = rawPulls.map(normalizePullRequest);
const numbers = new Set();
for (const pull of pulls) {
if (numbers.has(pull.number)) throw new Error(`duplicate_pr_number:${pull.number}`);
numbers.add(pull.number);
}

const edges = [];
for (let i = 0; i < pulls.length; i += 1) {
for (let j = i + 1; j < pulls.length; j += 1) {
const evidence = pairEvidence(pulls[i], pulls[j]);
if (!evidence) continue;
edges.push({ left: pulls[i].number, right: pulls[j].number, evidence });
}
}

const clusters = connectedComponents(pulls, edges).map((members) => {
const memberSet = new Set(members);
const clusterEdges = edges.filter((edge) => memberSet.has(edge.left) && memberSet.has(edge.right));
const confidence = clusterEdges.some((edge) => edge.evidence.confidence === "high") ? "high" : "medium";
return {
members,
confidence,
evidence: clusterEdges,
canonicalPr: null,
selectionRequired: true,
};
});

return {
state: clusters.length ? "candidates" : "none",
pulls,
clusters,
};
}

export function planPrConsolidation({ analysis, clusterMembers, canonicalPr } = {}) {
if (analysis?.state !== "candidates") throw new Error("consolidation_candidates_required");
const members = [...new Set((clusterMembers || []).map((value) => positiveInteger(value, "cluster_pr")))].sort((a, b) => a - b);
if (members.length < 2) throw new Error("consolidation_cluster_too_small");
const canonical = positiveInteger(canonicalPr, "canonical_pr");
if (!members.includes(canonical)) throw new Error("canonical_pr_not_in_cluster");
const matchingCluster = analysis.clusters.find((cluster) =>
cluster.members.length === members.length && cluster.members.every((number, index) => number === members[index]),
);
if (!matchingCluster) throw new Error("consolidation_cluster_not_proven");

const superseded = members.filter((number) => number !== canonical);
const unproven = superseded.filter((number) => !supersedeEvidenceBetween(matchingCluster, canonical, number));
if (unproven.length > 0) {
throw new Error(`canonical_pr_missing_supersede_evidence:${canonical}:${unproven.join(",")}`);
}

return {
state: "planned",
canonicalPr: canonical,
supersede: superseded.map((number) => ({
pr: number,
action: "delegate_supersede_pr",
canonicalPr: canonical,
})),
evidence: matchingCluster.evidence,
};
}
7 changes: 6 additions & 1 deletion scripts/lib/skill-router.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,13 @@ const FOLLOW_UP_ISSUE_REQUEST = /\bfollow[- ]?up\s+(?:issue|ticket)\b/;
const CREATE_PR_FOR_ISSUE_REQUEST = /\b(?:create|open)\b[\s\S]*\b(?:pr|pull request)\b[\s\S]*\b(?:issue|#\d+)\b/;
const IMPLEMENT_ISSUE_REQUEST = /\b(?:implement|fix|address|solve|resolve)\b[\s\S]{0,180}\b(?:issue|#\d+)\b|\b(?:issue|#\d+)\b[\s\S]{0,180}\b(?:implement|fix|address|solve|resolve)\b/;
const CREATE_PR_REQUEST = /\b(?:create|open|make)\b[\s\S]{0,120}\b(?:pr|pull request)\b/;
const RESEARCH_ISSUE_REQUEST = /\b(?:research|investigate)\b[\s\S]*\b(?:issue|issues|#\d+)\b/;
const OPEN_WORK_REQUEST = /\b(?:what do i have open|what(?:'s| is) in review|show (?:me )?my open (?:prs|pull requests)|list (?:me )?my open (?:prs|pull requests)|open (?:pr|pull request) standup|open[- ]work standup|my open work)\b/;
const WORK_ITEM_KEY = /\b[A-Z][A-Z0-9]*-\d+\b/i;
const WORK_ITEM_STATUS_REQUEST = /\b(?:what(?:'s| is) left|status|where is|where's|inspect|check|show me)\b/;
const WORK_ITEM_DELIVERY_REQUEST = /\b(?:ship|deliver|work on|implement|fix|finish|complete|take)\b|\b(?:create|open)\b[\s\S]{0,80}\b(?:pr|pull request)\b/;
const WORK_ITEM_PUBLICATION_REQUEST = /\b(?:ship|deliver)\b|\b(?:create|open)\b[\s\S]{0,80}\b(?:pr|pull request)\b/;
const CONSOLIDATE_PR_REQUEST = /\b(?:consolidate|cluster|triage|competing|overlapping|duplicate)\b[\s\S]{0,120}\b(?:prs|pull requests)\b|\b(?:prs|pull requests)\b[\s\S]{0,120}\b(?:competing|overlapping|duplicates?)\b/;
const DELIVERY_NAME = /\bgithub[- ]?delivery\b/;
const DELIVERY_UPDATE = /\b(update|upgrade)\b[\s\S]*\bgithub[- ]?delivery\b|\bgithub[- ]?delivery\b[\s\S]*\b(update|upgrade|latest stable release)\b/;
const DELIVERY_CONFIG = /\b(set ?up|install|configure|configuration|settings?|protection mode|windows hello)\b[\s\S]*\bgithub[- ]?delivery\b|\bgithub[- ]?delivery\b[\s\S]*\b(set ?up|install|configure|configuration|settings?|protection mode|windows hello)\b/;
Expand Down Expand Up @@ -99,6 +101,9 @@ export function routeShippingGithubPrompt(prompt) {
if (isOpenWorkRequest(text)) {
return result("references/open-work-status.md", "read-only", []);
}
if (CONSOLIDATE_PR_REQUEST.test(text) && !RESEARCH_ISSUE_REQUEST.test(text)) {
return result("references/consolidate-prs.md", "read-only", []);
}
if (isWorkItemRequest(text)) {
const readOnly = WORK_ITEM_STATUS_REQUEST.test(text) && !WORK_ITEM_DELIVERY_REQUEST.test(text);
return result(
Expand Down Expand Up @@ -135,7 +140,7 @@ export function routeShippingGithubPrompt(prompt) {
return result("references/create-pr-for-issue.md", "maintainer");
}
if (CREATE_PR_REQUEST.test(text) && !PR_REFERENCE.test(text)) return result("references/create-pr-from-local-work.md", "maintainer", ["push_code", "create_pr"]);
if (/\b(research|investigate)\b[\s\S]*\b(issue|issues|#\d+)\b/.test(text)) return result("references/research-issue.md", "review");
if (RESEARCH_ISSUE_REQUEST.test(text)) return result("references/research-issue.md", "review");

const issueCreationAction = issueCreationActionForPrompt(text);
if (issueCreationAction) return result("references/issue-workflows.md", "maintainer", [issueCreationAction]);
Expand Down
1 change: 1 addition & 0 deletions scripts/lib/workflow-mode.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
// is a workflow violation, never a publication excuse.

const WORKFLOW_MUTATION_MODES = Object.freeze({
"references/consolidate-prs.md": ["read-only"],
"references/create-pr-for-issue.md": ["maintainer"],
"references/create-pr-from-local-work.md": ["maintainer"],
"references/fix-pr-bots.md": ["maintainer"],
Expand Down
34 changes: 34 additions & 0 deletions tests/unit/pr-consolidation-routing.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import assert from "node:assert/strict";
import test from "node:test";

import { resolveDeliveryWorkflowProfile } from "../../scripts/lib/delivery-workflow-profiles.mjs";
import { routeShippingGithubPrompt } from "../../scripts/lib/skill-router.mjs";
import { validateWorkflowMutationMode } from "../../scripts/lib/workflow-mode.mjs";

test("routes competing PR analysis read-only", () => {
const route = routeShippingGithubPrompt("triage the competing PRs in this repo");
assert.equal(route.workflow, "references/consolidate-prs.md");
assert.equal(route.mutationMode, "read-only");
assert.deepEqual(route.explicitActions, []);
});

test("issue research keeps precedence when PR duplicates are only research evidence", () => {
const route = routeShippingGithubPrompt(
"Research issues #88 and #91 on the latest development branch — still real bugs? already fixed? open PRs? duplicates? priority; comment on each issue",
);
assert.equal(route.workflow, "references/research-issue.md");
assert.equal(route.mutationMode, "review");
assert.deepEqual(route.explicitActions, []);
});

test("consolidation route never grants maintainer mutation mode", () => {
assert.equal(validateWorkflowMutationMode({ workflow: "references/consolidate-prs.md", mutationMode: "read-only" }).valid, true);
assert.equal(validateWorkflowMutationMode({ workflow: "references/consolidate-prs.md", mutationMode: "maintainer" }).valid, false);
});

test("consolidation controller terminates after analysis/report", () => {
const profile = resolveDeliveryWorkflowProfile("consolidate-prs");
assert.equal(profile.mutation, "read-only");
assert.deepEqual(profile.graph.ANALYZE, ["REPORT"]);
assert.deepEqual(profile.graph.REPORT, ["DONE"]);
});
Loading