diff --git a/.github/workflows/repo-analysis.yml b/.github/workflows/repo-analysis.yml
index 59454169c..a20472809 100644
--- a/.github/workflows/repo-analysis.yml
+++ b/.github/workflows/repo-analysis.yml
@@ -22,6 +22,7 @@ jobs:
timeout-minutes: 25
permissions:
contents: read
+ issues: write
steps:
- uses: actions/checkout@v6
with:
@@ -36,6 +37,8 @@ jobs:
- name: Run repo analysis
env:
DEEPINFRA_TOKEN: ${{ secrets.DEEPINFRA_TOKEN }}
+ GITHUB_TOKEN: ${{ github.token }}
+ GITHUB_REPOSITORY: ${{ github.repository }}
run: deno run --allow-all cli/src/cli.ts run .reviews/AnalyzeRepoCI.md --component-dir .reviews/components --component-dir .reviews/policies --component-dir core/components -j .reviews/journal.analyze.ci.jsonl > .reviews/analyze-report.md
- name: Write run metadata
diff --git a/.reviews/components/CleanupIssues.md b/.reviews/components/CleanupIssues.md
new file mode 100644
index 000000000..f99f11936
--- /dev/null
+++ b/.reviews/components/CleanupIssues.md
@@ -0,0 +1,152 @@
+---
+inputs:
+ cleanupAnalysis:
+ type: object
+ required: true
+ diagnostics:
+ type: object
+ required: true
+---
+
+```ts persist eval
+const token = process.env.GITHUB_TOKEN;
+const repo = process.env.GITHUB_REPOSITORY;
+
+if (!token || !repo) {
+ return "";
+}
+
+const [owner, name] = repo.split("/");
+const api = `https://api.github.com/repos/${owner}/${name}`;
+const headers = {
+ "Authorization": `Bearer ${token}`,
+ "Accept": "application/vnd.github+json",
+ "Content-Type": "application/json",
+};
+
+const LABEL = "ema-cleanup";
+const TOP_N = 5;
+
+// 1. Ensure label exists
+const labelsResult = yield* fetch(`${api}/labels/${LABEL}`, { headers })
+ .json()
+ .catch(() => null);
+
+if (!labelsResult || labelsResult.message) {
+ yield* fetch(`${api}/labels`, {
+ method: "POST",
+ headers,
+ body: JSON.stringify({
+ name: LABEL,
+ description: "Auto-generated cleanup finding from repo analysis",
+ color: "d4c5f9",
+ }),
+ }).expect();
+}
+
+// 2. Fetch all open issues with ema-cleanup label
+const existingIssues = [];
+let page = 1;
+while (true) {
+ const batch = yield* fetch(
+ `${api}/issues?labels=${LABEL}&state=open&per_page=100&page=${page}`,
+ { headers },
+ ).expect().json();
+
+ if (!Array.isArray(batch) || batch.length === 0) break;
+ existingIssues.push(...batch);
+ if (batch.length < 100) break;
+ page++;
+}
+
+// 3. Build marker → issue map
+const markerRe = //;
+const issuesByFile = new Map();
+for (const issue of existingIssues) {
+ const match = issue.body?.match(markerRe);
+ if (match) {
+ issuesByFile.set(match[1], issue);
+ }
+}
+
+// 4. Process top 5 clusters
+const topClusters = cleanupAnalysis.fileClusters.slice(0, TOP_N);
+const topFiles = new Set(topClusters.map(c => c.file));
+
+let created = 0;
+let updated = 0;
+let closed = 0;
+
+for (const cluster of topClusters) {
+ const marker = ``;
+ const title = `cleanup(${cluster.kind}): ${cluster.file} — ${cluster.totalViolations} violations, ${cluster.coOccurrence} rules`;
+
+ const bodyLines = [
+ marker,
+ "",
+ `## Cleanup: \`${cluster.file}\``,
+ "",
+ "| Metric | Value |",
+ "|---|---|",
+ `| Score | ${cluster.score} |`,
+ `| Kind | ${cluster.kind} |`,
+ `| Violations | ${cluster.totalViolations} |`,
+ `| Co-occurring rules | ${cluster.coOccurrence} |`,
+ `| Structural | ${cluster.categories.structural} |`,
+ `| Verbosity | ${cluster.categories.verbosity} |`,
+ `| Type-aware | ${cluster.categories.typeAware} |`,
+ `| Other | ${cluster.categories.other} |`,
+ "",
+ `**Top rules:** ${cluster.ruleIds.slice(0, 15).join(", ")}${cluster.ruleIds.length > 15 ? ` (+${cluster.ruleIds.length - 15} more)` : ""}`,
+ "",
+ "---",
+ `Generated by EMA repo analysis · Last updated: ${new Date().toISOString()}`,
+ ];
+ const body = bodyLines.join("\n");
+
+ const existing = issuesByFile.get(cluster.file);
+
+ if (existing) {
+ yield* fetch(`${api}/issues/${existing.number}`, {
+ method: "PATCH",
+ headers,
+ body: JSON.stringify({ title, body }),
+ }).expect();
+ updated++;
+ } else {
+ yield* fetch(`${api}/issues`, {
+ method: "POST",
+ headers,
+ body: JSON.stringify({
+ title,
+ body,
+ labels: [LABEL],
+ }),
+ }).expect();
+ created++;
+ }
+}
+
+// 5. Close issues whose files are no longer in top 5
+for (const [file, issue] of issuesByFile.entries()) {
+ if (!topFiles.has(file)) {
+ yield* fetch(`${api}/issues/${issue.number}/comments`, {
+ method: "POST",
+ headers,
+ body: JSON.stringify({
+ body: "Resolved — file no longer in top-5 cleanup targets. Closing automatically.",
+ }),
+ }).expect();
+
+ yield* fetch(`${api}/issues/${issue.number}`, {
+ method: "PATCH",
+ headers,
+ body: JSON.stringify({ state: "closed" }),
+ }).expect();
+ closed++;
+ }
+}
+
+const summary = `Cleanup issues: created ${created}, updated ${updated}, closed ${closed}`;
+return summary;
+```
diff --git a/.reviews/components/RepoPolicyReport.md b/.reviews/components/RepoPolicyReport.md
index 5211e37bc..72c63b068 100644
--- a/.reviews/components/RepoPolicyReport.md
+++ b/.reviews/components/RepoPolicyReport.md
@@ -27,3 +27,5 @@ inputs:
+
+
diff --git a/specs/code-review-agent-spec.md b/specs/code-review-agent-spec.md
index 7d23fb377..8257836ba 100644
--- a/specs/code-review-agent-spec.md
+++ b/specs/code-review-agent-spec.md
@@ -1236,11 +1236,36 @@ compatibility since tsgo uses Node module resolution.
`.github/pull_request_template.md` — scope confirmation, Rule of
Three checklist for new abstractions, dependency justification.
-### 13.8 Additional files
+### 13.8 Cleanup issues (`CleanupIssues.md`)
+
+The repo analysis pipeline creates idempotent GitHub issues for
+the top 5 file clusters ranked by `buildCleanupAnalysis()`.
+
+**Identity:** Each issue body contains a marker comment
+``. The component searches open issues
+with the `ema-cleanup` label for matching markers before creating.
+
+**Lifecycle:**
+
+| Existing issue? | File in top 5? | Action |
+|---|---|---|
+| No | Yes | Create new issue |
+| Yes | Yes | Update title + body with latest stats |
+| Yes | No | Close with resolution comment |
+
+Issues are driven entirely by deterministic cluster data — file
+path, score, violation count, co-occurring rule count, category
+breakdown. No LLM output parsing.
+
+Local runs skip issue creation (no `GITHUB_TOKEN` → return empty).
+
+### 13.9 Additional files
```
.reviews/
.oxlintrc.json Sensor config (committed)
+ components/
+ CleanupIssues.md Idempotent GitHub issue lifecycle
.github/
pull_request_template.md Process enforcement