---
name: detect-upstream-candidates
description: Compare client source repos against a canonical template repo and produce a structured list of generalizable patterns worth proposing upstream. Detects via diff-scan and nominated PRs, applies a denylist and a generalizability discriminator, and scores cross-client corroboration. Read-only — emits candidates, changes nothing.
argument-hint: "[path-to-template-repo]"
---

# Detect Upstream Candidates

Read-only detection phase of the upstream-harvest loop. Given a canonical **template repo**
(the baseline) and a set of **client source repos**, identify changes that exist in clients
but not in the template and that are worth generalizing back upstream — then score how many
clients independently converged on each one.

Output is a structured candidate list conforming to `assets/candidate-schema.json` of the
`harvest-upstream-patterns` skill. **This skill writes no files to the repos** (it may write
the candidate list to a working artifact like `candidates.json`).

## Inputs

`$ARGUMENTS` — the **template repo path** (the canonical baseline). If empty, default to the
current working directory.

The orchestrator (`harvest-upstream-patterns`) or the calling workflow also provides:

- **Template root** — resolved from `$ARGUMENTS` (or cwd); the baseline every delta is
  measured against.
- **Sources** — client repos to harvest from (`org/repo`, cloned read-only, or local paths).
- **Mode**:
  - `scan` — diff each source against the template AND collect source PRs labeled
    `upstream-candidate`.
  - `pr` — examine a single nominated client PR (manual override / testing path).
- **Denylist** — globs that are never read or harvested (see Step 1).
- **Ledger** — existing `harvest-ledger.yml` entries, for dedup.

## Step 1 — Apply eligibility (denylist first)

Harvest from **anything except** the denylist. Default denylist globs (the caller may
extend, never shrink below this):

```
**/.env*            **/*secret*         **/*credential*
**/*.pem            **/*.key            **/*.p12   **/*.pfx
**/data/**          **/seeds/**         **/dags/**   (DAG business logic)
**/*client*name*    client-named modules and fixtures
```

Never open a denylisted file. If a promising change lives only in a denylisted path, it is
not eligible — skip it. (Rationale and full list:
`${CLAUDE_PLUGIN_ROOT}/skills/harvest-upstream-patterns/references/generalization-contract.md`.)

## Step 2 — Gather raw deltas per source

For each source repo:

- **`scan` mode:**
  1. Diff the source against the template for **eligible** files only. You are looking for
     things the source does that the template does not (or does worse): hardening, config
     improvements, CI/CD steps, Dockerfile patterns, dev-experience tooling, docs.
  2. Also collect every source PR labeled `upstream-candidate` and read its diff — these are
     human nominations and start with elevated priority.
- **`pr` mode:** read only the single nominated PR's diff.

For each raw delta, capture: the eligible file(s) touched, a tight description of *what*
changed relative to the template, and the source location (repo + PR/ref + path).

## Step 3 — Discriminate: innovation vs. client-specific customization

This is the crux. A raw delta becomes a **candidate** only if it is genuinely reusable.
Test each delta against ALL of:

1. **Eligible** — in a non-denylisted file (Step 1).
2. **Generally applicable** — a hardening / quality / reliability / DX improvement, not a
   client-specific source, credential, hostname, schedule, or business rule.
3. **Improves the template for all consumers** — would benefit every repo scaffolded from
   the template, not just this client.
4. **Portable** — can be expressed without client context once generalized (no dangling
   references to client-only files, services, or data).

A delta that fails any test is **not** a single-source candidate. Record borderline ones
(passes 1 + 4 but you are unsure on 2 or 3) in a `considered` list with the reason — they
may still surface via corroboration in Step 4.

## Step 4 — Score cross-client corroboration (the strong signal)

Independent convergence across clients is strong evidence a pattern is genuinely good.
After gathering candidates and `considered` items from ALL sources:

1. **Cluster by normalized intent.** Two deltas corroborate when they represent the *same
   directional change from the template baseline* — e.g. both add `cap_drop: [ALL]` to
   compose services; both pin a base image by digest; both add `no-new-privileges`.
   Normalize away client-specific surface detail when matching (service names, values).
2. Assign each cluster a stable `id` (kebab slug of the intent), and set:
   - `sources[]` — every contributing client repo + ref/PR + location.
   - `corroboration_count` — number of distinct source repos in the cluster.
   - `confidence` — `corroborated` if `corroboration_count >= 2`, else `single`.
3. **Threshold effect (promotion rule):**
   - `single` candidates must have fully passed the Step 3 discriminator to be proposed.
   - **`corroborated` candidates are promoted to proposed even if borderline** — i.e. a
     delta sitting in the `considered` list is *promoted* the moment a second independent
     client exhibits the same directional change. Two clients moving the same way against
     our current practice outweighs an individual judgment call.
   - Corroboration **never** relaxes the generalization/stripping standard applied later;
     it only changes whether the candidate clears the bar to be proposed.
4. **Directional check (avoid false corroboration):** only count clients moving the *same
   way relative to the template*. If the template already does X and a client diverged
   *away* from X, that is not corroboration — it is likely a client-specific exception and
   is rejected.

## Step 5 — Dedup against the ledger and open PRs

Drop any candidate whose `id`:

- appears in the ledger with status `ported` or `declined`, or
- already has an open `harvest/<id>` PR in the template repo.

A candidate previously `proposed` but neither merged nor declined (PR still open) is also
skipped here — it is already in flight.

## Step 6 — Emit the candidate list

Output the surviving candidates as JSON conforming to
`${CLAUDE_PLUGIN_ROOT}/skills/harvest-upstream-patterns/assets/candidate-schema.json`. Sort **corroborated first**
(highest `corroboration_count` first), then single-source in detection order.

Include a short `considered_but_skipped` section (id, intent, reason) for transparency —
this documents what detection saw and chose not to propose, so a human can spot a missed
call.

## Rules

- Read-only. Never modify any source or template file.
- Never read denylisted files, even to "check." Eligibility is decided by path.
- The candidate `id` must be stable across runs (same intent → same id) so dedup and the
  deterministic branch name `harvest/<id>` work.
- Prefer fewer, well-formed candidates over many noisy ones — except where corroboration
  justifies promotion. Noise erodes trust in the loop.
- Capture enough source location detail (`repo`, `ref/PR`, `path`, and the relevant snippet)
  that `generalize-pattern` can act without re-deriving the delta.
