diff --git a/.github/scripts/bot-pr-review-chain.js b/.github/scripts/bot-pr-review-chain.js
new file mode 100644
index 0000000..e5f6293
--- /dev/null
+++ b/.github/scripts/bot-pr-review-chain.js
@@ -0,0 +1,155 @@
+/**
+ * Idempotent CodeRabbit/Qodo kick for bot-opened PRs.
+ * @see .github/workflows/auto-pr-comment.yml, claude.yml, bot-pr-recovery.yml
+ */
+
+const KICK_AUTHORS = new Set(['MervinPraison', 'github-actions[bot]']);
+const BOT_PR_AUTHORS = new Set(['praisonai-triage-agent[bot]', 'github-actions[bot]']);
+
+async function listAllComments(github, owner, repo, issueNumber) {
+ if (typeof github.paginate === 'function') {
+ return github.paginate(github.rest.issues.listComments, {
+ owner,
+ repo,
+ issue_number: issueNumber,
+ per_page: 100,
+ });
+ }
+ const { data } = await github.rest.issues.listComments({
+ owner,
+ repo,
+ issue_number: issueNumber,
+ per_page: 100,
+ });
+ return data;
+}
+
+function kickAuthored(comment, marker) {
+ return (
+ KICK_AUTHORS.has(comment.user?.login) &&
+ (comment.body || '').includes(marker)
+ );
+}
+
+function coderabbitKickPosted(comments) {
+ return comments.some((c) => kickAuthored(c, '@coderabbitai review'));
+}
+
+function qodoKickPosted(comments) {
+ return comments.some(
+ (c) => KICK_AUTHORS.has(c.user?.login) && (c.body || '').trim() === '/review'
+ );
+}
+
+function chainKickPosted(comments) {
+ return coderabbitKickPosted(comments) && qodoKickPosted(comments);
+}
+
+function isBotOpenedPr(pr) {
+ if (BOT_PR_AUTHORS.has(pr.user?.login)) return true;
+ return pr.user?.type === 'Bot';
+}
+
+async function kickReviewChain(github, owner, repo, prNumber, core, preFetchedComments = null) {
+ const comments = preFetchedComments || await listAllComments(github, owner, repo, prNumber);
+ const needCoderabbit = !coderabbitKickPosted(comments);
+ const needQodo = !qodoKickPosted(comments);
+
+ if (!needCoderabbit && !needQodo) {
+ core?.info?.(`Review chain already kicked on PR #${prNumber}`);
+ return { kicked: false, reason: 'already_kicked' };
+ }
+
+ if (needCoderabbit) {
+ await github.rest.issues.createComment({
+ owner,
+ repo,
+ issue_number: prNumber,
+ body: '@coderabbitai review',
+ });
+ }
+ if (needQodo) {
+ await github.rest.issues.createComment({
+ owner,
+ repo,
+ issue_number: prNumber,
+ body: '/review',
+ });
+ }
+ core?.info?.(`Kicked review chain for PR #${prNumber} (coderabbit=${needCoderabbit}, qodo=${needQodo})`);
+ return { kicked: true, coderabbit: needCoderabbit, qodo: needQodo };
+}
+
+async function findOpenPrForIssue(github, owner, repo, issueNumber) {
+ const prefix = `claude/issue-${issueNumber}-`;
+ const { data: prs } = await github.rest.pulls.list({
+ owner,
+ repo,
+ state: 'open',
+ sort: 'created',
+ direction: 'desc',
+ per_page: 30,
+ });
+ return (
+ prs.find(
+ (p) => (p.head?.ref || '').startsWith(prefix) && isBotOpenedPr(p)
+ ) || null
+ );
+}
+
+async function kickReviewChainForIssue(github, owner, repo, issueNumber, core) {
+ const pr = await findOpenPrForIssue(github, owner, repo, issueNumber);
+ if (!pr) {
+ core?.info?.(`No open PR for issue #${issueNumber}, skipping review kick`);
+ return { kicked: false, reason: 'no_pr' };
+ }
+ const result = await kickReviewChain(github, owner, repo, pr.number, core);
+ return { ...result, prNumber: pr.number };
+}
+
+async function recoverStalledBotPrs(github, owner, repo, options, core) {
+ const { prNumber = null, minAgeMs = 10 * 60 * 1000, maxRecover = 10 } = options || {};
+ let prs;
+ if (prNumber) {
+ const { data } = await github.rest.pulls.get({ owner, repo, pull_number: prNumber });
+ prs = [data];
+ } else if (typeof github.paginate === 'function') {
+ prs = await github.paginate(github.rest.pulls.list, {
+ owner,
+ repo,
+ state: 'open',
+ per_page: 100,
+ });
+ } else {
+ const { data } = await github.rest.pulls.list({ owner, repo, state: 'open', per_page: 100 });
+ prs = data;
+ }
+
+ const cutoff = Date.now() - minAgeMs;
+ let recovered = 0;
+ for (const pr of prs) {
+ if (recovered >= maxRecover) break;
+ if (!isBotOpenedPr(pr)) continue;
+ if (!prNumber && new Date(pr.created_at).getTime() > cutoff) continue;
+ const comments = await listAllComments(github, owner, repo, pr.number);
+ if (chainKickPosted(comments)) continue;
+ await kickReviewChain(github, owner, repo, pr.number, core, comments);
+ recovered += 1;
+ }
+ core?.info?.(`Recovery complete (${recovered} PR(s) kicked)`);
+ return recovered;
+}
+
+module.exports = {
+ KICK_AUTHORS,
+ BOT_PR_AUTHORS,
+ listAllComments,
+ chainKickPosted,
+ coderabbitKickPosted,
+ qodoKickPosted,
+ isBotOpenedPr,
+ kickReviewChain,
+ findOpenPrForIssue,
+ kickReviewChainForIssue,
+ recoverStalledBotPrs,
+};
diff --git a/.github/scripts/ci-failure-claude-selftest.js b/.github/scripts/ci-failure-claude-selftest.js
new file mode 100644
index 0000000..36f7f21
--- /dev/null
+++ b/.github/scripts/ci-failure-claude-selftest.js
@@ -0,0 +1,35 @@
+#!/usr/bin/env node
+/**
+ * Run: node .github/scripts/ci-failure-claude-selftest.js
+ */
+const ciFix = require('./ci-failure-claude.js');
+const mergeGate = require('./merge-gate.js');
+const config = require('./gate-config.js');
+
+let failed = 0;
+function assert(name, cond) {
+ if (!cond) {
+ console.error('FAIL:', name);
+ failed += 1;
+ } else {
+ console.log('ok:', name);
+ }
+}
+
+const LOG = `
+python UNKNOWN STEP FAILED (0.0100s) tests/unit/test_example.py::test_foo - AssertionError: bar
+python UNKNOWN STEP ##[error]Process completed with exit code 1.
+`;
+
+const parsed = ciFix.parsePytestFailures(LOG);
+assert('parses pytest failure', parsed.length === 1);
+assert('uses CI workflow list', config.ciFailureWorkflowRuns.includes('CI'));
+
+const comment = ciFix.buildCiFixComment({
+ headSha: 'abc1234567890abcdef1234567890abcdef12',
+ failedChecks: [{ name: 'python', workflow: 'CI', html_url: 'https://example.com/job/1' }],
+ failureSummaries: [{ jobName: 'python', failures: parsed }],
+});
+assert('comment mentions product guardrails', comment.includes('Product guardrails'));
+
+process.exit(failed ? 1 : 0);
diff --git a/.github/scripts/ci-failure-claude.js b/.github/scripts/ci-failure-claude.js
new file mode 100644
index 0000000..34dbe1b
--- /dev/null
+++ b/.github/scripts/ci-failure-claude.js
@@ -0,0 +1,294 @@
+/**
+ * Post @claude comments with CI failure details for internal PRs.
+ * @see .github/workflows/ci-failure-claude.yml
+ */
+
+const mergeGate = require('./merge-gate.js');
+const config = require('./gate-config.js');
+
+const CI_FIX_LABEL = 'claude-ci-fix-pending';
+const COOLDOWN_MS = 12 * 60 * 60 * 1000;
+const AUTO_ACTORS = mergeGate.AUTO_ACTORS;
+const MAX_FAILURES = 15;
+const MAX_JOBS_TO_FETCH = 5;
+
+function shortSha(headSha) {
+ return (headSha || '').slice(0, 8);
+}
+
+function ciFixShaMarker(headSha) {
+ return `ci failed on head \`${shortSha(headSha)}`.toLowerCase();
+}
+
+function isCiFixComment(comment) {
+ const body = (comment.body || '').toLowerCase();
+ return body.includes('@claude') && body.includes('ci failed on head');
+}
+
+function hasCiFixCommentForSha(comments, headSha) {
+ const marker = shortSha(headSha).toLowerCase();
+ return comments.some((c) => {
+ if (!AUTO_ACTORS.includes(c.user?.login)) return false;
+ if (!isCiFixComment(c)) return false;
+ return (c.body || '').toLowerCase().includes(marker);
+ });
+}
+
+function hasRecentCiFixComment(comments, headSha) {
+ const cutoff = Date.now() - COOLDOWN_MS;
+ const marker = shortSha(headSha).toLowerCase();
+ return comments.some((c) => {
+ if (!AUTO_ACTORS.includes(c.user?.login)) return false;
+ if (!isCiFixComment(c)) return false;
+ const body = (c.body || '').toLowerCase();
+ if (body.includes(marker)) return true;
+ return new Date(c.created_at).getTime() > cutoff;
+ });
+}
+
+function shouldSkipCiFix({ comments, headSha, labels, hasFinal, claudeInProgress, failedChecks }) {
+ if (!failedChecks.length) return { skip: true, reason: 'no failed checks' };
+ if (labels.includes(CI_FIX_LABEL)) return { skip: true, reason: 'ci fix pending' };
+ if (!hasFinal) return { skip: true, reason: 'awaiting final claude review trigger' };
+ if (claudeInProgress) return { skip: true, reason: 'claude in progress' };
+ if (hasCiFixCommentForSha(comments, headSha)) {
+ return { skip: true, reason: 'already commented for this sha' };
+ }
+ if (hasRecentCiFixComment(comments, headSha)) {
+ return { skip: true, reason: 'recent ci fix comment cooldown' };
+ }
+ return { skip: false };
+}
+
+function parsePytestFailures(logText) {
+ if (!logText) return [];
+ const lines = logText.split('\n');
+ const failures = [];
+ const seen = new Set();
+
+ for (let i = lines.length - 1; i >= 0 && failures.length < MAX_FAILURES; i -= 1) {
+ const line = lines[i].trim();
+ if (!line) continue;
+
+ const pytestMatch = line.match(
+ /FAILED\s*(?:\([^)]+\))?\s+(tests\/[^\s]+(?:::[^\s-]+)*)\s*-\s*(.+)/i
+ );
+ if (pytestMatch) {
+ const testId = pytestMatch[1];
+ if (!seen.has(testId)) {
+ seen.add(testId);
+ failures.push({ testId, error: pytestMatch[2].trim() });
+ }
+ continue;
+ }
+
+ const errorMatch = line.match(/##\[error\](.+)/);
+ if (errorMatch && /tests\//.test(errorMatch[1])) {
+ const msg = errorMatch[1].trim();
+ if (!seen.has(msg)) {
+ seen.add(msg);
+ failures.push({ testId: msg, error: msg });
+ }
+ }
+ }
+
+ return failures;
+}
+
+async function fetchJobFailureSummary(github, owner, repo, jobId, jobName) {
+ try {
+ const response = await github.rest.actions.downloadJobLogsForWorkflowRun({
+ owner,
+ repo,
+ job_id: jobId,
+ });
+ let logText = typeof response.data === 'string' ? response.data : String(response.data || '');
+ if (logText.length > 100000) {
+ logText = logText.slice(-100000);
+ }
+ return { jobName, jobId, failures: parsePytestFailures(logText) };
+ } catch (err) {
+ return { jobName, jobId, failures: [], error: err.message };
+ }
+}
+
+function mapFailedChecks(runs) {
+ return runs.map((run) => ({
+ name: run.name,
+ id: run.id,
+ html_url: run.html_url || run.details_url,
+ workflow: run.app?.slug || run.check_suite?.app?.slug || run.name,
+ }));
+}
+
+function buildCiFixComment({ headSha, failedChecks, failureSummaries }) {
+ const parts = [
+ `@claude CI failed on HEAD \`${shortSha(headSha)}\`. Please fix the failures below and push to this branch.`,
+ '',
+ '## Failed checks',
+ ];
+
+ for (const check of failedChecks) {
+ parts.push(`- **${check.workflow || check.name}** / \`${check.name}\` — ${check.html_url}`);
+ }
+
+ parts.push('', '## Failures (extracted)');
+ let idx = 0;
+ for (const summary of failureSummaries) {
+ for (const failure of summary.failures) {
+ idx += 1;
+ if (idx > MAX_FAILURES) break;
+ parts.push(`${idx}. \`${failure.testId}\` — \`${failure.error}\``);
+ parts.push(` - Job: \`${summary.jobName}\``);
+ }
+ if (idx >= MAX_FAILURES) break;
+ }
+
+ if (idx === 0) {
+ parts.push('_(Could not extract pytest details — see job logs above.)_');
+ }
+
+ const exampleTest = failureSummaries.find((s) => s.failures.length)?.failures[0]?.testId;
+ parts.push(
+ '',
+ '## Critical review first',
+ 'Before changing code or tests, decide **which side is wrong**:',
+ '1. **Legitimate feature change** — the PR intent is correct but implementation or tests need updating. Preserve product guarantees; update tests only when behaviour intentionally changed and document why.',
+ '2. **Regression / bug in this PR** — the failure exposes a real breakage introduced here. Fix the implementation; **do not weaken, skip, or delete tests** just to go green.',
+ '3. **Pre-existing flake or unrelated failure** — say so explicitly; prefer fixing the root cause over masking it.',
+ '',
+ '**Product guardrails (AGENTS.md):**',
+ `- ${config.finalClaudeScope}`,
+ '- Tests must continue to guard backward compatibility — passing CI by lowering test standards is not acceptable.',
+ '- If the feature does not genuinely add product value, recommend reverting or narrowing scope instead of patching around failures.',
+ '',
+ '## What to do',
+ '1. State your verdict: **legitimate fix**, **regression fix**, or **needs human review** — and why (1–3 sentences).',
+ '2. Fix root cause with **minimal changes**; never bloat the Agent class with extra params.',
+ exampleTest
+ ? `3. Run failing tests locally, e.g. \`pytest ${exampleTest} -q\`, plus any related tests touched by the PR.`
+ : '3. Run failing tests locally with targeted pytest, plus any related tests touched by the PR.',
+ '4. Push to this branch and comment: files changed, review verdict, and why tests still protect product behaviour.',
+ );
+
+ return parts.join('\n');
+}
+
+async function maybeClearCiFixLabel(github, owner, repo, prNumber, labels, headSha, core) {
+ if (!labels.includes(CI_FIX_LABEL)) return false;
+ const ciGreen = await mergeGate.allChecksGreenOnSha(github, owner, repo, headSha, core);
+ if (!ciGreen) return false;
+ try {
+ await github.rest.issues.removeLabel({
+ owner,
+ repo,
+ issue_number: prNumber,
+ name: CI_FIX_LABEL,
+ });
+ core?.info?.(`Removed ${CI_FIX_LABEL} from PR #${prNumber}`);
+ } catch (err) {
+ if (err.status !== 404) throw err;
+ }
+ return true;
+}
+
+async function maybeTriggerCiFixClaude(github, owner, repo, prNumber, core, opts = {}) {
+ const baseRepo = `${owner}/${repo}`;
+ const ctx = await mergeGate.loadPrContext(github, owner, repo, prNumber);
+ const pr = ctx.pr;
+
+ if (pr.state !== 'open') return { skipped: true, reason: 'not open' };
+ if (pr.draft) return { skipped: true, reason: 'draft' };
+
+ const headRepo = pr.head.repo?.full_name;
+ if (headRepo && headRepo !== baseRepo && !pr.maintainer_can_modify) {
+ return { skipped: true, reason: 'fork without maintainer edits' };
+ }
+
+ const headSha = pr.head.sha;
+ const labels = ctx.labels;
+
+ if (await maybeClearCiFixLabel(github, owner, repo, prNumber, labels, headSha, core)) {
+ return { skipped: true, reason: 'ci green, label cleared' };
+ }
+
+ const ciGreen = await mergeGate.allChecksGreenOnSha(github, owner, repo, headSha, core);
+ if (ciGreen) return { skipped: true, reason: 'ci green' };
+
+ const runs = await mergeGate.listChecksOnSha(github, owner, repo, headSha);
+ const failedRuns = mergeGate.listFailedChecksOnSha(runs);
+ if (failedRuns.length === 0) {
+ return { skipped: true, reason: 'no failed checks yet (may be pending)' };
+ }
+
+ const hasFinal = mergeGate.hasFinalClaudeReviewTrigger(ctx.comments);
+ const claudeInProgress = await mergeGate.hasInProgressClaudeAssistant(
+ github, owner, repo, prNumber
+ );
+ const skip = shouldSkipCiFix({
+ comments: ctx.comments,
+ headSha,
+ labels,
+ hasFinal,
+ claudeInProgress,
+ failedChecks: failedRuns,
+ });
+ if (skip.skip) return { skipped: true, reason: skip.reason };
+
+ const failedChecks = mapFailedChecks(failedRuns);
+ const failureSummaries = [];
+ for (const check of failedChecks.slice(0, MAX_JOBS_TO_FETCH)) {
+ failureSummaries.push(
+ await fetchJobFailureSummary(github, owner, repo, check.id, check.name)
+ );
+ }
+
+ const body = buildCiFixComment({ headSha, failedChecks, failureSummaries });
+
+ await github.rest.issues.addLabels({
+ owner,
+ repo,
+ issue_number: prNumber,
+ labels: [CI_FIX_LABEL],
+ });
+ await github.rest.issues.createComment({
+ owner,
+ repo,
+ issue_number: prNumber,
+ body,
+ });
+
+ core?.info?.(`Posted CI fix @claude on PR #${prNumber} (${shortSha(headSha)})`);
+ return { triggered: true, headSha: shortSha(headSha) };
+}
+
+async function processWorkflowRunFailure(github, owner, repo, workflowRun, core) {
+ if (workflowRun.conclusion !== 'failure') {
+ return { skipped: true, reason: `conclusion=${workflowRun.conclusion}` };
+ }
+ const allowed = config.ciFailureWorkflowRuns;
+ if (!allowed.includes(workflowRun.name)) {
+ return { skipped: true, reason: `workflow ${workflowRun.name}` };
+ }
+ const prNumber = await mergeGate.resolvePrNumberFromWorkflowRun(
+ github, owner, repo, workflowRun
+ );
+ if (!prNumber) return { skipped: true, reason: 'no linked PR' };
+ return maybeTriggerCiFixClaude(github, owner, repo, prNumber, core);
+}
+
+module.exports = {
+ CI_FIX_LABEL,
+ COOLDOWN_MS,
+ ciFixShaMarker,
+ isCiFixComment,
+ hasCiFixCommentForSha,
+ hasRecentCiFixComment,
+ shouldSkipCiFix,
+ parsePytestFailures,
+ fetchJobFailureSummary,
+ buildCiFixComment,
+ maybeClearCiFixLabel,
+ maybeTriggerCiFixClaude,
+ processWorkflowRunFailure,
+};
diff --git a/.github/scripts/gate-config.js b/.github/scripts/gate-config.js
new file mode 100644
index 0000000..18af81b
--- /dev/null
+++ b/.github/scripts/gate-config.js
@@ -0,0 +1,32 @@
+/**
+ * PraisonAI-Plugins gate configuration.
+ */
+
+module.exports = {
+ repoFullName: 'MervinPraison/PraisonAI-Plugins',
+ productPathPrefixes: ['src/praisonai_plugins/', 'tests/'],
+ sensitivePathPatterns: [
+ /^\.github\/workflows\//,
+ /^pyproject\.toml$/,
+ ],
+ requiredCheckPatterns: [/^ci$/i, /python/i, /test/i, /lint/i, /ruff/i],
+ ciWorkflowFile: 'ci.yml',
+ ciWorkflowName: 'CI',
+ mergeGateWorkflowRuns: ['CI', 'Claude Assistant'],
+ ciFailureWorkflowRuns: ['CI'],
+ pypiPackageName: 'praisonai-plugins',
+ packagePaths: ['src/praisonai_plugins', 'pyproject.toml'],
+ finalClaudeScope:
+ 'SCOPE: Focus ONLY on PraisonAI-Plugins (src/praisonai_plugins, tests). '
+ + 'Lifecycle plugins use praisonai.plugins; sandbox backends use praisonai.sandbox. '
+ + 'Do NOT add agent-callable tools here — those belong in PraisonAI-Tools.',
+ finalClaudeProductValue:
+ '4. Product value: plugins wrap execution lifecycle (hooks, guardrails, policies); '
+ + 'reject scope creep into praisonaiagents core or agent tools.',
+ agentPyChecks: false,
+ reviewBotLogins: [
+ 'coderabbitai[bot]',
+ 'qodo-code-review[bot]',
+ 'greptile-apps[bot]',
+ ],
+};
diff --git a/.github/scripts/merge-gate-selftest.js b/.github/scripts/merge-gate-selftest.js
new file mode 100644
index 0000000..14ef768
--- /dev/null
+++ b/.github/scripts/merge-gate-selftest.js
@@ -0,0 +1,237 @@
+#!/usr/bin/env node
+/**
+ * Local self-test for merge-gate.js heuristics (no GitHub API).
+ * Run: node .github/scripts/merge-gate-selftest.js
+ */
+const mg = require('./merge-gate.js');
+const config = require('./gate-config.js');
+const productSample = `${config.productPathPrefixes[0]}a/b.py`;
+const repoShort = config.repoFullName.split('/')[1];
+
+let failed = 0;
+function assert(name, cond) {
+ if (!cond) {
+ console.error('FAIL:', name);
+ failed++;
+ } else {
+ console.log('ok:', name);
+ }
+}
+
+// Stale FINAL: push after FINAL, no @claude since head
+const finals = [
+ { user: { login: 'github-actions[bot]' }, body: '@claude FINAL architecture reviewer', created_at: '2026-06-12T08:00:00Z' },
+];
+assert('stale when head after final', mg.isStaleFinalAfterPush(finals, '2026-06-12T09:00:00Z'));
+
+const withRecovery = [
+ ...finals,
+ { user: { login: 'github-actions[bot]' }, body: '@claude FINAL architecture reviewer', created_at: '2026-06-12T09:30:00Z' },
+];
+assert('not stale when @claude after head', !mg.isStaleFinalAfterPush(withRecovery, '2026-06-12T09:00:00Z'));
+
+const withClaudeReply = [
+ ...finals,
+ {
+ user: { login: 'praisonai-triage-agent[bot]' },
+ body: "**Claude finished @MervinPraison's task** —— [View job](https://github.com/)",
+ created_at: '2026-06-12T08:30:00Z',
+ },
+];
+assert('not stale when Claude replied after FINAL', !mg.isStaleFinalAfterPush(withClaudeReply, '2026-06-12T09:00:00Z'));
+assert('claude final reply detected', mg.isClaudeFinalReplyComment(withClaudeReply[1]));
+
+assert('cancelled detect-and-trigger does not block', mg.OPTIONAL_CANCELLED_CHECKS.has('detect-and-trigger'));
+
+// Stale-FINAL recovery guards (PR #2560 push loop)
+const nowMs = Date.now();
+const iso = (ms) => new Date(ms).toISOString();
+const firstFinal = {
+ user: { login: 'MervinPraison' },
+ body: '@claude You are the FINAL architecture reviewer.',
+ created_at: iso(nowMs - 20 * 60 * 1000),
+};
+const pushSoonAfter = mg.shouldSkipStaleFinalRecovery(
+ [firstFinal],
+ iso(nowMs - 16 * 60 * 1000)
+);
+assert('debounce push soon after FINAL', pushSoonAfter.skip && pushSoonAfter.reason.includes('soon after'));
+
+const secondFinal = {
+ user: { login: 'MervinPraison' },
+ body: '@claude You are the FINAL architecture reviewer.',
+ created_at: iso(nowMs - 20 * 60 * 1000),
+};
+const capped = mg.shouldSkipStaleFinalRecovery(
+ [firstFinal, secondFinal],
+ iso(nowMs - 2 * 60 * 1000)
+);
+assert('hourly cap blocks third FINAL in window', capped.skip && capped.reason.includes('capped'));
+
+const botPush = mg.shouldSkipStaleFinalRecovery(
+ [firstFinal],
+ iso(nowMs - 16 * 60 * 1000),
+ 'praisonai-triage-agent[bot]'
+);
+assert('skip when automation pushed head', botPush.skip && botPush.reason.includes('automation'));
+
+assert('claude automation login', mg.isClaudeAutomationLogin('praisonai-triage-agent[bot]'));
+
+// Bot CHANGES_REQUESTED then APPROVE
+const reviews = [
+ { user: { login: 'coderabbit[bot]', type: 'Bot' }, state: 'CHANGES_REQUESTED', submitted_at: '2026-06-12T08:00:00Z' },
+ { user: { login: 'coderabbit[bot]', type: 'Bot' }, state: 'APPROVED', submitted_at: '2026-06-12T09:00:00Z' },
+];
+assert('bot approve clears CR', !mg.hasAnyChangesRequested(reviews));
+assert('human CR blocks', mg.hasAnyChangesRequested([
+ { user: { login: 'MervinPraison', type: 'User' }, state: 'CHANGES_REQUESTED', submitted_at: '2026-06-12T09:00:00Z' },
+]));
+
+// Verdict after HEAD
+const verdictComments = [
+ { body: 'MERGE_GATE_VERDICT: APPROVE', created_at: '2026-06-12T08:00:00Z' },
+];
+assert('verdict before head rejected', mg.findMergeGateVerdict(verdictComments, null, '2026-06-12T09:00:00Z') === null);
+assert('verdict after head accepted', mg.findMergeGateVerdict(
+ [{ body: 'MERGE_GATE_VERDICT: APPROVE', created_at: '2026-06-12T10:00:00Z' }],
+ null,
+ '2026-06-12T09:00:00Z'
+) === 'APPROVE');
+
+const fallbackApprove = [
+ {
+ body: 'MERGE_GATE_VERDICT: APPROVE\n\nAutomated fallback — Claude assess did not post a verdict comment.',
+ created_at: '2026-06-12T10:00:00Z',
+ },
+];
+assert(
+ 'automated fallback APPROVE ignored when Opus required',
+ mg.findMergeGateVerdict(fallbackApprove, null, '2026-06-12T09:00:00Z', { excludeAutomatedFallback: true }) === null
+);
+assert(
+ 'automated fallback APPROVE still visible without Opus-only filter',
+ mg.findMergeGateVerdict(fallbackApprove, null, '2026-06-12T09:00:00Z') === 'APPROVE'
+);
+
+const fallbackBlockNewFormat = [
+ {
+ body: 'MERGE_GATE_VERDICT: BLOCK\n\nAutomated fallback — Opus merge gate assessment did not complete.',
+ created_at: '2026-06-12T10:00:00Z',
+ },
+];
+assert(
+ 'new-format fallback BLOCK ignored when Opus required',
+ mg.findMergeGateVerdict(fallbackBlockNewFormat, null, '2026-06-12T09:00:00Z', { excludeAutomatedFallback: true }) === null
+);
+assert(
+ 'new-format fallback BLOCK detected by marker helper',
+ mg.isAutomatedFallbackVerdict(fallbackBlockNewFormat[0].body)
+);
+assert(
+ 'findMergeGateVerdict null-safe with explicit null options',
+ mg.findMergeGateVerdict(fallbackApprove, null, '2026-06-12T09:00:00Z', null) === 'APPROVE'
+);
+
+const noise = [{ user: { login: 'MervinPraison' }, body: '**Merge gate scan** — wait for `@claude`', created_at: new Date().toISOString() }];
+assert('diagnostic comment not a trigger', !mg.hasRecentClaudeTrigger(noise, 35));
+
+// Cooldown skip requires an actual verdict on HEAD, not just a FINAL trigger comment
+const recentFinalOnHead = [
+ {
+ user: { login: 'MervinPraison' },
+ body: '@claude You are the FINAL architecture reviewer.',
+ created_at: '2026-06-27T10:00:00Z',
+ },
+];
+assert(
+ 'final trigger alone does not count as verdict on head',
+ mg.findMergeGateVerdict(recentFinalOnHead, null, '2026-06-27T09:55:00Z') === null
+);
+const recentVerdictOnHead = [
+ ...recentFinalOnHead,
+ {
+ user: { login: 'github-actions[bot]' },
+ body: 'MERGE_GATE_VERDICT: APPROVE',
+ created_at: '2026-06-27T10:05:00Z',
+ },
+];
+assert(
+ 'verdict on head skips cooldown gate',
+ mg.findMergeGateVerdict(recentVerdictOnHead, null, '2026-06-27T09:55:00Z') === 'APPROVE'
+);
+
+// Sensitive + secrets
+assert('workflow path sensitive', mg.sensitivePathReasons([{ filename: '.github/workflows/foo.yml' }]).length === 1);
+assert('ci-only label exempts workflows', mg.sensitivePathReasons(
+ [{ filename: '.github/workflows/foo.yml' }],
+ [mg.WORKFLOW_ONLY_LABEL]
+).length === 0);
+assert('ci-only label not exempt mixed changes', mg.sensitivePathReasons(
+ [{ filename: '.github/workflows/foo.yml' }, { filename: productSample }],
+ [mg.WORKFLOW_ONLY_LABEL]
+).length === 1);
+assert('secret in patch', mg.secretScanReasons([{ filename: 'x.py', patch: '+key = "ghp_abcdefghijklmnopqrstuvwxyz1234567890"' }]).length === 1);
+
+// Claude run scoping — other PR branches must not block
+assert('other branch claude does not block', !mg.hasBlockingClaudeRunForPr(
+ [{ event: 'issue_comment', head_branch: 'other-branch' }],
+ 'my-branch'
+));
+assert('same branch claude blocks', mg.hasBlockingClaudeRunForPr(
+ [{ event: 'issue_comment', head_branch: 'my-branch' }],
+ 'my-branch'
+));
+assert('issues event never blocks', !mg.hasBlockingClaudeRunForPr(
+ [{ event: 'issues', head_branch: 'my-branch' }],
+ 'my-branch'
+));
+
+// Conflict rebase clears after bot completion + FINAL on HEAD (within 12h cooldown)
+const conflictNowMs = Date.now();
+const conflictIso = (offsetMs) => new Date(conflictNowMs + offsetMs).toISOString();
+const conflictTrigger = {
+ user: { login: 'MervinPraison' },
+ body: '@claude this PR has merge conflicts with main. Please rebase',
+ created_at: conflictIso(-30 * 60 * 1000),
+};
+const rebaseDone = {
+ user: { login: 'praisonai-triage-agent[bot]' },
+ body: 'Rebase complete — PR #2308 onto latest main',
+ created_at: conflictIso(-29 * 60 * 1000),
+};
+const finalAfterRebase = {
+ user: { login: 'MervinPraison' },
+ body: '@claude You are the FINAL architecture reviewer.',
+ created_at: conflictIso(-20 * 60 * 1000),
+};
+const headAfterRebase = conflictIso(-21 * 60 * 1000);
+const rebaseComments = [conflictTrigger, rebaseDone, finalAfterRebase];
+assert('conflict blocks before rebase done', mg.hasRecentConflictComment([conflictTrigger], headAfterRebase));
+assert('conflict clears after rebase + FINAL on HEAD', !mg.hasRecentConflictComment(rebaseComments, headAfterRebase));
+assert('conflict still blocks without FINAL on HEAD', mg.hasRecentConflictComment(
+ [conflictTrigger, rebaseDone],
+ headAfterRebase
+));
+
+// Tests heuristic
+assert('product code without tests', mg.missingTestsReason([{ filename: productSample, additions: 3 }]) !== null);
+assert('product code with tests ok', mg.missingTestsReason([
+ { filename: productSample, additions: 3 },
+ { filename: 'tests/test_x.py', additions: 10 },
+]) === null);
+
+// PR size
+assert('large PR blocked', mg.prSizeReasons([{ additions: 900 }]).length > 0);
+
+assert('internal PR link accepted', mg.isInternalPullRequestLink(
+ { base: { repo: { full_name: config.repoFullName } } },
+ 'MervinPraison',
+ repoShort
+));
+assert('fork sync PR link rejected', !mg.isInternalPullRequestLink(
+ { number: 21, base: { repo: { full_name: `Milkmange/${repoShort}` } } },
+ 'MervinPraison',
+ repoShort
+));
+
+process.exit(failed ? 1 : 0);
diff --git a/.github/scripts/merge-gate.js b/.github/scripts/merge-gate.js
new file mode 100644
index 0000000..9224327
--- /dev/null
+++ b/.github/scripts/merge-gate.js
@@ -0,0 +1,899 @@
+/**
+ * Shared merge-gate helpers for claude-merge-gate.yml
+ * @see .github/workflows/claude-merge-gate.yml
+ */
+
+const config = require('./gate-config.js');
+
+const CLAUDE_TRIGGER_LOGINS = ['MervinPraison', 'github-actions[bot]'];
+const AUTO_ACTORS = CLAUDE_TRIGGER_LOGINS;
+const CONFLICT_COOLDOWN_MS = 12 * 60 * 60 * 1000;
+const CLAUDE_ACTIVE_MS = 35 * 60 * 1000;
+const STALE_FINAL_RECOVERY_WINDOW_MS = 60 * 60 * 1000;
+const STALE_FINAL_MAX_PER_WINDOW = 2;
+const PUSH_AFTER_FINAL_DEBOUNCE_MS = 15 * 60 * 1000;
+const ALLOWED_MERGE_STATES = new Set(['CLEAN', 'UNSTABLE']);
+const AGENT_PY_MAX_AUTO_LINES = 100;
+const PR_MAX_AUTO_ADDITIONS = 800;
+const PR_MAX_AUTO_FILES = 30;
+const MANUAL_ONLY_LABELS = new Set(['security', 'breaking-change', 'needs-manual-review', 'release']);
+const WORKFLOW_ONLY_LABEL = 'merge-gate-ci-only';
+const CI_ONLY_PATH_PREFIXES = ['.github/workflows/', '.github/actions/', '.github/scripts/merge-gate'];
+const SDK_PATH_PREFIXES = config.productPathPrefixes;
+const SENSITIVE_PATH_PATTERNS = config.sensitivePathPatterns;
+const REQUIRED_SDK_CHECK_PATTERNS = config.requiredCheckPatterns;
+const SECRET_PATTERNS = [
+ /sk-[a-zA-Z0-9]{20,}/,
+ /AKIA[0-9A-Z]{16}/,
+ /ghp_[a-zA-Z0-9]{36,}/,
+ /github_pat_/,
+ /Bearer eyJ[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+/,
+ /-----BEGIN (RSA |EC |OPENSSH )?PRIVATE KEY-----/,
+];
+const BLOCK_LABELS = new Set([
+ 'claude-conflict-pending',
+ 'claude-merge-gate-active',
+ 'no-auto-merge',
+ 'auto-merged-by-gate',
+]);
+const MERGE_READY_LABEL = 'pipeline/merge-ready';
+/** Superseded concurrency runs; must not block merge when real tests passed. */
+const OPTIONAL_CANCELLED_CHECKS = new Set(['detect-and-trigger']);
+const BOT_REVIEWER_PATTERNS = [
+ 'coderabbit',
+ 'qodo',
+ 'gemini',
+ 'copilot',
+ 'greptile',
+];
+
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+
+function isFinalClaudeTriggerComment(c) {
+ const body = (c.body || '').toLowerCase();
+ if (!AUTO_ACTORS.includes(c.user?.login)) return false;
+ if (!body.includes('@claude')) return false;
+ if (body.includes('merge conflict')) return false;
+ return body.includes('final architecture reviewer') || body.includes('lead engineer');
+}
+
+function isClaudeTriggerNoise(c) {
+ const body = c.body || '';
+ if (body.includes('Merge gate scan')) return true;
+ if (body.includes('MERGE_GATE_VERDICT')) return true;
+ if (body.includes('Merged by **Claude PR merge gate**')) return true;
+ return false;
+}
+
+function isClaudeFinalReplyComment(c) {
+ const login = (c.user?.login || '').toLowerCase();
+ if (!login.includes('praisonai-triage')) return false;
+ return (c.body || '').includes('Claude finished');
+}
+
+function hasRecentClaudeTrigger(comments, minutes = 35) {
+ const cutoff = Date.now() - minutes * 60 * 1000;
+ return comments.some((c) => {
+ if (!CLAUDE_TRIGGER_LOGINS.includes(c.user?.login)) return false;
+ if (isClaudeTriggerNoise(c)) return false;
+ if (!(c.body || '').includes('@claude')) return false;
+ return new Date(c.created_at).getTime() > cutoff;
+ });
+}
+
+function isConflictRebaseTriggerComment(c) {
+ if (!AUTO_ACTORS.includes(c.user?.login)) return false;
+ const body = (c.body || '').toLowerCase();
+ return body.includes('@claude') && body.includes('merge conflict');
+}
+
+function isConflictRebaseCompletionComment(c) {
+ const login = (c.user?.login || '').toLowerCase();
+ if (!login.includes('praisonai-triage') && !login.includes('github-actions')) return false;
+ const body = (c.body || '').toLowerCase();
+ return (
+ body.includes('rebase complete') ||
+ body.includes('rebase onto') ||
+ (body.includes('conflict') && body.includes('resolved'))
+ );
+}
+
+function conflictRebaseQuiescent(comments, headPushedAt) {
+ const conflictTriggers = comments.filter(isConflictRebaseTriggerComment);
+ if (conflictTriggers.length === 0) return true;
+
+ const latestConflict = conflictTriggers.reduce((a, b) =>
+ new Date(a.created_at) > new Date(b.created_at) ? a : b
+ );
+ const conflictTime = new Date(latestConflict.created_at).getTime();
+
+ const rebaseDone = comments.some(
+ (c) =>
+ new Date(c.created_at).getTime() > conflictTime && isConflictRebaseCompletionComment(c)
+ );
+ if (!rebaseDone) return false;
+
+ return finalClaudeCompletedOnSha(comments, headPushedAt);
+}
+
+function hasRecentConflictComment(comments, headPushedAt = null) {
+ const cutoff = Date.now() - CONFLICT_COOLDOWN_MS;
+ const hasRecentTrigger = comments.some((c) => {
+ if (!isConflictRebaseTriggerComment(c)) return false;
+ return new Date(c.created_at).getTime() > cutoff;
+ });
+ if (!hasRecentTrigger) return false;
+
+ if (headPushedAt && conflictRebaseQuiescent(comments, headPushedAt)) {
+ return false;
+ }
+ return true;
+}
+
+function isBotReviewer(login, userType) {
+ const lower = (login || '').toLowerCase();
+ if (userType === 'Bot') return true;
+ if (lower.endsWith('[bot]')) return true;
+ return ['coderabbit', 'qodo', 'gemini', 'copilot', 'greptile'].some((p) => lower.includes(p));
+}
+
+function latestReviewsByUser(reviews) {
+ const latestByUser = new Map();
+ for (const r of reviews) {
+ const login = r.user?.login;
+ if (!login) continue;
+ const prev = latestByUser.get(login);
+ if (!prev || new Date(r.submitted_at) > new Date(prev.submitted_at)) {
+ latestByUser.set(login, r);
+ }
+ }
+ return latestByUser;
+}
+
+function hasHumanChangesRequested(reviews) {
+ for (const [login, review] of latestReviewsByUser(reviews)) {
+ if (review.state !== 'CHANGES_REQUESTED') continue;
+ if (!isBotReviewer(login, review.user?.type)) return true;
+ }
+ return false;
+}
+
+function hasAnyChangesRequested(reviews) {
+ for (const [, review] of latestReviewsByUser(reviews)) {
+ if (review.state === 'CHANGES_REQUESTED') return true;
+ }
+ return false;
+}
+
+function hasFinalClaudeReviewTrigger(comments) {
+ return comments.some(isFinalClaudeTriggerComment);
+}
+
+function isStaleFinalAfterPush(comments, headPushedAt) {
+ if (!headPushedAt) return false;
+ const headTime = new Date(headPushedAt).getTime();
+ const finals = comments.filter(isFinalClaudeTriggerComment);
+ if (finals.length === 0) return true;
+ const latestFinal = finals.reduce((a, b) =>
+ new Date(a.created_at) > new Date(b.created_at) ? a : b
+ );
+ const finalTime = new Date(latestFinal.created_at).getTime();
+ if (headTime <= finalTime + 60000) return false;
+ const claudeRepliedAfterFinal = comments.some((c) => {
+ if (!isClaudeFinalReplyComment(c)) return false;
+ return new Date(c.created_at).getTime() >= finalTime - 60000;
+ });
+ if (claudeRepliedAfterFinal) return false;
+ const claudeSinceHead = comments.some((c) => {
+ if (!CLAUDE_TRIGGER_LOGINS.includes(c.user?.login)) return false;
+ if (isClaudeTriggerNoise(c)) return false;
+ if (!(c.body || '').includes('@claude')) return false;
+ return new Date(c.created_at).getTime() >= headTime - 60000;
+ });
+ return !claudeSinceHead;
+}
+
+function needsStaleFinalRecovery(comments, headPushedAt) {
+ return (
+ hasFinalClaudeReviewTrigger(comments) &&
+ isStaleFinalAfterPush(comments, headPushedAt)
+ );
+}
+
+function shouldSkipFinalRecovery(comments, headPushedAt) {
+ const isStale = isStaleFinalAfterPush(comments, headPushedAt);
+ if (isStale) return false;
+ return hasRecentClaudeTrigger(comments, 35);
+}
+
+function countFinalTriggersSince(comments, sinceMs) {
+ return comments.filter(
+ (c) => isFinalClaudeTriggerComment(c) && new Date(c.created_at).getTime() > sinceMs
+ ).length;
+}
+
+function isClaudeAutomationLogin(login) {
+ const lower = (login || '').toLowerCase();
+ return lower.includes('praisonai-triage') || lower === 'github-actions[bot]';
+}
+
+function isPushSoonAfterLatestFinal(comments, headPushedAt) {
+ if (!headPushedAt) return false;
+ const finals = comments.filter(isFinalClaudeTriggerComment);
+ if (finals.length === 0) return false;
+ const latestFinal = finals.reduce((a, b) =>
+ new Date(a.created_at) > new Date(b.created_at) ? a : b
+ );
+ const finalTime = new Date(latestFinal.created_at).getTime();
+ const headTime = new Date(headPushedAt).getTime();
+ if (headTime <= finalTime) return false;
+ return headTime - finalTime < PUSH_AFTER_FINAL_DEBOUNCE_MS;
+}
+
+/** Returns { skip: true, reason } when stale-FINAL recovery should not post. */
+function shouldSkipStaleFinalRecovery(comments, headPushedAt, headPusherLogin = null) {
+ if (!isStaleFinalAfterPush(comments, headPushedAt)) {
+ return { skip: true, reason: 'not stale' };
+ }
+ if (headPusherLogin && isClaudeAutomationLogin(headPusherLogin)) {
+ return { skip: true, reason: 'head pushed by Claude automation' };
+ }
+ if (isPushSoonAfterLatestFinal(comments, headPushedAt)) {
+ return {
+ skip: true,
+ reason: 'head pushed soon after FINAL (wait for CI / batched fixes)',
+ };
+ }
+ const windowStart = Date.now() - STALE_FINAL_RECOVERY_WINDOW_MS;
+ const finalsInWindow = countFinalTriggersSince(comments, windowStart);
+ if (finalsInWindow >= STALE_FINAL_MAX_PER_WINDOW) {
+ return {
+ skip: true,
+ reason: `stale-FINAL capped (${finalsInWindow} FINAL triggers in last hour)`,
+ };
+ }
+ return { skip: false, reason: '' };
+}
+
+function finalClaudeCompletedOnSha(comments, headPushedAt) {
+ if (!hasFinalClaudeReviewTrigger(comments)) return false;
+ if (isStaleFinalAfterPush(comments, headPushedAt)) return false;
+ return true;
+}
+
+const FINAL_CLAUDE_REVIEW_BODY =
+ `@claude You are the FINAL architecture reviewer. If the branch is under ${config.repoFullName} (not a fork), you are able to make modifications to this branch and push directly. ${config.finalClaudeScope} Read ALL comments above from Gemini, Qodo, CodeRabbit, and Copilot carefully before responding.\n\n**Phase 1: Review per AGENTS.md**\n1. Protocol-driven: check heavy implementations vs core SDK\n2. Backward compatible: ensure zero feature regressions\n3. Performance: no hot-path regressions (lazy imports, import-time <200ms)\n${config.finalClaudeProductValue}\n\n**Phase 2: FIX Valid Issues**\n5. For any VALID bugs or architectural flaws found by Gemini, CodeRabbit, Qodo, Copilot, or any other reviewer: implement the fix\n6. Push all code fixes directly to THIS branch (do NOT create a new PR)\n7. Comment a summary of exact files modified and what you skipped\n\n**Phase 3: Final Verdict**\n8. If all issues are resolved, approve the PR / close the Issue\n9. If blocking issues remain, request changes / leave clear action items`;
+
+async function getMergeState(github, owner, repo, prNumber) {
+ const query = `
+ query($owner: String!, $repo: String!, $number: Int!) {
+ repository(owner: $owner, name: $repo) {
+ pullRequest(number: $number) {
+ mergeStateStatus
+ maintainerCanModify
+ isDraft
+ headRefOid
+ headRef { repository { nameWithOwner } }
+ }
+ }
+ }
+ `;
+ for (let attempt = 0; attempt < 3; attempt++) {
+ const result = await github.graphql(query, { owner, repo, number: prNumber });
+ const prGql = result?.repository?.pullRequest;
+ const status = (prGql?.mergeStateStatus || '').toUpperCase();
+ if (status && status !== 'UNKNOWN') {
+ return {
+ status,
+ isDraft: prGql?.isDraft,
+ headRepo: prGql?.headRef?.repository?.nameWithOwner,
+ headSha: prGql?.headRefOid,
+ maintainerCanModify: prGql?.maintainerCanModify === true,
+ };
+ }
+ if (attempt < 2) await sleep(10000);
+ }
+ const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: prNumber });
+ return {
+ status: (pr.mergeable_state || '').toUpperCase(),
+ isDraft: pr.draft,
+ headRepo: pr.head?.repo?.full_name,
+ headSha: pr.head?.sha,
+ maintainerCanModify: pr.maintainer_can_modify === true,
+ };
+}
+
+async function listChecksOnSha(github, owner, repo, sha) {
+ const { data } = await github.rest.checks.listForRef({
+ owner,
+ repo,
+ ref: sha,
+ per_page: 100,
+ });
+ return (data.check_runs || []).filter((r) => r.head_sha === sha);
+}
+
+function listFailedChecksOnSha(runs) {
+ return (runs || []).filter((run) => {
+ if (run.status !== 'completed') return false;
+ return run.conclusion === 'failure';
+ });
+}
+
+async function allChecksGreenOnSha(github, owner, repo, sha, core) {
+ const runs = await listChecksOnSha(github, owner, repo, sha);
+ if (runs.length === 0) {
+ core?.info?.(`No check runs on ${sha.slice(0, 7)} — allowing (e.g. docs-only PR)`);
+ return true;
+ }
+ for (const run of runs) {
+ if (run.status !== 'completed') {
+ core?.info?.(`Check pending: ${run.name} (${run.status})`);
+ return false;
+ }
+ const ok =
+ ['success', 'neutral', 'skipped'].includes(run.conclusion) ||
+ (run.conclusion === 'cancelled' && OPTIONAL_CANCELLED_CHECKS.has(run.name));
+ if (!ok) {
+ core?.info?.(`Check failed: ${run.name} (${run.conclusion})`);
+ return false;
+ }
+ }
+ return true;
+}
+
+function claudeRunBlocksPr(run, headRef) {
+ if (!run || run.event === 'issues') return false;
+ if (!headRef) return true;
+ return run.head_branch === headRef;
+}
+
+function hasBlockingClaudeRunForPr(runs, headRef) {
+ return (runs || []).some((r) => claudeRunBlocksPr(r, headRef));
+}
+
+async function hasInProgressClaudeAssistant(github, owner, repo, prNumber = null) {
+ try {
+ const { data } = await github.rest.actions.listWorkflowRuns({
+ owner,
+ repo,
+ workflow_id: 'claude.yml',
+ status: 'in_progress',
+ per_page: 20,
+ });
+ const runs = (data.workflow_runs || []).filter((r) => r.event !== 'issues');
+ if (runs.length === 0) return false;
+ if (prNumber == null) return runs.length > 0;
+
+ const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: prNumber });
+ const headRef = pr.head.ref;
+ return hasBlockingClaudeRunForPr(runs, headRef);
+ } catch {
+ return false;
+ }
+}
+
+function isCiOnlyChange(files) {
+ if (!files.length) return false;
+ return files.every((f) => CI_ONLY_PATH_PREFIXES.some((p) => f.filename.startsWith(p)));
+}
+
+function isInternalPullRequestLink(link, owner, repo) {
+ if (link?.base?.repo?.full_name === `${owner}/${repo}`) return true;
+ const baseUrl = link?.base?.repo?.url || '';
+ return baseUrl.endsWith(`/repos/${owner}/${repo}`);
+}
+
+async function resolvePrNumberFromLinkedPullRequests(github, owner, repo, linked) {
+ const repoFull = `${owner}/${repo}`;
+ const internal = (linked || []).filter((l) => isInternalPullRequestLink(l, owner, repo));
+ for (const link of internal) {
+ if (!link.number) continue;
+ try {
+ const { data } = await github.rest.pulls.get({
+ owner,
+ repo,
+ pull_number: link.number,
+ });
+ if (data.state === 'open' && data.base?.repo?.full_name === repoFull) {
+ return data.number;
+ }
+ } catch (err) {
+ if (err.status !== 404) throw err;
+ }
+ }
+ return null;
+}
+
+async function resolvePrNumberFromHeadBranch(github, owner, repo, branch) {
+ if (!branch || branch === 'main' || branch === 'master') return null;
+ const { data } = await github.rest.pulls.list({
+ owner,
+ repo,
+ state: 'open',
+ head: `${owner}:${branch}`,
+ per_page: 1,
+ });
+ return data[0]?.number || null;
+}
+
+async function resolvePrNumberFromWorkflowRun(github, owner, repo, workflowRun) {
+ const fromLinked = await resolvePrNumberFromLinkedPullRequests(
+ github, owner, repo, workflowRun.pull_requests
+ );
+ if (fromLinked) return fromLinked;
+
+ const fromBranch = await resolvePrNumberFromHeadBranch(
+ github, owner, repo, workflowRun.head_branch
+ );
+ if (fromBranch) return fromBranch;
+
+ return null;
+}
+
+async function listPullFiles(github, owner, repo, prNumber) {
+ if (typeof github.paginate === 'function') {
+ return github.paginate(github.rest.pulls.listFiles, {
+ owner,
+ repo,
+ pull_number: prNumber,
+ per_page: 100,
+ });
+ }
+ const { data } = await github.rest.pulls.listFiles({
+ owner,
+ repo,
+ pull_number: prNumber,
+ per_page: 100,
+ });
+ return data;
+}
+
+function countNewAgentParams(patch) {
+ if (!patch) return 0;
+ return patch
+ .split('\n')
+ .filter((l) => l.startsWith('+') && !l.startsWith('+++'))
+ .filter((l) => {
+ const t = l.slice(1).trim();
+ if (!t || t.startsWith('#') || t.startsWith('"""') || t.startsWith("'''")) return false;
+ if (/^(def|class|@|return\b|if\b|elif\b|else\b|for\b|while\b)/.test(t)) return false;
+ if (/^\w+\s*[:=]/.test(t) && !t.startsWith('self.')) return true;
+ return false;
+ }).length;
+}
+
+function getAgentPyChangeFromFiles(files) {
+ const agentFile = files.find((f) => f.filename.endsWith('praisonaiagents/agent/agent.py'));
+ if (!agentFile) {
+ return { touched: false, additions: 0, newParams: 0 };
+ }
+ return {
+ touched: true,
+ additions: agentFile.additions || 0,
+ newParams: countNewAgentParams(agentFile.patch || ''),
+ };
+}
+
+async function getAgentPyChange(github, owner, repo, prNumber) {
+ const files = await listPullFiles(github, owner, repo, prNumber);
+ return getAgentPyChangeFromFiles(files);
+}
+
+function touchesSdk(files) {
+ return files.some((f) => SDK_PATH_PREFIXES.some((p) => f.filename.startsWith(p)));
+}
+
+function hasManualOnlyLabel(labels) {
+ return labels.some((l) => MANUAL_ONLY_LABELS.has(l));
+}
+
+function sensitivePathReasons(files, labels = []) {
+ if (labels.includes(WORKFLOW_ONLY_LABEL) && isCiOnlyChange(files)) {
+ return [];
+ }
+ const reasons = [];
+ for (const f of files) {
+ if (SENSITIVE_PATH_PATTERNS.some((p) => p.test(f.filename))) {
+ reasons.push(`sensitive path: ${f.filename}`);
+ break;
+ }
+ }
+ return reasons;
+}
+
+function prSizeReasons(files) {
+ const reasons = [];
+ const totalAdditions = files.reduce((sum, f) => sum + (f.additions || 0), 0);
+ if (totalAdditions > PR_MAX_AUTO_ADDITIONS) {
+ reasons.push(`PR +${totalAdditions} lines (>${PR_MAX_AUTO_ADDITIONS}) requires manual review`);
+ }
+ if (files.length > PR_MAX_AUTO_FILES) {
+ reasons.push(`${files.length} files changed (>${PR_MAX_AUTO_FILES}) requires manual review`);
+ }
+ return reasons;
+}
+
+function missingTestsReason(files) {
+ const productAdds = files.filter(
+ (f) =>
+ SDK_PATH_PREFIXES.some((p) => f.filename.startsWith(p)) &&
+ f.filename.endsWith('.py') &&
+ !f.filename.endsWith('__init__.py') &&
+ (f.additions || 0) > 0
+ );
+ if (productAdds.length === 0) return null;
+ const hasTestChange = files.some(
+ (f) => /\/tests?\//.test(f.filename) || /test_.*\.py$/.test(f.filename) || /_test\.py$/.test(f.filename)
+ );
+ if (!hasTestChange) return 'Product code added without test file changes — requires manual review';
+ return null;
+}
+
+function secretScanReasons(files) {
+ for (const f of files) {
+ if (/\/tests?\//.test(f.filename) || /test_.*\.py$/.test(f.filename)) continue;
+ const patch = f.patch || '';
+ if (!patch) continue;
+ for (const pattern of SECRET_PATTERNS) {
+ if (pattern.test(patch)) {
+ return [`possible secret in diff (${f.filename}) — requires manual review`];
+ }
+ }
+ }
+ return [];
+}
+
+async function sdkTestChecksReason(github, owner, repo, sha, files, core) {
+ if (!touchesSdk(files)) return null;
+ const { data } = await github.rest.checks.listForRef({
+ owner,
+ repo,
+ ref: sha,
+ per_page: 100,
+ });
+ const runs = (data.check_runs || []).filter((r) => r.head_sha === sha);
+ if (runs.length === 0) {
+ return 'SDK code changed but no CI checks on HEAD';
+ }
+ const testRuns = runs.filter((r) => REQUIRED_SDK_CHECK_PATTERNS.some((p) => p.test(r.name || '')));
+ if (testRuns.length === 0) {
+ return 'SDK code changed but no test check runs on HEAD';
+ }
+ core?.info?.(`SDK test checks on HEAD: ${testRuns.map((r) => r.name).join(', ')}`);
+ return null;
+}
+
+function manualReviewReasonForAgentPy(agentChange) {
+ if (!agentChange.touched) return null;
+ if (agentChange.additions > AGENT_PY_MAX_AUTO_LINES) {
+ return `agent.py +${agentChange.additions} lines (>${AGENT_PY_MAX_AUTO_LINES}) requires manual review`;
+ }
+ if (agentChange.newParams > 0) {
+ return `agent.py adds ${agentChange.newParams} new Agent param(s) — requires manual review`;
+ }
+ return null;
+}
+
+async function listAllComments(github, owner, repo, issueNumber) {
+ if (typeof github.paginate === 'function') {
+ return github.paginate(github.rest.issues.listComments, {
+ owner,
+ repo,
+ issue_number: issueNumber,
+ per_page: 100,
+ });
+ }
+ const { data } = await github.rest.issues.listComments({
+ owner,
+ repo,
+ issue_number: issueNumber,
+ per_page: 100,
+ });
+ return data;
+}
+
+async function getHeadCommitDate(github, owner, repo, prNumber) {
+ try {
+ let commits;
+ if (typeof github.paginate === 'function') {
+ commits = await github.paginate(github.rest.pulls.listCommits, {
+ owner,
+ repo,
+ pull_number: prNumber,
+ per_page: 100,
+ });
+ } else {
+ const { data } = await github.rest.pulls.listCommits({
+ owner,
+ repo,
+ pull_number: prNumber,
+ per_page: 100,
+ });
+ commits = data;
+ }
+ const last = commits[commits.length - 1];
+ return last?.commit?.committer?.date || last?.commit?.author?.date || null;
+ } catch {
+ return null;
+ }
+}
+
+async function loadPrContext(github, owner, repo, prNumber) {
+ const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: prNumber });
+ const { data: issue } = await github.rest.issues.get({ owner, repo, issue_number: prNumber });
+ const comments = await listAllComments(github, owner, repo, prNumber);
+ const { data: reviews } = await github.rest.pulls.listReviews({
+ owner,
+ repo,
+ pull_number: prNumber,
+ per_page: 100,
+ });
+ const mergeState = await getMergeState(github, owner, repo, prNumber);
+ const headSha = mergeState.headSha || pr.head.sha;
+ const headCommitDate = await getHeadCommitDate(github, owner, repo, prNumber);
+ const headPushedAt = headCommitDate || pr.updated_at;
+
+ return {
+ pr,
+ issue,
+ comments,
+ reviews,
+ mergeState,
+ headSha,
+ headPushedAt,
+ labels: issue.labels.map((l) => l.name),
+ baseRepo: `${owner}/${repo}`,
+ };
+}
+
+async function evaluatePipelineQuiescent(github, owner, repo, prNumber, core, options = {}) {
+ const {
+ forMergeStep = false,
+ skipGlobalClaudeRunCheck = false,
+ skipRecentClaudeCooldown = false,
+ } = options;
+ const ctx = await loadPrContext(github, owner, repo, prNumber);
+ const reasons = [];
+
+ if (ctx.pr.draft) reasons.push('draft');
+ if (ctx.pr.state !== 'open') reasons.push('not open');
+ if (ctx.labels.includes('auto-merged-by-gate')) reasons.push('already merged by gate');
+ if (ctx.labels.includes('no-auto-merge')) reasons.push('no-auto-merge label');
+ if (ctx.labels.includes('claude-conflict-pending')) reasons.push('claude-conflict-pending');
+ if (!forMergeStep && ctx.labels.includes('claude-merge-gate-active')) {
+ reasons.push('claude-merge-gate-active');
+ }
+
+ const { status, headRepo, maintainerCanModify } = ctx.mergeState;
+ if (!ALLOWED_MERGE_STATES.has(status)) reasons.push(`mergeState=${status}`);
+
+ if (headRepo && headRepo !== ctx.baseRepo) {
+ reasons.push('fork PR');
+ }
+
+ if (hasRecentConflictComment(ctx.comments, ctx.headPushedAt)) {
+ reasons.push('recent merge-conflict @claude');
+ }
+ if (!skipRecentClaudeCooldown && hasRecentClaudeTrigger(ctx.comments, 35)) {
+ const verdictOnHead = findMergeGateVerdict(ctx.comments, null, ctx.headPushedAt) !== null;
+ if (!verdictOnHead) reasons.push('recent @claude within 35min');
+ }
+
+ if (!skipGlobalClaudeRunCheck && (await hasInProgressClaudeAssistant(github, owner, repo, prNumber))) {
+ reasons.push('claude.yml in progress');
+ }
+
+ const checksOk = await allChecksGreenOnSha(github, owner, repo, ctx.headSha, core);
+ if (!checksOk) reasons.push('CI not green on HEAD');
+
+ if (!finalClaudeCompletedOnSha(ctx.comments, ctx.headPushedAt)) {
+ if (!hasFinalClaudeReviewTrigger(ctx.comments)) {
+ reasons.push('no FINAL Claude review trigger');
+ } else if (isStaleFinalAfterPush(ctx.comments, ctx.headPushedAt)) {
+ reasons.push('stale FINAL after new commits (needs @claude re-review)');
+ } else {
+ reasons.push('FINAL Claude not complete on HEAD');
+ }
+ }
+
+ if (hasAnyChangesRequested(ctx.reviews)) reasons.push('CHANGES_REQUESTED review');
+
+ if (hasManualOnlyLabel(ctx.labels)) {
+ const manualLabel = ctx.labels.find((l) => MANUAL_ONLY_LABELS.has(l));
+ reasons.push(`manual-only label: ${manualLabel}`);
+ }
+
+ const pullFiles = await listPullFiles(github, owner, repo, prNumber);
+
+ if (config.agentPyChecks) {
+ const agentChange = getAgentPyChangeFromFiles(pullFiles);
+ const agentManual = manualReviewReasonForAgentPy(agentChange);
+ if (agentManual) reasons.push(agentManual);
+ }
+
+ reasons.push(...sensitivePathReasons(pullFiles, ctx.labels));
+ reasons.push(...prSizeReasons(pullFiles));
+ reasons.push(...secretScanReasons(pullFiles));
+
+ const testsReason = missingTestsReason(pullFiles);
+ if (testsReason) reasons.push(testsReason);
+
+ const sdkCheckReason = await sdkTestChecksReason(github, owner, repo, ctx.headSha, pullFiles, core);
+ if (sdkCheckReason) reasons.push(sdkCheckReason);
+
+ if (ctx.pr.mergeable === false) reasons.push('not mergeable');
+
+ const ready = reasons.length === 0;
+ return {
+ ready,
+ reasons,
+ headSha: ctx.headSha,
+ prNumber,
+ };
+}
+
+async function listPrNumbersForMergeGateScan(github, owner, repo, core) {
+ let mergeReadyIssues;
+ if (typeof github.paginate === 'function') {
+ mergeReadyIssues = await github.paginate(github.rest.issues.listForRepo, {
+ owner,
+ repo,
+ state: 'open',
+ labels: MERGE_READY_LABEL,
+ per_page: 100,
+ });
+ } else {
+ ({ data: mergeReadyIssues } = await github.rest.issues.listForRepo({
+ owner,
+ repo,
+ state: 'open',
+ labels: MERGE_READY_LABEL,
+ per_page: 100,
+ }));
+ }
+ const mergeReady = mergeReadyIssues
+ .filter((issue) => issue.pull_request)
+ .map((issue) => issue.number)
+ .sort((a, b) => a - b);
+
+ if (mergeReady.length > 0) {
+ core?.info?.(
+ `Merge gate scan: ${mergeReady.length} ${MERGE_READY_LABEL} PR(s) (skipping unlabelled open PRs)`
+ );
+ return mergeReady;
+ }
+
+ core?.info?.(`No ${MERGE_READY_LABEL} PRs — scanning oldest open PRs`);
+ const prs =
+ typeof github.paginate === 'function'
+ ? await github.paginate(github.rest.pulls.list, {
+ owner,
+ repo,
+ state: 'open',
+ per_page: 100,
+ sort: 'created',
+ direction: 'asc',
+ })
+ : (await github.rest.pulls.list({
+ owner,
+ repo,
+ state: 'open',
+ per_page: 100,
+ sort: 'created',
+ direction: 'asc',
+ })).data;
+ return prs.map((pr) => pr.number);
+}
+
+async function selectMergeGateCandidates(github, owner, repo, prNumbers, maxCandidates, core) {
+ const readyList = [];
+ const skipped = [];
+ for (const num of prNumbers) {
+ if (readyList.length >= maxCandidates) {
+ core?.info?.(`Found ${maxCandidates} candidate(s) — skipping remaining PR scans`);
+ break;
+ }
+ const result = await evaluatePipelineQuiescent(github, owner, repo, num, core);
+ if (result.ready) {
+ readyList.push({ pr_number: num, head_sha: result.headSha });
+ } else {
+ skipped.push({ pr: num, reasons: result.reasons });
+ }
+ }
+ readyList.sort((a, b) => a.pr_number - b.pr_number);
+ return {
+ candidates: readyList.slice(0, maxCandidates),
+ skipped,
+ };
+}
+
+const AUTOMATED_FALLBACK_MARKER = 'Automated fallback —';
+
+function isAutomatedFallbackVerdict(body) {
+ return (body || '').includes(AUTOMATED_FALLBACK_MARKER);
+}
+
+function findMergeGateVerdict(comments, minCreatedAt = null, headPushedAt = null, options = {}) {
+ const { excludeAutomatedFallback = false } = options || {};
+ const minTime = minCreatedAt ? new Date(minCreatedAt).getTime() : 0;
+ const headTime = headPushedAt ? new Date(headPushedAt).getTime() - 60000 : 0;
+ const gateComments = comments
+ .filter((c) => {
+ if (!(c.body || '').includes('MERGE_GATE_VERDICT:')) return false;
+ if (excludeAutomatedFallback && isAutomatedFallbackVerdict(c.body)) return false;
+ const created = new Date(c.created_at).getTime();
+ if (minTime && created < minTime) return false;
+ if (headTime && created < headTime) return false;
+ return true;
+ })
+ .sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
+ if (gateComments.length === 0) return null;
+ const body = gateComments[0].body || '';
+ if (body.includes('MERGE_GATE_VERDICT: APPROVE')) return 'APPROVE';
+ if (body.includes('MERGE_GATE_VERDICT: BLOCK')) return 'BLOCK';
+ return null;
+}
+
+module.exports = {
+ CLAUDE_TRIGGER_LOGINS,
+ AUTO_ACTORS,
+ isFinalClaudeTriggerComment,
+ isClaudeTriggerNoise,
+ isClaudeFinalReplyComment,
+ hasRecentClaudeTrigger,
+ hasRecentConflictComment,
+ isConflictRebaseTriggerComment,
+ isConflictRebaseCompletionComment,
+ conflictRebaseQuiescent,
+ hasHumanChangesRequested,
+ hasAnyChangesRequested,
+ hasFinalClaudeReviewTrigger,
+ finalClaudeCompletedOnSha,
+ getMergeState,
+ OPTIONAL_CANCELLED_CHECKS,
+ listChecksOnSha,
+ listFailedChecksOnSha,
+ allChecksGreenOnSha,
+ hasInProgressClaudeAssistant,
+ claudeRunBlocksPr,
+ hasBlockingClaudeRunForPr,
+ isCiOnlyChange,
+ isInternalPullRequestLink,
+ resolvePrNumberFromLinkedPullRequests,
+ resolvePrNumberFromHeadBranch,
+ resolvePrNumberFromWorkflowRun,
+ WORKFLOW_ONLY_LABEL,
+ getAgentPyChange,
+ getAgentPyChangeFromFiles,
+ manualReviewReasonForAgentPy,
+ countNewAgentParams,
+ listPullFiles,
+ touchesSdk,
+ hasManualOnlyLabel,
+ sensitivePathReasons,
+ prSizeReasons,
+ missingTestsReason,
+ secretScanReasons,
+ sdkTestChecksReason,
+ listAllComments,
+ getHeadCommitDate,
+ isStaleFinalAfterPush,
+ needsStaleFinalRecovery,
+ shouldSkipFinalRecovery,
+ shouldSkipStaleFinalRecovery,
+ isClaudeAutomationLogin,
+ isPushSoonAfterLatestFinal,
+ countFinalTriggersSince,
+ STALE_FINAL_RECOVERY_WINDOW_MS,
+ STALE_FINAL_MAX_PER_WINDOW,
+ FINAL_CLAUDE_REVIEW_BODY,
+ loadPrContext,
+ evaluatePipelineQuiescent,
+ MERGE_READY_LABEL,
+ listPrNumbersForMergeGateScan,
+ selectMergeGateCandidates,
+ isAutomatedFallbackVerdict,
+ AUTOMATED_FALLBACK_MARKER,
+ findMergeGateVerdict,
+};
diff --git a/.github/scripts/pipeline-status-selftest.js b/.github/scripts/pipeline-status-selftest.js
new file mode 100644
index 0000000..2989b77
--- /dev/null
+++ b/.github/scripts/pipeline-status-selftest.js
@@ -0,0 +1,82 @@
+#!/usr/bin/env node
+/**
+ * Run: node .github/scripts/pipeline-status-selftest.js
+ */
+const ps = require('./pipeline-status.js');
+const mg = require('./merge-gate.js');
+const config = require('./gate-config.js');
+
+let failed = 0;
+function assert(name, cond) {
+ if (!cond) {
+ console.error('FAIL:', name);
+ failed++;
+ } else {
+ console.log('ok:', name);
+ }
+}
+
+const kickComments = [
+ { user: { login: 'MervinPraison' }, body: '@coderabbitai review' },
+ { user: { login: 'MervinPraison' }, body: '/review' },
+];
+
+const finalComment = {
+ user: { login: 'MervinPraison' },
+ body: '@claude You are the FINAL architecture reviewer.',
+ created_at: '2026-06-26T10:00:00Z',
+};
+
+assert(
+ 'reviews pending when not kicked',
+ ps.deriveStage([], { ready: false, reasons: ['no FINAL Claude review trigger'] })
+ === 'pipeline/reviews-pending'
+);
+assert(
+ 'final pending after kick',
+ ps.deriveStage(kickComments, { ready: false, reasons: ['no FINAL Claude review trigger'] })
+ === 'pipeline/final-claude-pending'
+);
+assert(
+ 'awaiting merge gate after bot review comment',
+ ps.deriveStage(
+ [
+ { user: { login: 'coderabbitai[bot]' }, body: 'review summary' },
+ finalComment,
+ ],
+ { ready: false, reasons: ['recent @claude within 35min'] }
+ ) === 'pipeline/awaiting-merge-gate'
+);
+
+assert('review bot logins configured', config.reviewBotLogins.includes('coderabbitai[bot]'));
+assert(
+ 'merge ready',
+ ps.deriveStage([...kickComments, finalComment], { ready: true, reasons: [] })
+ === 'pipeline/merge-ready'
+);
+
+const blockers = ps.deriveBlockerLabels({
+ ready: false,
+ reasons: ['CI not green on HEAD', 'SDK code added without test file changes — requires manual review'],
+});
+assert('maps ci blocker', blockers.includes('pipeline/blocked:ci'));
+assert('maps manual blocker', blockers.includes('pipeline/blocked:manual-review'));
+
+assert(
+ 'internal link matches upstream base',
+ mg.isInternalPullRequestLink(
+ { base: { repo: { full_name: 'MervinPraison/PraisonAI' } } },
+ 'MervinPraison',
+ 'PraisonAI'
+ )
+);
+assert(
+ 'fork sync link rejected',
+ !mg.isInternalPullRequestLink(
+ { number: 21, base: { repo: { full_name: 'Milkmange/PraisonAI' } } },
+ 'MervinPraison',
+ 'PraisonAI'
+ )
+);
+
+process.exit(failed ? 1 : 0);
diff --git a/.github/scripts/pipeline-status.js b/.github/scripts/pipeline-status.js
new file mode 100644
index 0000000..15caee2
--- /dev/null
+++ b/.github/scripts/pipeline-status.js
@@ -0,0 +1,264 @@
+/**
+ * Sync pipeline stage/blocker labels on open PRs.
+ * @see .github/workflows/pipeline-status-sync.yml
+ */
+
+const mergeGate = require('./merge-gate.js');
+const chain = require('./bot-pr-review-chain.js');
+const ciFix = require('./ci-failure-claude.js');
+const config = require('./gate-config.js');
+
+const PIPELINE_PREFIX = 'pipeline/';
+const STAGE_LABELS = [
+ 'pipeline/reviews-pending',
+ 'pipeline/final-claude-pending',
+ 'pipeline/awaiting-merge-gate',
+ 'pipeline/merge-ready',
+ 'pipeline/merged',
+];
+const BLOCKER_LABELS = [
+ 'pipeline/blocked:ci',
+ 'pipeline/blocked:conflict',
+ 'pipeline/blocked:manual-review',
+ 'pipeline/blocked:cooldown',
+ 'pipeline/blocked:stale-final',
+ 'pipeline/blocked:no-final',
+];
+const ALL_PIPELINE_LABELS = [...STAGE_LABELS, ...BLOCKER_LABELS];
+
+const LABEL_SPECS = [
+ { name: 'pipeline/reviews-pending', color: 'fbca04', description: 'Waiting for CodeRabbit/Qodo/Copilot reviews' },
+ { name: 'pipeline/final-claude-pending', color: 'd4c5f9', description: 'Reviews done; waiting for FINAL @claude' },
+ { name: 'pipeline/awaiting-merge-gate', color: 'c5def5', description: 'FINAL done; waiting for merge gate / CI' },
+ { name: 'pipeline/merge-ready', color: '0e8a16', description: 'Eligible for merge gate auto-merge' },
+ { name: 'pipeline/merged', color: '6f42c1', description: 'Merged via pipeline' },
+ { name: 'pipeline/blocked:ci', color: 'd93f0b', description: 'Blocked: CI not green on HEAD' },
+ { name: 'pipeline/blocked:conflict', color: 'b60205', description: 'Blocked: merge conflict or rebase pending' },
+ { name: 'pipeline/blocked:manual-review', color: 'e99695', description: 'Blocked: requires manual review' },
+ { name: 'pipeline/blocked:cooldown', color: 'fef2c0', description: 'Blocked: post-push or @claude cooldown' },
+ { name: 'pipeline/blocked:stale-final', color: 'f9d0c4', description: 'Blocked: FINAL stale after new commits' },
+ { name: 'pipeline/blocked:no-final', color: 'ededed', description: 'Blocked: no FINAL @claude trigger yet' },
+];
+
+function reviewChainStarted(comments) {
+ if (chain.chainKickPosted(comments)) return true;
+ return comments.some((c) => config.reviewBotLogins.includes(c.user?.login));
+}
+
+function deriveStage(comments, evalResult) {
+ if (evalResult.ready) return 'pipeline/merge-ready';
+ if (!reviewChainStarted(comments)) return 'pipeline/reviews-pending';
+ if (!mergeGate.hasFinalClaudeReviewTrigger(comments)) return 'pipeline/final-claude-pending';
+ return 'pipeline/awaiting-merge-gate';
+}
+
+function reasonToBlockerLabel(reason) {
+ const r = (reason || '').toLowerCase();
+ if (r.includes('ci not green') || r.includes('sdk code changed but no ci')) return 'pipeline/blocked:ci';
+ if (
+ r.includes('conflict') ||
+ r.includes('mergestate=dirty') ||
+ r.includes('not mergeable')
+ ) return 'pipeline/blocked:conflict';
+ if (
+ r.includes('manual') ||
+ r.includes('requires manual') ||
+ r.includes('sensitive path') ||
+ r.includes('no-auto-merge') ||
+ r.includes('manual-only label') ||
+ r.includes('without test') ||
+ r.includes('product code') ||
+ r.includes('files changed') ||
+ r.includes('possible secret')
+ ) return 'pipeline/blocked:manual-review';
+ if (r.includes('recent @claude') || r.includes('post-push buffer')) return 'pipeline/blocked:cooldown';
+ if (r.includes('stale final')) return 'pipeline/blocked:stale-final';
+ if (r.includes('no final claude')) return 'pipeline/blocked:no-final';
+ if (r.includes('final claude not complete')) return 'pipeline/blocked:stale-final';
+ if (r.includes('claude.yml in progress') || r.includes('claude-merge-gate-active')) {
+ return null;
+ }
+ return null;
+}
+
+function deriveBlockerLabels(evalResult) {
+ if (evalResult.ready) return [];
+ const labels = new Set();
+ for (const reason of evalResult.reasons || []) {
+ const mapped = reasonToBlockerLabel(reason);
+ if (mapped && mapped.startsWith('pipeline/blocked:')) labels.add(mapped);
+ }
+ return [...labels];
+}
+
+function computePipelineLabels(comments, evalResult) {
+ const stage = deriveStage(comments, evalResult);
+ const blockers = stage === 'pipeline/merge-ready' ? [] : deriveBlockerLabels(evalResult);
+ return { stage, blockers, all: [stage, ...blockers] };
+}
+
+async function ensurePipelineLabels(github, owner, repo, core) {
+ let existing;
+ if (typeof github.paginate === 'function') {
+ existing = await github.paginate(github.rest.issues.listLabelsForRepo, {
+ owner,
+ repo,
+ per_page: 100,
+ });
+ } else {
+ ({ data: existing } = await github.rest.issues.listLabelsForRepo({
+ owner,
+ repo,
+ per_page: 100,
+ }));
+ }
+ const names = new Set(existing.map((l) => l.name));
+ for (const spec of LABEL_SPECS) {
+ if (names.has(spec.name)) continue;
+ try {
+ await github.rest.issues.createLabel({
+ owner,
+ repo,
+ name: spec.name,
+ color: spec.color,
+ description: spec.description,
+ });
+ core?.info?.(`Created label ${spec.name}`);
+ } catch (err) {
+ if (err.status !== 422) throw err;
+ core?.info?.(`Label ${spec.name} already exists`);
+ }
+ }
+}
+
+async function syncPipelineLabels(github, owner, repo, prNumber, core) {
+ const ctx = await mergeGate.loadPrContext(github, owner, repo, prNumber);
+ if (ctx.pr.state !== 'open') return { synced: false, reason: 'not_open' };
+
+ const evalResult = await mergeGate.evaluatePipelineQuiescent(
+ github, owner, repo, prNumber, core
+ );
+ const { stage, blockers, all } = computePipelineLabels(ctx.comments, evalResult);
+
+ const current = ctx.labels.filter((l) => l.startsWith(PIPELINE_PREFIX));
+ const desired = new Set(all);
+ const toRemove = current.filter((l) => !desired.has(l) && l !== 'pipeline/merged');
+ const toAdd = all.filter((l) => !current.includes(l));
+
+ for (const name of toRemove) {
+ try {
+ await github.rest.issues.removeLabel({ owner, repo, issue_number: prNumber, name });
+ } catch (err) {
+ if (err.status !== 404) throw err;
+ }
+ }
+ if (toAdd.length) {
+ await github.rest.issues.addLabels({
+ owner,
+ repo,
+ issue_number: prNumber,
+ labels: toAdd,
+ });
+ }
+
+ core?.info?.(`PR #${prNumber}: stage=${stage} blockers=[${blockers.join(', ')}]`);
+
+ const ciNotGreen = (evalResult.reasons || []).some((r) =>
+ r.toLowerCase().includes('ci not green')
+ );
+ if (ciNotGreen || blockers.includes('pipeline/blocked:ci')) {
+ await ciFix.maybeTriggerCiFixClaude(github, owner, repo, prNumber, core);
+ } else {
+ await ciFix.maybeClearCiFixLabel(
+ github, owner, repo, prNumber, ctx.labels, ctx.headSha, core
+ );
+ }
+
+ return {
+ synced: true,
+ stage,
+ blockers,
+ ready: evalResult.ready,
+ reasons: evalResult.reasons,
+ labels: ctx.labels,
+ createdAt: new Date(ctx.pr.created_at).getTime(),
+ };
+}
+
+async function dispatchMergeGateForOldestReady(github, owner, repo, readyCandidates, core) {
+ if (!readyCandidates.length) return 0;
+ readyCandidates.sort((a, b) => a.createdAt - b.createdAt);
+ for (const cand of readyCandidates) {
+ if ((cand.labels || []).includes('claude-merge-gate-active')) {
+ core?.info?.(`Skip dispatch PR #${cand.prNumber}: merge gate already active`);
+ continue;
+ }
+ await github.rest.repos.createDispatchEvent({
+ owner,
+ repo,
+ event_type: 'claude-merge-gate',
+ client_payload: { pr_number: cand.prNumber },
+ });
+ core?.info?.(`Dispatched merge gate for ready PR #${cand.prNumber}`);
+ return cand.prNumber;
+ }
+ return 0;
+}
+
+async function syncOpenPullRequests(github, owner, repo, options, core) {
+ const { maxPrs = 20, dispatchMergeGate = true } = options || {};
+ await ensurePipelineLabels(github, owner, repo, core);
+ let prs;
+ if (maxPrs <= 100) {
+ ({ data: prs } = await github.rest.pulls.list({
+ owner,
+ repo,
+ state: 'open',
+ per_page: 100,
+ }));
+ } else {
+ prs = await github.paginate(github.rest.pulls.list, {
+ owner,
+ repo,
+ state: 'open',
+ per_page: 100,
+ });
+ }
+ let synced = 0;
+ const readyCandidates = [];
+ for (const pr of prs) {
+ if (synced >= maxPrs) break;
+ if (pr.draft) continue;
+ if (pr.head?.repo?.full_name && pr.head.repo.full_name !== `${owner}/${repo}`) continue;
+ const result = await syncPipelineLabels(github, owner, repo, pr.number, core);
+ if (result.ready) {
+ readyCandidates.push({
+ prNumber: pr.number,
+ createdAt: result.createdAt || new Date(pr.created_at).getTime(),
+ labels: result.labels || [],
+ });
+ }
+ synced += 1;
+ }
+ let dispatched = 0;
+ if (dispatchMergeGate && readyCandidates.length) {
+ dispatched = await dispatchMergeGateForOldestReady(
+ github, owner, repo, readyCandidates, core
+ );
+ }
+ core?.info?.(`Pipeline label sync complete (${synced} PR(s), dispatched=${dispatched || 'none'})`);
+ return synced;
+}
+
+module.exports = {
+ STAGE_LABELS,
+ BLOCKER_LABELS,
+ ALL_PIPELINE_LABELS,
+ deriveStage,
+ deriveBlockerLabels,
+ computePipelineLabels,
+ ensurePipelineLabels,
+ syncPipelineLabels,
+ syncOpenPullRequests,
+ dispatchMergeGateForOldestReady,
+};
diff --git a/.github/scripts/pr-review-chain.js b/.github/scripts/pr-review-chain.js
new file mode 100644
index 0000000..02a304b
--- /dev/null
+++ b/.github/scripts/pr-review-chain.js
@@ -0,0 +1,300 @@
+/**
+ * PR review pipeline: CodeRabbit / Greptile (+ optional Qodo/Gemini) → Claude (FINAL).
+ * Copilot step optional — set REVIEW_CHAIN_SKIP_COPILOT=1 or skipCopilot: true to bypass.
+ * @see .github/workflows/auto-pr-comment.yml
+ */
+
+function isSkipCopilot(options = {}) {
+ if (options.skipCopilot === true) return true;
+ if (options.skipCopilot === false) return false;
+ return process.env.REVIEW_CHAIN_SKIP_COPILOT === '1';
+}
+
+const COPILOT_TRIGGER_LOGINS = new Set(['MervinPraison', 'github-actions[bot]']);
+const CLAUDE_TRIGGER_LOGINS = new Set(['MervinPraison', 'github-actions[bot]']);
+
+const REQUIRED_PRIOR = ['coderabbit', 'greptile'];
+const OPTIONAL_PRIOR = ['qodo', 'gemini'];
+const OPTIONAL_PRIOR_WAIT_MS = 20 * 60 * 1000;
+
+function loginOf(item) {
+ return (item?.user?.login || '').toLowerCase();
+}
+
+function bodyOf(item) {
+ return item?.body || '';
+}
+
+function hasCoderabbitSummary(comments) {
+ return comments.some(
+ (c) =>
+ loginOf(c).includes('coderabbit') &&
+ bodyOf(c).toLowerCase().includes('summarize by coderabbit')
+ );
+}
+
+function hasGreptileReview(comments) {
+ return comments.some((c) => {
+ if (!loginOf(c).includes('greptile')) return false;
+ const body = bodyOf(c);
+ return body.includes('Greptile Summary') || body.includes('
Greptile Summary
') || body.length > 150;
+ });
+}
+
+function hasQodoReview(comments, reviews = []) {
+ if (comments.some((c) => loginOf(c).includes('qodo'))) return true;
+ return reviews.some((r) => loginOf(r).includes('qodo'));
+}
+
+function hasGeminiReview(comments, reviews = []) {
+ if (reviews.some((r) => loginOf(r).includes('gemini'))) return true;
+ return comments.some((c) => {
+ if (loginOf(c).includes('gemini')) return true;
+ const body = bodyOf(c);
+ return (
+ body.includes('Review completed by Gemini CLI') ||
+ body.includes('The fix is ready for review. Please test')
+ );
+ });
+}
+
+const PRIOR_CHECKS = {
+ coderabbit: (comments) => hasCoderabbitSummary(comments),
+ greptile: (comments) => hasGreptileReview(comments),
+ qodo: (comments, reviews) => hasQodoReview(comments, reviews),
+ gemini: (comments, reviews) => hasGeminiReview(comments, reviews),
+};
+
+function priorReviewerStatus(comments, reviews = []) {
+ const status = {};
+ for (const [id, check] of Object.entries(PRIOR_CHECKS)) {
+ status[id] = check(comments, reviews);
+ }
+ return status;
+}
+
+function priorReviewersReady(comments, reviews = [], options = {}) {
+ const waitMs = options.optionalWaitMs ?? OPTIONAL_PRIOR_WAIT_MS;
+ const prCreatedAt = options.prCreatedAt || null;
+ const status = priorReviewerStatus(comments, reviews);
+
+ const missingRequired = REQUIRED_PRIOR.filter((id) => !status[id]);
+ if (missingRequired.length) {
+ return { ready: false, reason: `waiting for ${missingRequired.join(', ')}`, status };
+ }
+
+ const missingOptional = OPTIONAL_PRIOR.filter((id) => !status[id]);
+ if (missingOptional.length && prCreatedAt) {
+ const age = Date.now() - new Date(prCreatedAt).getTime();
+ if (age < waitMs) {
+ return {
+ ready: false,
+ reason: `waiting for optional ${missingOptional.join(', ')} (${Math.round((waitMs - age) / 60000)}m left)`,
+ status,
+ };
+ }
+ }
+
+ return { ready: true, reason: '', status };
+}
+
+function copilotTriggerComment(comments) {
+ return comments.find(
+ (c) =>
+ bodyOf(c).includes('@copilot') &&
+ COPILOT_TRIGGER_LOGINS.has(c.user?.login) &&
+ !bodyOf(c).toLowerCase().includes('@claude')
+ );
+}
+
+function copilotTriggered(comments) {
+ return Boolean(copilotTriggerComment(comments));
+}
+
+function copilotReviewReady(comments, reviews = []) {
+ const trigger = copilotTriggerComment(comments);
+ if (!trigger) {
+ return { ready: false, reason: 'copilot not triggered yet' };
+ }
+ const triggerTime = new Date(trigger.created_at).getTime();
+ const copilotComment = comments.some((c) => {
+ if (c === trigger) return false;
+ if (new Date(c.created_at).getTime() < triggerTime) return false;
+ const login = loginOf(c);
+ return (
+ login.includes('copilot') ||
+ c.user?.login === 'Copilot' ||
+ c.user?.login === 'copilot-swe-agent'
+ );
+ });
+ const copilotReview = reviews.some((r) => {
+ if (new Date(r.submitted_at).getTime() < triggerTime) return false;
+ return loginOf(r).includes('copilot');
+ });
+ if (copilotComment || copilotReview) {
+ return { ready: true, reason: '' };
+ }
+ return { ready: false, reason: 'awaiting copilot review' };
+}
+
+function isFinalClaudeTriggerComment(c) {
+ const body = bodyOf(c).toLowerCase();
+ if (!CLAUDE_TRIGGER_LOGINS.has(c.user?.login)) return false;
+ if (!body.includes('@claude')) return false;
+ if (body.includes('merge conflict')) return false;
+ return body.includes('final architecture reviewer') || body.includes('lead engineer');
+}
+
+function claudeFinalAlreadyTriggered(comments) {
+ return comments.some(isFinalClaudeTriggerComment);
+}
+
+function claudeFinalReady(comments, reviews = [], options = {}) {
+ if (!options.allowStaleRepost && claudeFinalAlreadyTriggered(comments)) {
+ return { ready: false, reason: 'claude FINAL already triggered', already: true };
+ }
+ const prior = priorReviewersReady(comments, reviews, options);
+ if (!prior.ready) {
+ return { ready: false, reason: prior.reason };
+ }
+ if (isSkipCopilot(options)) {
+ return { ready: true, reason: 'copilot skipped', copilotSkipped: true };
+ }
+ const copilot = copilotReviewReady(comments, reviews);
+ if (copilot.ready) {
+ return { ready: true, reason: '' };
+ }
+ if (options.allowCopilotTimeout && copilotTriggered(comments)) {
+ return { ready: true, reason: 'copilot timeout fallback', copilotSkipped: true };
+ }
+ return { ready: false, reason: copilot.reason };
+}
+
+/** Post @copilot when prior reviewers are ready, then @claude FINAL when Copilot done (or timeout). */
+async function advanceReviewChain(github, owner, repo, prNumber, finalBody, core, options = {}) {
+ const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: prNumber });
+ const chainOpts = {
+ prCreatedAt: pr.created_at,
+ optionalWaitMs: options.optionalWaitMs ?? 0,
+ };
+ const copilot = options.skipCopilot
+ ? { triggered: false, reason: 'skipped' }
+ : await maybeTriggerCopilot(github, owner, repo, prNumber, core, chainOpts);
+ const claude = await maybeTriggerClaudeFinal(
+ github, owner, repo, prNumber, finalBody, core,
+ { ...options, ...chainOpts }
+ );
+ return { copilot, claude };
+}
+
+async function maybeTriggerClaudeFinal(github, owner, repo, prNumber, finalBody, core, options = {}) {
+ const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: prNumber });
+ const { comments, reviews } = await listCommentsAndReviews(github, owner, repo, prNumber);
+ const gate = claudeFinalReady(comments, reviews, {
+ prCreatedAt: pr.created_at,
+ optionalWaitMs: options.optionalWaitMs,
+ allowCopilotTimeout: options.allowCopilotTimeout !== false,
+ allowStaleRepost: options.allowStaleRepost === true,
+ skipCopilot: options.skipCopilot,
+ });
+ if (!gate.ready) {
+ core?.info?.(`PR #${prNumber}: skip Claude FINAL — ${gate.reason}`);
+ return { posted: false, reason: gate.reason };
+ }
+ await github.rest.issues.createComment({
+ owner,
+ repo,
+ issue_number: prNumber,
+ body: finalBody,
+ });
+ const note = gate.copilotSkipped ? ' (Copilot timeout fallback)' : '';
+ core?.info?.(`Posted Claude FINAL on PR #${prNumber}${note}`);
+ return { posted: true, reason: '' };
+}
+
+const COPILOT_REVIEW_BODY =
+ '@copilot Do a thorough review of this PR. Read ALL existing reviewer comments above from Qodo, CodeRabbit, Gemini, and Greptile first — incorporate their findings.\n\nReview areas:\n1. **Bloat check**: Are changes minimal and focused? Any unnecessary code or scope creep?\n2. **Security**: Any hardcoded secrets, unsafe eval/exec, missing input validation?\n3. **Performance**: Any module-level heavy imports? Hot-path regressions?\n4. **Tests**: Are tests included? Do they cover the changes adequately?\n5. **Backward compat**: Any public API changes without deprecation?\n6. **Code quality**: DRY violations, naming conventions, error handling?\n7. **Address reviewer feedback**: If Qodo, CodeRabbit, Gemini, or Greptile flagged valid issues, include them in your review\n8. Suggest specific improvements with code examples where possible';
+
+async function listCommentsAndReviews(github, owner, repo, prNumber) {
+ let comments;
+ if (typeof github.paginate === 'function') {
+ comments = await github.paginate(github.rest.issues.listComments, {
+ owner,
+ repo,
+ issue_number: prNumber,
+ per_page: 100,
+ });
+ } else {
+ const { data } = await github.rest.issues.listComments({
+ owner,
+ repo,
+ issue_number: prNumber,
+ per_page: 100,
+ });
+ comments = data;
+ }
+ const { data: reviews } = await github.rest.pulls.listReviews({
+ owner,
+ repo,
+ pull_number: prNumber,
+ per_page: 100,
+ });
+ return { comments, reviews };
+}
+
+async function maybeTriggerCopilot(github, owner, repo, prNumber, core, options = {}) {
+ const { comments, reviews } = await listCommentsAndReviews(github, owner, repo, prNumber);
+ if (copilotTriggered(comments)) {
+ core?.info?.(`Copilot already triggered on PR #${prNumber}`);
+ return { triggered: false, reason: 'already_triggered' };
+ }
+ const prior = priorReviewersReady(comments, reviews, options);
+ if (!prior.ready) {
+ core?.info?.(`PR #${prNumber}: not ready for Copilot — ${prior.reason}`);
+ return { triggered: false, reason: prior.reason };
+ }
+ await github.rest.issues.createComment({
+ owner,
+ repo,
+ issue_number: prNumber,
+ body: COPILOT_REVIEW_BODY,
+ });
+ core?.info?.(`Posted @copilot on PR #${prNumber} after prior reviewers`);
+ return { triggered: true };
+}
+
+async function pollCopilotResponse(github, owner, repo, prNumber, options = {}) {
+ const maxAttempts = options.maxAttempts ?? 20;
+ const delayMs = options.delayMs ?? 30000;
+ for (let i = 0; i < maxAttempts; i += 1) {
+ const { comments, reviews } = await listCommentsAndReviews(github, owner, repo, prNumber);
+ const copilot = copilotReviewReady(comments, reviews);
+ if (copilot.ready) {
+ return { ready: true, reason: '' };
+ }
+ if (i < maxAttempts - 1) {
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
+ }
+ }
+ return { ready: false, reason: 'copilot timeout' };
+}
+
+module.exports = {
+ isSkipCopilot,
+ REQUIRED_PRIOR,
+ OPTIONAL_PRIOR,
+ COPILOT_REVIEW_BODY,
+ priorReviewerStatus,
+ priorReviewersReady,
+ copilotTriggered,
+ copilotReviewReady,
+ claudeFinalReady,
+ claudeFinalAlreadyTriggered,
+ isFinalClaudeTriggerComment,
+ maybeTriggerCopilot,
+ maybeTriggerClaudeFinal,
+ advanceReviewChain,
+ pollCopilotResponse,
+ hasGeminiReview,
+ listCommentsAndReviews,
+};
diff --git a/.github/scripts/release-gate-selftest.js b/.github/scripts/release-gate-selftest.js
new file mode 100644
index 0000000..d04fb53
--- /dev/null
+++ b/.github/scripts/release-gate-selftest.js
@@ -0,0 +1,32 @@
+#!/usr/bin/env node
+/**
+ * Run: node .github/scripts/release-gate-selftest.js
+ */
+const rg = require('./release-gate.js');
+const config = require('./gate-config.js');
+
+let failed = 0;
+function assert(name, cond) {
+ if (!cond) {
+ console.error('FAIL:', name);
+ failed += 1;
+ } else {
+ console.log('ok:', name);
+ }
+}
+
+assert(`package name is ${config.pypiPackageName}`, config.pypiPackageName === 'praisonai-plugins');
+assert('bumpPatch works', rg.bumpPatch('0.3.127') === '0.3.128');
+
+try {
+ const versions = rg.readVersionsFromTree(process.cwd());
+ assert('reads current version', /^\d+\.\d+\.\d+$/.test(versions.current));
+ assert('computes target patch', versions.target === rg.bumpPatch(versions.current));
+} catch (err) {
+ console.error('FAIL: readVersionsFromTree', err.message);
+ failed += 1;
+}
+
+assert('PACKAGE_PATHS includes pyproject', rg.PACKAGE_PATHS.includes('pyproject.toml'));
+
+process.exit(failed ? 1 : 0);
diff --git a/.github/scripts/release-gate.js b/.github/scripts/release-gate.js
new file mode 100644
index 0000000..2e135e2
--- /dev/null
+++ b/.github/scripts/release-gate.js
@@ -0,0 +1,205 @@
+/**
+ * Release gate preflight — path changes, CI SHA, dedupe, PyPI version checks (PraisonAIUI).
+ */
+
+const https = require('https');
+const config = require('./gate-config.js');
+
+const PACKAGE_PATHS = config.packagePaths;
+const ACTIVE_RELEASE_STATUSES = new Set([
+ 'queued', 'in_progress', 'waiting', 'pending', 'requested',
+]);
+
+function bumpPatch(version) {
+ const parts = version.split('.');
+ if (parts.length !== 3) throw new Error(`Invalid version: ${version}`);
+ return `${parts[0]}.${parts[1]}.${Number(parts[2]) + 1}`;
+}
+
+function readVersionsFromTree(root = '.') {
+ const fs = require('fs');
+ const path = require('path');
+ const toml = fs.readFileSync(path.join(root, 'pyproject.toml'), 'utf8');
+ const match = toml.match(/^version\s*=\s*"([^"]+)"/m);
+ if (!match) throw new Error('Could not read version from pyproject.toml');
+ const current = match[1];
+ const target = bumpPatch(current);
+ return { current, target, packageName: config.pypiPackageName };
+}
+
+function pypiVersionExists(packageName, version) {
+ return new Promise((resolve) => {
+ const req = https.get(
+ `https://pypi.org/pypi/${packageName}/${version}/json`,
+ { timeout: 15000 },
+ (res) => {
+ res.resume();
+ resolve(res.statusCode === 200);
+ }
+ );
+ req.on('error', () => resolve(false));
+ req.on('timeout', () => {
+ req.destroy();
+ resolve(false);
+ });
+ });
+}
+
+async function hasActiveReleaseRun(github, owner, repo) {
+ const runs = await github.rest.actions.listWorkflowRuns({
+ owner,
+ repo,
+ workflow_id: 'pypi-release.yml',
+ per_page: 20,
+ });
+ return runs.data.workflow_runs.some(
+ (r) => ACTIVE_RELEASE_STATUSES.has(r.status) && !r.conclusion
+ );
+}
+
+function utcDayStart(now = new Date()) {
+ return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
+}
+
+async function hasSuccessfulReleaseToday(github, owner, repo, now = new Date()) {
+ const dayStart = utcDayStart(now);
+ const runs = await github.rest.actions.listWorkflowRuns({
+ owner,
+ repo,
+ workflow_id: 'pypi-release.yml',
+ status: 'completed',
+ per_page: 30,
+ });
+ return runs.data.workflow_runs.some(
+ (r) => r.conclusion === 'success' && new Date(r.created_at) >= dayStart
+ );
+}
+
+async function lastGreenCiSha(github, owner, repo) {
+ const runs = await github.rest.actions.listWorkflowRuns({
+ owner,
+ repo,
+ workflow_id: config.ciWorkflowFile,
+ branch: 'main',
+ status: 'completed',
+ per_page: 30,
+ });
+ const hit = runs.data.workflow_runs.find((r) => r.conclusion === 'success');
+ return hit ? hit.head_sha : '';
+}
+
+async function evaluateReleasePreflight(github, owner, repo, options, core) {
+ const {
+ headSha,
+ isCiTrigger = false,
+ bump = 'patch',
+ } = options;
+
+ const reasons = [];
+ const out = {
+ ready: false,
+ reasons,
+ headSha: headSha || '',
+ lastTag: '',
+ targetVersion: '',
+ packageName: config.pypiPackageName,
+ };
+
+ if (bump !== 'patch') {
+ reasons.push('only patch auto-release supported');
+ return out;
+ }
+
+ if (await hasActiveReleaseRun(github, owner, repo)) {
+ reasons.push('PyPI Release already in progress or awaiting approval');
+ return out;
+ }
+
+ const referenceTime = options.now instanceof Date ? options.now : new Date();
+ if (await hasSuccessfulReleaseToday(github, owner, repo, referenceTime)) {
+ const day = referenceTime.toISOString().slice(0, 10);
+ reasons.push(`already released today (UTC ${day}); max one patch release per day`);
+ return out;
+ }
+
+ let versions;
+ try {
+ versions = readVersionsFromTree();
+ } catch (err) {
+ reasons.push(err.message);
+ return out;
+ }
+ out.targetVersion = versions.target;
+
+ const onPypi = await pypiVersionExists(config.pypiPackageName, versions.target);
+ if (onPypi) {
+ reasons.push(`already published: ${config.pypiPackageName}==${versions.target}`);
+ return out;
+ }
+
+ const { execSync } = require('child_process');
+ let lastTag = '';
+ try {
+ lastTag = execSync('git describe --tags --match "v*" --abbrev=0', { encoding: 'utf8' }).trim();
+ } catch {
+ reasons.push('no v* tag found');
+ return out;
+ }
+ out.lastTag = lastTag;
+
+ let changed = '';
+ try {
+ changed = execSync(
+ `git diff --name-only ${lastTag} HEAD -- ${PACKAGE_PATHS.join(' ')}`,
+ { encoding: 'utf8' }
+ ).trim();
+ } catch {
+ reasons.push('git diff failed');
+ return out;
+ }
+
+ if (!changed) {
+ reasons.push(`no changes in ${PACKAGE_PATHS.join(' or ')} since ${lastTag}`);
+ return out;
+ }
+
+ if (isCiTrigger) {
+ let mainSha = '';
+ try {
+ mainSha = execSync('git rev-parse origin/main', { encoding: 'utf8' }).trim();
+ } catch {
+ reasons.push('could not resolve origin/main');
+ return out;
+ }
+ if (headSha !== mainSha) {
+ reasons.push(`superseded: green SHA ${headSha.slice(0, 7)} != main ${mainSha.slice(0, 7)}`);
+ return out;
+ }
+ out.headSha = headSha;
+ } else {
+ const evalSha = execSync('git rev-parse HEAD', { encoding: 'utf8' }).trim();
+ out.headSha = evalSha;
+ const greenSha = await lastGreenCiSha(github, owner, repo);
+ if (greenSha !== evalSha) {
+ reasons.push(`CI not green on HEAD (last green: ${greenSha ? greenSha.slice(0, 7) : 'none'})`);
+ return out;
+ }
+ }
+
+ out.ready = true;
+ out.reasons = ['ready'];
+ if (core) core.info(`Release preflight passed for ${out.headSha || headSha}`);
+ return out;
+}
+
+module.exports = {
+ bumpPatch,
+ readVersionsFromTree,
+ pypiVersionExists,
+ hasSuccessfulReleaseToday,
+ utcDayStart,
+ evaluateReleasePreflight,
+ lastGreenCiSha,
+ ACTIVE_RELEASE_STATUSES,
+ PACKAGE_PATHS,
+};
diff --git a/.github/workflows/auto-pr-comment.yml b/.github/workflows/auto-pr-comment.yml
index 9038726..3f80619 100644
--- a/.github/workflows/auto-pr-comment.yml
+++ b/.github/workflows/auto-pr-comment.yml
@@ -15,12 +15,32 @@ on:
pull_request_review:
types: [submitted]
pull_request:
- types: [opened]
+ types: [opened, synchronize]
+ pull_request_target:
+ types: [synchronize]
+ schedule:
+ - cron: '30 3,9,15,21 * * *'
+ workflow_dispatch:
+ inputs:
+ pr_number:
+ description: 'PR number for manual FINAL (leave empty to sweep all open PRs)'
+ required: false
+ type: number
+
+permissions:
+ contents: read
+ issues: write
+ pull-requests: write
env:
MAX_POLL_ATTEMPTS: 20
POLL_DELAY_MS: 30000
CLAUDE_TRIGGER_LOGINS: '["MervinPraison","github-actions[bot]"]'
+ # GH_TOKEN (user PAT) posts as MervinPraison for @copilot; fallback to github.token for @claude
+ GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }}
+ CLAUDE_UI_SCOPE: 'SCOPE: Focus ONLY on PraisonAI-Plugins (src/praisonai_plugins, tests). Do not expand into praisonaiagents or the monorepo unless the issue explicitly requires it.'
+ CLAUDE_FINAL_PHASES_COMPACT: "**Phase 1: Review per AGENTS.md** (lazy imports, backward compat, import-time <200ms; SDK/product value gate — genuine plugin lifecycle value, correct plugin vs SDK vs Tools layering, no scope creep)\n**Phase 2: FIX valid issues** found by prior reviewers — push to THIS branch\n**Phase 3: Final verdict** — approve PR if ready, else request changes"
+ CLAUDE_FINAL_PHASES_DETAILED: "**Phase 1: Review per AGENTS.md**\n1. Protocol-driven: check heavy implementations vs core SDK\n2. Backward compatible: ensure zero feature regressions\n3. Performance: no hot-path regressions (lazy imports, import-time <200ms)\n4. SDK/product value: review whether the change genuinely adds value — correct plugin layering (lifecycle hooks/guardrails/sandbox backends here; agent tools belong in PraisonAI-Tools; core SDK in praisonaiagents). Reject scope creep; request changes or recommend closing if value is unclear\n\n**Phase 2: FIX Valid Issues**\n5. For any VALID bugs or architectural flaws found by Gemini, CodeRabbit, Qodo, Copilot, or any other reviewer: implement the fix\n6. Push all code fixes directly to THIS branch (do NOT create a new PR)\n7. Comment a summary of exact files modified and what you skipped\n\n**Phase 3: Final Verdict**\n8. If all issues are resolved, approve the PR / close the Issue\n9. If blocking issues remain, request changes / leave clear action items"
jobs:
# ================================================================
@@ -31,7 +51,7 @@ jobs:
github.event_name == 'issue_comment' &&
github.event.issue.pull_request &&
github.event.comment.user.login == 'coderabbitai[bot]' &&
- contains(github.event.comment.body, 'summary by coderabbit')
+ contains(github.event.comment.body, 'summarize by coderabbit')
runs-on: ubuntu-latest
permissions:
pull-requests: write
@@ -43,7 +63,7 @@ jobs:
id: trigger
uses: actions/github-script@v7
with:
- github-token: ${{ secrets.GH_TOKEN }}
+ github-token: ${{ env.GH_TOKEN }}
script: |
const comments = await github.rest.issues.listComments({
issue_number: context.issue.number,
@@ -86,7 +106,7 @@ jobs:
id: trigger
uses: actions/github-script@v7
with:
- github-token: ${{ secrets.GH_TOKEN }}
+ github-token: ${{ env.GH_TOKEN }}
script: |
const comments = await github.rest.issues.listComments({
issue_number: context.issue.number,
@@ -112,6 +132,90 @@ jobs:
core.setOutput('triggered', 'true');
core.setOutput('pr_number', context.issue.number.toString());
+ # ================================================================
+ # FALLBACK: Claude trigger for cases where Copilot chain fails/skips
+ # Ensures Claude always runs after sufficient review time
+ # ================================================================
+ claude-fallback-timeout:
+ if: |
+ github.event_name == 'pull_request' &&
+ github.event.action == 'opened'
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ permissions:
+ issues: write
+ pull-requests: write
+ steps:
+ - name: Wait for review window (15 min)
+ run: sleep 900
+
+ - name: Check if Claude already triggered
+ id: check_claude
+ uses: actions/github-script@v7
+ env:
+ TRIGGER_LOGINS: ${{ env.CLAUDE_TRIGGER_LOGINS }}
+ PR_NUMBER: ${{ github.event.pull_request.number }}
+ with:
+ github-token: ${{ env.GH_TOKEN }}
+ script: |
+ const pr = parseInt(process.env.PR_NUMBER, 10);
+ const triggerLogins = JSON.parse(process.env.TRIGGER_LOGINS);
+ const comments = await github.rest.issues.listComments({
+ issue_number: pr,
+ owner: context.repo.owner,
+ repo: context.repo.repo
+ });
+ const alreadyPosted = comments.data.some(c =>
+ c.body.includes('@claude') && triggerLogins.includes(c.user.login)
+ );
+ core.setOutput('already_triggered', alreadyPosted ? 'true' : 'false');
+ core.setOutput('comments_count', comments.data.length.toString());
+
+ const botReviews = comments.data.filter(c =>
+ ['coderabbitai[bot]', 'qodo-code-review[bot]', 'gemini-code-assist[bot]',
+ 'copilot-swe-agent', 'Copilot', 'greptile-apps[bot]'].some(bot =>
+ c.user.login.toLowerCase().includes(bot.toLowerCase())
+ )
+ );
+ core.setOutput('review_count', botReviews.length.toString());
+
+ - name: Aggregate reviews and trigger Claude
+ if: steps.check_claude.outputs.already_triggered != 'true'
+ uses: actions/github-script@v7
+ env:
+ PR_NUMBER: ${{ github.event.pull_request.number }}
+ with:
+ github-token: ${{ env.GH_TOKEN }}
+ script: |
+ const pr = parseInt(process.env.PR_NUMBER, 10);
+ const comments = await github.rest.issues.listComments({issue_number: pr, owner: context.repo.owner, repo: context.repo.repo, per_page: 100});
+ const reviews = {coderabbit: [], qodo: [], gemini: [], copilot: [], greptile: [], others: []};
+ comments.data.forEach(c => {
+ const login = c.user.login.toLowerCase();
+ const body = c.body.substring(0, 500);
+ if (login.includes('coderabbit')) reviews.coderabbit.push(body);
+ else if (login.includes('qodo')) reviews.qodo.push(body);
+ else if (login.includes('gemini')) reviews.gemini.push(body);
+ else if (login.includes('copilot')) reviews.copilot.push(body);
+ else if (login.includes('greptile')) reviews.greptile.push(body);
+ else if (c.user.type === 'Bot') reviews.others.push(body);
+ });
+ const summaryParts = [];
+ if (reviews.coderabbit.length) summaryParts.push('CodeRabbit: ' + reviews.coderabbit.length + ' comments');
+ if (reviews.qodo.length) summaryParts.push('Qodo: ' + reviews.qodo.length + ' comments');
+ if (reviews.gemini.length) summaryParts.push('Gemini: ' + reviews.gemini.length + ' comments');
+ if (reviews.copilot.length) summaryParts.push('Copilot: ' + reviews.copilot.length + ' comments');
+ if (reviews.greptile.length) summaryParts.push('Greptile: ' + reviews.greptile.length + ' comments');
+ const reviewSummary = summaryParts.length ? summaryParts.join(' | ') : 'No bot reviews detected';
+ const scope = process.env.CLAUDE_UI_SCOPE;
+ const phases = process.env.CLAUDE_FINAL_PHASES_COMPACT;
+ const claudeBody = '@claude You are the FINAL architecture reviewer. If the branch is under MervinPraison/PraisonAI-Plugins (not a fork), you are able to make modifications to this branch and push directly. ' + scope + ' Read ALL prior reviewer comments. ' + reviewSummary + '.\n\n' +
+ 'Review Context: ' + comments.data.length + ' total comments, ' +
+ (reviews.coderabbit.length + reviews.qodo.length + reviews.gemini.length + reviews.copilot.length + reviews.greptile.length) + ' bot reviews.\n\n' +
+ phases;
+ await github.rest.issues.createComment({issue_number: pr, owner: context.repo.owner, repo: context.repo.repo, body: claudeBody});
+ console.log('Claude fallback triggered on PR #' + pr + ' with ' + reviewSummary);
+
# ================================================================
# Claude FINAL review — polls for Copilot's response, then triggers
# This runs IN-BAND under the PAT context (MervinPraison), avoiding
@@ -129,6 +233,10 @@ jobs:
issues: write
pull-requests: write
steps:
+ - uses: actions/checkout@v4
+ with:
+ persist-credentials: false
+
- name: Wait for Copilot to respond (poll)
id: poll
uses: actions/github-script@v7
@@ -137,7 +245,7 @@ jobs:
MAX_POLL_ATTEMPTS: ${{ env.MAX_POLL_ATTEMPTS }}
POLL_DELAY_MS: ${{ env.POLL_DELAY_MS }}
with:
- github-token: ${{ secrets.GH_TOKEN }}
+ github-token: ${{ env.GH_TOKEN }}
script: |
const pr = parseInt(process.env.PR_NUMBER, 10);
const owner = context.repo.owner;
@@ -206,7 +314,7 @@ jobs:
PR_NUMBER: ${{ needs.copilot-after-coderabbit.outputs.pr_number }}
TRIGGER_LOGINS: ${{ env.CLAUDE_TRIGGER_LOGINS }}
with:
- github-token: ${{ secrets.GH_TOKEN }}
+ github-token: ${{ env.GH_TOKEN }}
script: |
const pr = parseInt(process.env.PR_NUMBER, 10);
const triggerLogins = JSON.parse(process.env.TRIGGER_LOGINS);
@@ -226,17 +334,33 @@ jobs:
env:
PR_NUMBER: ${{ needs.copilot-after-coderabbit.outputs.pr_number }}
with:
- github-token: ${{ secrets.GH_TOKEN }}
+ github-token: ${{ env.GH_TOKEN }}
script: |
const pr = parseInt(process.env.PR_NUMBER, 10);
+ const scope = process.env.CLAUDE_UI_SCOPE;
+ const phases = process.env.CLAUDE_FINAL_PHASES_DETAILED;
await github.rest.issues.createComment({
issue_number: pr,
owner: context.repo.owner,
repo: context.repo.repo,
- body: '@claude You are the FINAL architecture reviewer. Read ALL comments above from Gemini, Qodo, CodeRabbit, and Copilot carefully before responding.\n\n**Phase 1: Review per AGENTS.md**\n1. Protocol-driven: check heavy implementations vs core SDK\n2. Backward compatible: ensure zero feature regressions\n3. Performance: no hot-path regressions\n\n**Phase 2: FIX Valid Issues**\n4. For any VALID bugs or architectural flaws found by Gemini, CodeRabbit, Qodo, Copilot, or any other reviewer: implement the fix\n5. Push all code fixes directly to THIS branch (do NOT create a new PR)\n6. Comment a summary of exact files modified and what you skipped\n\n**Phase 3: Final Verdict**\n7. If all issues are resolved, approve the PR / close the Issue\n8. If blocking issues remain, request changes / leave clear action items'
+ body: '@claude You are the FINAL architecture reviewer. If the branch is under MervinPraison/PraisonAI-Plugins (not a fork), you are able to make modifications to this branch and push directly. ' + scope + ' Read ALL comments above from Gemini, Qodo, CodeRabbit, and Copilot carefully before responding.\n\n' + phases
});
console.log(`Claude triggered on PR #${pr}`);
+ - name: Sync pipeline status labels
+ if: steps.check_claude.outputs.already_triggered != 'true'
+ uses: actions/github-script@v7
+ env:
+ PR_NUMBER: ${{ needs.copilot-after-coderabbit.outputs.pr_number }}
+ with:
+ github-token: ${{ env.GH_TOKEN }}
+ script: |
+ const path = require('path');
+ const ps = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pipeline-status.js'));
+ const pr = parseInt(process.env.PR_NUMBER, 10);
+ await ps.ensurePipelineLabels(github, context.repo.owner, context.repo.repo, core);
+ await ps.syncPipelineLabels(github, context.repo.owner, context.repo.repo, pr, core);
+
# ================================================================
# Claude after Qodo fallback path (same polling approach)
# ================================================================
@@ -260,7 +384,7 @@ jobs:
MAX_POLL_ATTEMPTS: ${{ env.MAX_POLL_ATTEMPTS }}
POLL_DELAY_MS: ${{ env.POLL_DELAY_MS }}
with:
- github-token: ${{ secrets.GH_TOKEN }}
+ github-token: ${{ env.GH_TOKEN }}
script: |
const pr = parseInt(process.env.PR_NUMBER, 10);
const owner = context.repo.owner;
@@ -310,7 +434,7 @@ jobs:
PR_NUMBER: ${{ needs.copilot-after-qodo.outputs.pr_number }}
TRIGGER_LOGINS: ${{ env.CLAUDE_TRIGGER_LOGINS }}
with:
- github-token: ${{ secrets.GH_TOKEN }}
+ github-token: ${{ env.GH_TOKEN }}
script: |
const pr = parseInt(process.env.PR_NUMBER, 10);
const triggerLogins = JSON.parse(process.env.TRIGGER_LOGINS);
@@ -330,14 +454,16 @@ jobs:
env:
PR_NUMBER: ${{ needs.copilot-after-qodo.outputs.pr_number }}
with:
- github-token: ${{ secrets.GH_TOKEN }}
+ github-token: ${{ env.GH_TOKEN }}
script: |
const pr = parseInt(process.env.PR_NUMBER, 10);
+ const scope = process.env.CLAUDE_UI_SCOPE;
+ const phases = process.env.CLAUDE_FINAL_PHASES_DETAILED;
await github.rest.issues.createComment({
issue_number: pr,
owner: context.repo.owner,
repo: context.repo.repo,
- body: '@claude You are the FINAL architecture reviewer. Read ALL comments above from Gemini, Qodo, CodeRabbit, and Copilot carefully before responding.\n\n**Phase 1: Review per AGENTS.md**\n1. Protocol-driven: check heavy implementations vs core SDK\n2. Backward compatible: ensure zero feature regressions\n3. Performance: no hot-path regressions\n\n**Phase 2: FIX Valid Issues**\n4. For any VALID bugs or architectural flaws found by Gemini, CodeRabbit, Qodo, Copilot, or any other reviewer: implement the fix\n5. Push all code fixes directly to THIS branch (do NOT create a new PR)\n6. Comment a summary of exact files modified and what you skipped\n\n**Phase 3: Final Verdict**\n7. If all issues are resolved, approve the PR / close the Issue\n8. If blocking issues remain, request changes / leave clear action items'
+ body: '@claude You are the FINAL architecture reviewer. If the branch is under MervinPraison/PraisonAI-Plugins (not a fork), you are able to make modifications to this branch and push directly. ' + scope + ' Read ALL comments above from Gemini, Qodo, CodeRabbit, and Copilot carefully before responding.\n\n' + phases
});
console.log(`Claude triggered on PR #${pr}`);
@@ -365,7 +491,7 @@ jobs:
env:
TRIGGER_LOGINS: ${{ env.CLAUDE_TRIGGER_LOGINS }}
with:
- github-token: ${{ secrets.GH_TOKEN }}
+ github-token: ${{ env.GH_TOKEN }}
script: |
const triggerLogins = JSON.parse(process.env.TRIGGER_LOGINS);
const comments = await github.rest.issues.listComments({
@@ -382,64 +508,302 @@ jobs:
if: steps.check_claude.outputs.already_triggered != 'true'
uses: actions/github-script@v7
with:
- github-token: ${{ secrets.GH_TOKEN }}
+ github-token: ${{ env.GH_TOKEN }}
script: |
+ const scope = process.env.CLAUDE_UI_SCOPE;
+ const phases = process.env.CLAUDE_FINAL_PHASES_DETAILED;
await github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
- body: '@claude You are the Lead Engineer. Read ALL analysis and reviews above carefully (Gemini, CodeRabbit, Qodo, Copilot, etc).\n\n**Phase 1: Review per AGENTS.md**\n1. Protocol-driven: check heavy implementations vs core SDK\n2. Backward compatible: ensure zero feature regressions\n3. Performance: no hot-path regressions\n\n**Phase 2: FIX Valid Issues**\n4. For any VALID bugs or architectural flaws found by Gemini, CodeRabbit, Qodo, Copilot, or any other reviewer: implement the fix\n5. Push all code fixes directly to THIS branch (do NOT create a new PR)\n6. Comment a summary of exact files modified and what you skipped\n\n**Phase 3: Final Verdict**\n7. If all issues are resolved, approve the PR / close the Issue\n8. If blocking issues remain, request changes / leave clear action items'
+ body: '@claude You are the Lead Engineer. If the branch is under MervinPraison/PraisonAI-Plugins (not a fork), you are able to make modifications to this branch and push directly. ' + scope + ' Read ALL analysis and reviews above carefully (Gemini, CodeRabbit, Qodo, Copilot, etc).\n\n' + phases
});
# ================================================================
- # BOT PRs: CodeRabbit/Qodo skip bot-authored PRs by default.
- # Trigger them explicitly. Do NOT trigger Copilot here — it will
- # be triggered by copilot-after-coderabbit/qodo when they finish.
+ # Recovery: re-post FINAL @claude after push or when chain missed
# ================================================================
- bot-pr-trigger-reviews:
+ claude-review-recovery:
if: |
- github.event_name == 'pull_request' &&
- github.event.action == 'opened' &&
- (
- github.event.pull_request.user.login == 'github-actions[bot]' ||
- github.event.pull_request.user.login == 'praisonai-triage-agent[bot]' ||
- github.event.pull_request.user.type == 'Bot'
- )
+ github.event_name == 'pull_request_target' &&
+ github.event.action == 'synchronize'
runs-on: ubuntu-latest
permissions:
- pull-requests: write
+ issues: write
+ pull-requests: read
steps:
- - name: Trigger CodeRabbit, Qodo, and Gemini reviews
+ - uses: actions/checkout@v4
+
+ - name: Re-post FINAL Claude review when stale or missing
uses: actions/github-script@v7
with:
- github-token: ${{ secrets.GH_TOKEN }}
+ github-token: ${{ env.GH_TOKEN }}
script: |
+ const path = require('path');
+ const mergeGate = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/merge-gate.js'));
+ const chain = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pr-review-chain.js'));
const pr = context.payload.pull_request.number;
const owner = context.repo.owner;
const repo = context.repo.repo;
- // 1. Trigger CodeRabbit review
- await github.rest.issues.createComment({
- issue_number: pr, owner, repo,
- body: '@coderabbitai review'
+ const comments = await mergeGate.listAllComments(github, owner, repo, pr);
+ const headPushedAt = (await mergeGate.getHeadCommitDate(github, owner, repo, pr))
+ || context.payload.pull_request.updated_at;
+ const headPusher = context.payload.sender?.login || null;
+
+ const hasFinal = mergeGate.hasFinalClaudeReviewTrigger(comments);
+ const isStale = mergeGate.isStaleFinalAfterPush(comments, headPushedAt);
+
+ if (mergeGate.shouldSkipFinalRecovery(comments, headPushedAt)) {
+ core.info('Recent @claude within 35min and FINAL not stale — skip recovery');
+ return;
+ }
+
+ if (await mergeGate.hasInProgressClaudeAssistant(github, owner, repo, pr)) {
+ core.info('Claude workflow in progress — skip recovery');
+ return;
+ }
+
+ const finalBody = mergeGate.FINAL_CLAUDE_REVIEW_BODY;
+ const staleOpts = { allowStaleRepost: true, skipCopilot: true, optionalWaitMs: 0 };
+ const missingOpts = { allowCopilotTimeout: true, optionalWaitMs: 0 };
+
+ if (hasFinal && isStale) {
+ const gate = mergeGate.shouldSkipStaleFinalRecovery(comments, headPushedAt, headPusher);
+ if (gate.skip) {
+ core.info(`Skip stale-FINAL recovery: ${gate.reason}`);
+ return;
+ }
+ const result = await chain.maybeTriggerClaudeFinal(
+ github, owner, repo, pr, finalBody, core, staleOpts
+ );
+ if (result.posted) core.info(`Posted stale-FINAL recovery @claude on PR #${pr}`);
+ return;
+ }
+
+ if (hasFinal) {
+ core.info('FINAL Claude review current on HEAD — skip recovery');
+ return;
+ }
+
+ const result = await chain.maybeTriggerClaudeFinal(
+ github, owner, repo, pr, finalBody, core, missingOpts
+ );
+ if (result.posted) core.info(`Posted missing FINAL Claude review on PR #${pr}`);
+
+ - name: Sync pipeline status labels
+ uses: actions/github-script@v7
+ with:
+ github-token: ${{ env.GH_TOKEN }}
+ script: |
+ const path = require('path');
+ const ps = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pipeline-status.js'));
+ const pr = context.payload.pull_request.number;
+ await ps.ensurePipelineLabels(github, context.repo.owner, context.repo.repo, core);
+ await ps.syncPipelineLabels(github, context.repo.owner, context.repo.repo, pr, core);
+
+ # Scheduled + manual: post missing or stale FINAL @claude on all open PRs
+ post-missing-claude-final:
+ if: |
+ github.event_name == 'schedule' ||
+ (github.event_name == 'workflow_dispatch' && !inputs.pr_number)
+ runs-on: ubuntu-latest
+ permissions:
+ issues: write
+ pull-requests: read
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Post missing/stale FINAL @claude on open PRs
+ uses: actions/github-script@v7
+ with:
+ github-token: ${{ env.GH_TOKEN }}
+ script: |
+ const path = require('path');
+ const mergeGate = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/merge-gate.js'));
+ const chain = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pr-review-chain.js'));
+ const ps = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pipeline-status.js'));
+ const owner = context.repo.owner;
+ const repo = context.repo.repo;
+ const prs = await github.paginate(github.rest.pulls.list, {
+ owner, repo, state: 'open', per_page: 100,
});
- console.log(`Triggered CodeRabbit for bot PR #${pr}`);
+ let posted = 0;
+ for (const pr of prs) {
+ if (pr.draft) continue;
+ const comments = await mergeGate.listAllComments(github, owner, repo, pr.number);
+ const headPushedAt = (await mergeGate.getHeadCommitDate(github, owner, repo, pr.number)) || pr.updated_at;
+ const hasFinal = mergeGate.hasFinalClaudeReviewTrigger(comments);
+ const isStale = mergeGate.isStaleFinalAfterPush(comments, headPushedAt);
- // 2. Trigger Qodo review
+ if (hasFinal && !isStale) continue;
+ if (mergeGate.hasRecentClaudeTrigger(comments, 10)) {
+ core.info(`PR #${pr.number}: @claude within 10min — skip`);
+ continue;
+ }
+ if (await mergeGate.hasInProgressClaudeAssistant(github, owner, repo, pr.number)) {
+ core.info(`PR #${pr.number}: Claude in progress — skip`);
+ continue;
+ }
+ if (hasFinal && isStale) {
+ const gate = mergeGate.shouldSkipStaleFinalRecovery(comments, headPushedAt);
+ if (gate.skip) {
+ core.info(`PR #${pr.number}: skip stale — ${gate.reason}`);
+ continue;
+ }
+ }
+
+ const opts = hasFinal && isStale
+ ? { allowStaleRepost: true, skipCopilot: true, optionalWaitMs: 0 }
+ : { allowCopilotTimeout: true, optionalWaitMs: 0 };
+ const result = await chain.maybeTriggerClaudeFinal(
+ github, owner, repo, pr.number,
+ mergeGate.FINAL_CLAUDE_REVIEW_BODY, core, opts
+ );
+ if (result.posted) {
+ posted++;
+ core.info(`Posted Claude FINAL on PR #${pr.number}`);
+ }
+ await ps.syncPipelineLabels(github, owner, repo, pr.number, core);
+ if (posted >= 10) break;
+ }
+ core.info(`Missing/stale Claude FINAL sweep complete (${posted} posted)`);
+
+ # ================================================================
+ # BOT PRs: CodeRabbit → Claude directly (skip Copilot when no user PAT)
+ # Copilot ignores github-actions[bot] comments; bot PRs use this path.
+ # ================================================================
+ claude-after-coderabbit-bot-pr:
+ if: |
+ github.event_name == 'issue_comment' &&
+ github.event.issue.pull_request &&
+ github.event.comment.user.login == 'coderabbitai[bot]' &&
+ contains(github.event.comment.body, 'summarize by coderabbit')
+ runs-on: ubuntu-latest
+ permissions:
+ issues: write
+ pull-requests: read
+ steps:
+ - name: Trigger Claude final review on bot PR
+ uses: actions/github-script@v7
+ env:
+ TRIGGER_LOGINS: ${{ env.CLAUDE_TRIGGER_LOGINS }}
+ with:
+ github-token: ${{ env.GH_TOKEN }}
+ script: |
+ const pr = context.issue.number;
+ const { data: pull } = await github.rest.pulls.get({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ pull_number: pr,
+ });
+ const author = pull.user.login;
+ const isBot = author === 'github-actions[bot]' ||
+ author === 'praisonai-triage-agent[bot]' ||
+ pull.user.type === 'Bot';
+ if (!isBot) {
+ console.log('Human PR — Copilot chain handles Claude trigger');
+ return;
+ }
+ const triggerLogins = JSON.parse(process.env.TRIGGER_LOGINS);
+ const comments = await github.rest.issues.listComments({
+ issue_number: pr,
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ per_page: 100,
+ });
+ const alreadyPosted = comments.data.some(c =>
+ c.body.includes('@claude') && triggerLogins.includes(c.user.login)
+ );
+ if (alreadyPosted) {
+ console.log('Claude already triggered on PR #' + pr);
+ return;
+ }
await github.rest.issues.createComment({
- issue_number: pr, owner, repo,
- body: '/review'
+ issue_number: pr,
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ body: '@claude You are the FINAL architecture reviewer on this bot-authored PR. Push fixes directly to THIS branch (do NOT open a new PR). ' + process.env.CLAUDE_UI_SCOPE + ' Read ALL CodeRabbit/Qodo comments above.\n\n' + process.env.CLAUDE_FINAL_PHASES_COMPACT
});
- console.log(`Triggered Qodo for bot PR #${pr}`);
+ console.log('Claude final review triggered on bot PR #' + pr);
- // 3. Trigger Gemini review
+ # ================================================================
+ # Manual: trigger Claude final review on any PR
+ # ================================================================
+ claude-manual-dispatch:
+ if: github.event_name == 'workflow_dispatch' && inputs.pr_number
+ runs-on: ubuntu-latest
+ permissions:
+ issues: write
+ steps:
+ - name: Trigger Claude final review
+ uses: actions/github-script@v7
+ env:
+ PR_NUMBER: ${{ inputs.pr_number }}
+ TRIGGER_LOGINS: ${{ env.CLAUDE_TRIGGER_LOGINS }}
+ with:
+ github-token: ${{ env.GH_TOKEN }}
+ script: |
+ const pr = parseInt(process.env.PR_NUMBER, 10);
+ const triggerLogins = JSON.parse(process.env.TRIGGER_LOGINS);
+ const comments = await github.rest.issues.listComments({
+ issue_number: pr,
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ per_page: 100,
+ });
+ const alreadyPosted = comments.data.some(c =>
+ c.body.includes('@claude') && triggerLogins.includes(c.user.login)
+ );
+ if (alreadyPosted) {
+ console.log('Claude already triggered on PR #' + pr);
+ return;
+ }
await github.rest.issues.createComment({
- issue_number: pr, owner, repo,
- body: '@gemini review this PR'
+ issue_number: pr,
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ body: '@claude You are the FINAL architecture reviewer. If the branch is under MervinPraison/PraisonAI-Plugins (not a fork), you are able to make modifications to this branch and push directly. ' + process.env.CLAUDE_UI_SCOPE + ' Read all prior reviewer comments.\n\n' + process.env.CLAUDE_FINAL_PHASES_COMPACT
});
- console.log(`Triggered Gemini for bot PR #${pr}`);
+ console.log('Claude manually triggered on PR #' + pr);
+
+ # ================================================================
+ # BOT PRs: kick CodeRabbit + Qodo
+ # ================================================================
+ bot-pr-trigger-reviews:
+ if: |
+ github.event_name == 'pull_request' &&
+ github.event.action == 'opened' &&
+ (
+ github.event.pull_request.user.login == 'github-actions[bot]' ||
+ github.event.pull_request.user.login == 'praisonai-triage-agent[bot]' ||
+ github.event.pull_request.user.type == 'Bot'
+ )
+ runs-on: ubuntu-latest
+ permissions:
+ issues: write
+ pull-requests: write
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ persist-credentials: false
+
+ - name: Trigger CodeRabbit and Qodo reviews
+ uses: actions/github-script@v7
+ with:
+ github-token: ${{ env.GH_TOKEN }}
+ script: |
+ const path = require('path');
+ const chain = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/bot-pr-review-chain.js'));
+ const pr = context.payload.pull_request.number;
+ await chain.kickReviewChain(github, context.repo.owner, context.repo.repo, pr, core);
- // NOTE: @copilot is NOT triggered here.
- // It will be triggered by copilot-after-coderabbit or copilot-after-qodo
- // once those bots finish, ensuring Copilot always reviews LAST.
- console.log('Copilot will be triggered after CodeRabbit/Qodo complete.');
\ No newline at end of file
+ - name: Sync pipeline status labels
+ uses: actions/github-script@v7
+ with:
+ github-token: ${{ env.GH_TOKEN }}
+ script: |
+ const path = require('path');
+ const ps = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pipeline-status.js'));
+ const pr = context.payload.pull_request.number;
+ await ps.ensurePipelineLabels(github, context.repo.owner, context.repo.repo, core);
+ await ps.syncPipelineLabels(github, context.repo.owner, context.repo.repo, pr, core);
\ No newline at end of file
diff --git a/.github/workflows/ci-failure-claude.yml b/.github/workflows/ci-failure-claude.yml
new file mode 100644
index 0000000..fd0ee7f
--- /dev/null
+++ b/.github/workflows/ci-failure-claude.yml
@@ -0,0 +1,102 @@
+name: Auto CI Failure → Claude
+
+# Detects internal PRs with failing CI and posts @claude with extracted failure details.
+
+on:
+ workflow_run:
+ workflows: [CI]
+ types: [completed]
+ pull_request:
+ types: [opened, synchronize, reopened]
+ workflow_dispatch:
+ inputs:
+ pr_number:
+ description: 'Optional PR number to process (default: scan blocked PRs)'
+ required: false
+ type: string
+ schedule:
+ - cron: '15 */6 * * *'
+
+concurrency:
+ group: ci-failure-scan-${{ github.repository }}-${{ github.event.pull_request.number || github.event.workflow_run.id || github.run_id }}
+ cancel-in-progress: true
+
+env:
+ # PAT posts as MervinPraison so issue_comment triggers claude.yml (GITHUB_TOKEN cannot chain workflows)
+ GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }}
+
+permissions:
+ pull-requests: read
+ issues: write
+ actions: read
+
+jobs:
+ detect-and-trigger:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ persist-credentials: false
+
+ - name: Detect CI failures and trigger Claude
+ uses: actions/github-script@v7
+ with:
+ github-token: ${{ env.GH_TOKEN }}
+ script: |
+ const path = require('path');
+ const ciFix = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/ci-failure-claude.js'));
+ const owner = context.repo.owner;
+ const repo = context.repo.repo;
+ const baseRepo = `${owner}/${repo}`;
+
+ async function processPR(prNumber) {
+ return ciFix.maybeTriggerCiFixClaude(github, owner, repo, prNumber, core);
+ }
+
+ let results = [];
+
+ if (context.eventName === 'workflow_run') {
+ const wr = context.payload.workflow_run;
+ const result = await ciFix.processWorkflowRunFailure(
+ github, owner, repo, wr, core
+ );
+ results.push({ event: 'workflow_run', workflow: wr.name, ...result });
+ } else if (context.eventName === 'pull_request') {
+ results.push({
+ pr: context.payload.pull_request.number,
+ ...(await processPR(context.payload.pull_request.number)),
+ });
+ } else {
+ let prNumbers = [];
+ if (context.eventName === 'workflow_dispatch' && context.payload.inputs?.pr_number) {
+ prNumbers = [Number(context.payload.inputs.pr_number)];
+ } else {
+ const prs = await github.paginate(github.rest.pulls.list, {
+ owner,
+ repo,
+ state: 'open',
+ per_page: 100,
+ });
+ for (const pr of prs) {
+ if (pr.draft) continue;
+ const headRepo = pr.head?.repo?.full_name;
+ if (headRepo && headRepo !== baseRepo) continue;
+ const { data: issue } = await github.rest.issues.get({
+ owner,
+ repo,
+ issue_number: pr.number,
+ });
+ const labels = issue.labels.map((l) => l.name);
+ if (labels.includes('pipeline/blocked:ci') || labels.includes(ciFix.CI_FIX_LABEL)) {
+ prNumbers.push(pr.number);
+ }
+ }
+ }
+
+ for (const num of prNumbers) {
+ results.push({ pr: num, ...(await processPR(num)) });
+ }
+ }
+
+ core.summary.addRaw('```json\n' + JSON.stringify(results, null, 2) + '\n```');
+ await core.summary.write();
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..8a33d5c
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,34 @@
+name: CI
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ branches: [main]
+
+jobs:
+ python:
+ name: python
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ python-version: ["3.10", "3.11", "3.12"]
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up Python ${{ matrix.python-version }}
+ uses: actions/setup-python@v5
+ with:
+ python-version: ${{ matrix.python-version }}
+
+ - name: Install dependencies
+ run: |
+ pip install -e ".[dev]"
+ pip install ruff pytest-timeout
+
+ - name: Lint with ruff
+ run: ruff check src/praisonai_plugins tests
+
+ - name: Test with pytest
+ run: pytest tests/ -v --timeout=60
diff --git a/.github/workflows/claude-merge-gate.yml b/.github/workflows/claude-merge-gate.yml
new file mode 100644
index 0000000..a6c85e3
--- /dev/null
+++ b/.github/workflows/claude-merge-gate.yml
@@ -0,0 +1,531 @@
+name: Claude PR Merge Gate
+
+# Read-only Claude assesses merge readiness; GH_TOKEN merges (no @claude, no gh pr review).
+# Does not overlap with merge-conflict-claude.yml or auto-pr-comment @claude chains.
+
+on:
+ schedule:
+ # Scan for merge-ready PRs every 10 minutes (assess runs only when candidates exist).
+ - cron: '*/10 * * * *'
+ workflow_dispatch:
+ inputs:
+ pr_number:
+ description: 'Optional PR number (scan all open internal PRs if empty)'
+ required: false
+ type: string
+ merge_method:
+ description: 'Merge method'
+ required: false
+ type: choice
+ default: merge
+ options:
+ - merge
+ - squash
+ - rebase
+ workflow_run:
+ workflows:
+ - Claude Assistant
+ - CI
+ types:
+ - completed
+ repository_dispatch:
+ types:
+ - claude-merge-gate
+
+concurrency:
+ group: claude-merge-gate-${{ github.repository }}-${{ github.event.inputs.pr_number || github.event.client_payload.pr_number || github.event.workflow_run.id || 'scan' }}
+ cancel-in-progress: false
+
+env:
+ GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }}
+ MAX_CANDIDATES: 3
+ DISPATCH_PR_NUMBER: ${{ github.event.inputs.pr_number || github.event.client_payload.pr_number || '' }}
+
+permissions:
+ contents: read
+ pull-requests: read
+ issues: read
+ actions: read
+
+jobs:
+ auto-dispatch:
+ if: github.event_name == 'workflow_run'
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ pull-requests: read
+ issues: read
+ actions: write
+ id-token: write
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Generate GitHub App token
+ id: app-token
+ uses: actions/create-github-app-token@v1
+ with:
+ app-id: ${{ secrets.CLAUDE_APP_ID }}
+ private-key: ${{ secrets.CLAUDE_APP_PRIVATE_KEY }}
+ owner: ${{ github.repository_owner }}
+
+ - name: Dispatch merge gate when PR pipeline is ready
+ uses: actions/github-script@v7
+ with:
+ github-token: ${{ steps.app-token.outputs.token }}
+ script: |
+ const path = require('path');
+ const mergeGate = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/merge-gate.js'));
+ const wr = context.payload.workflow_run;
+ const owner = context.repo.owner;
+ const repo = context.repo.repo;
+
+ if (wr.conclusion !== 'success') {
+ core.info(`Skip dispatch: ${wr.name} conclusion=${wr.conclusion}`);
+ return;
+ }
+ const allowed = ['Claude Assistant', 'CI'];
+ if (!allowed.includes(wr.name)) {
+ core.info(`Skip dispatch: workflow ${wr.name}`);
+ return;
+ }
+
+ const prNumber = await mergeGate.resolvePrNumberFromWorkflowRun(github, owner, repo, wr);
+ if (!prNumber) {
+ core.info('Skip dispatch: could not resolve PR from workflow run');
+ return;
+ }
+
+ const fromClaude = wr.name === 'Claude Assistant';
+ const check = await mergeGate.evaluatePipelineQuiescent(
+ github, owner, repo, prNumber, core, {
+ skipGlobalClaudeRunCheck: fromClaude,
+ skipRecentClaudeCooldown: fromClaude,
+ }
+ );
+ if (!check.ready) {
+ core.info(`PR #${prNumber} not ready: ${check.reasons.join(', ')}`);
+ return;
+ }
+
+ await github.rest.repos.createDispatchEvent({
+ owner,
+ repo,
+ event_type: 'claude-merge-gate',
+ client_payload: { pr_number: prNumber },
+ });
+ core.info(`Dispatched merge gate for PR #${prNumber} after ${wr.name}`);
+
+ scan-candidates:
+ if: github.event_name != 'workflow_run'
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ pull-requests: read
+ issues: write
+ actions: read
+ id-token: write
+ outputs:
+ matrix: ${{ steps.scan.outputs.matrix }}
+ has_candidates: ${{ steps.scan.outputs.has_candidates }}
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Generate GitHub App token
+ id: app-token
+ uses: actions/create-github-app-token@v1
+ with:
+ app-id: ${{ secrets.CLAUDE_APP_ID }}
+ private-key: ${{ secrets.CLAUDE_APP_PRIVATE_KEY }}
+ owner: ${{ github.repository_owner }}
+
+ - name: Scan open PRs for merge gate eligibility
+ id: scan
+ uses: actions/github-script@v7
+ with:
+ github-token: ${{ steps.app-token.outputs.token }}
+ script: |
+ const path = require('path');
+ const mergeGate = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/merge-gate.js'));
+ const ps = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pipeline-status.js'));
+ const owner = context.repo.owner;
+ const repo = context.repo.repo;
+ const maxCandidates = parseInt(process.env.MAX_CANDIDATES || '5', 10);
+ const inputPr = String(process.env.DISPATCH_PR_NUMBER || '').trim();
+
+ const candidates = [];
+ const skipped = [];
+
+ if (inputPr) {
+ const n = parseInt(inputPr, 10);
+ if (Number.isNaN(n)) {
+ throw new Error(`Invalid pr_number input: ${inputPr}`);
+ }
+ const result = await mergeGate.evaluatePipelineQuiescent(github, owner, repo, n, core);
+ if (result.ready) {
+ candidates.push({ pr_number: n, head_sha: result.headSha });
+ } else {
+ skipped.push({ pr: n, reasons: result.reasons });
+ }
+ } else {
+ const prNumbers = await mergeGate.listPrNumbersForMergeGateScan(
+ github, owner, repo, core
+ );
+ const selected = await mergeGate.selectMergeGateCandidates(
+ github, owner, repo, prNumbers, maxCandidates, core
+ );
+ candidates.push(...selected.candidates);
+ skipped.push(...selected.skipped);
+ }
+
+ core.info('Skipped: ' + JSON.stringify(skipped));
+ core.setOutput('matrix', JSON.stringify({ include: candidates }));
+ core.setOutput('has_candidates', candidates.length > 0 ? 'true' : 'false');
+
+ core.summary.addRaw('## Merge gate scan\n');
+ core.summary.addRaw(`Candidates: ${candidates.length}\n`);
+ core.summary.addRaw('```json\n' + JSON.stringify({ candidates, skipped }, null, 2) + '\n```');
+ await core.summary.write();
+
+ if (inputPr && skipped.length === 1 && skipped[0].pr === parseInt(inputPr, 10)) {
+ const reasons = skipped[0].reasons;
+ const body = [
+ '**Merge gate scan** — not eligible for auto-merge.',
+ '',
+ ...reasons.map((r) => `- \`${r}\``),
+ '',
+ '**Actions:** wait for CI and the Claude review chain, or add label `needs-manual-review` and merge manually.',
+ '**Opt out:** label `no-auto-merge`.',
+ ].join('\n');
+ await github.rest.issues.createComment({
+ owner, repo, issue_number: skipped[0].pr, body,
+ });
+ } else if (inputPr && candidates.length === 1) {
+ await github.rest.issues.createComment({
+ owner, repo, issue_number: candidates[0].pr_number,
+ body: '**Merge gate scan** — eligible for assessment. Claude merge gate will assess and may auto-merge if `MERGE_GATE_VERDICT: APPROVE`.',
+ });
+ }
+
+ await ps.syncOpenPullRequests(github, owner, repo, { maxPrs: 10 }, core);
+
+ claude-assess:
+ needs: scan-candidates
+ if: needs.scan-candidates.outputs.has_candidates == 'true'
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ strategy:
+ fail-fast: false
+ matrix: ${{ fromJson(needs.scan-candidates.outputs.matrix) }}
+ permissions:
+ contents: read
+ pull-requests: read
+ issues: write
+ actions: read
+ id-token: write
+ steps:
+ - name: Generate GitHub App Token
+ id: app-token
+ uses: actions/create-github-app-token@v1
+ with:
+ app-id: ${{ secrets.CLAUDE_APP_ID }}
+ private-key: ${{ secrets.CLAUDE_APP_PRIVATE_KEY }}
+ owner: ${{ github.repository_owner }}
+
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+ token: ${{ steps.app-token.outputs.token }}
+
+ - name: Mark merge gate active
+ id: mark_active
+ uses: actions/github-script@v7
+ with:
+ github-token: ${{ steps.app-token.outputs.token }}
+ script: |
+ core.setOutput('started_at', new Date().toISOString());
+ await github.rest.issues.addLabels({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: ${{ matrix.pr_number }},
+ labels: ['claude-merge-gate-active'],
+ });
+
+ - name: Stash merge gate helpers
+ run: cp .github/scripts/merge-gate.js /tmp/merge-gate.js
+
+ - name: Fetch PR branch
+ env:
+ PR_NUMBER: ${{ matrix.pr_number }}
+ run: |
+ git fetch origin "pull/${PR_NUMBER}/head:pr-${PR_NUMBER}"
+ git checkout "pr-${PR_NUMBER}"
+
+ - name: Claude merge gate assessment (read-only)
+ id: assess
+ uses: ./.github/actions/claude-code-action
+ env:
+ GH_TOKEN: ${{ steps.app-token.outputs.token }}
+ CLAUDE_CODE_SUBAGENT_MODEL: inherit
+ ANTHROPIC_DEFAULT_SONNET_MODEL: claude-sonnet-4-6
+ with:
+ claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
+ github_token: ${{ steps.app-token.outputs.token }}
+ model: claude-opus-4-8
+ trigger_phrase: '@claude-merge-gate-internal'
+ direct_prompt: |
+ You are the MERGE GATE reviewer for PraisonAI PR #${{ matrix.pr_number }}.
+ READ-ONLY: do not modify code, commit, push, rebase, or create branches.
+
+ CRITICAL OUTPUT RULE:
+ Post exactly one PR comment via GitHub MCP containing either:
+ MERGE_GATE_VERDICT: APPROVE
+ or
+ MERGE_GATE_VERDICT: BLOCK
+ followed by a short rationale on the next lines.
+
+ NEVER include @claude in any comment. NEVER mention merge conflict resolution.
+
+ Review checklist:
+ 1. Read AGENTS.md and the PR diff on the current branch.
+ 2. Read all PR comments and reviews (FINAL architecture reviewer / Lead Engineer must have run).
+ 3. Confirm SDK value gate: change strengthens praisonaiagents (no scope creep).
+ 4. Do not bloat the Agent class with additional params — only if absolutely required; we already support many params.
+ 5. If agent.py adds >100 lines or new Agent __init__ params, respond MERGE_GATE_VERDICT: BLOCK (manual review required).
+ 6. BLOCK if labels security/breaking-change/needs-manual-review/release, sensitive paths (.github/workflows, auth, pyproject.toml), secrets in diff, PR >800 lines or >30 files, or SDK changes without tests. Label `merge-gate-ci-only` exempts CI-only changes under `.github/workflows/`, `.github/actions/`, or merge-gate scripts.
+ 7. Confirm no CHANGES_REQUESTED reviews and CI green on HEAD ${{ matrix.head_sha }}.
+ 8. If ready to merge: MERGE_GATE_VERDICT: APPROVE. Otherwise: MERGE_GATE_VERDICT: BLOCK with reasons.
+
+ Do not approve or merge via GitHub UI/API — only post the verdict comment.
+ disallowed_tools: |
+ Bash
+ Edit
+ Write
+ Replace
+ allowed_tools: |
+ View
+ GlobTool
+ GrepTool
+ Read
+ timeout_minutes: 15
+
+ - name: Post fallback BLOCK if Opus did not comment
+ id: fallback_verdict
+ if: steps.assess.outcome != 'success'
+ uses: actions/github-script@v7
+ with:
+ github-token: ${{ steps.app-token.outputs.token }}
+ script: |
+ const mergeGate = require('/tmp/merge-gate.js');
+ const owner = context.repo.owner;
+ const repo = context.repo.repo;
+ const prNumber = ${{ matrix.pr_number }};
+
+ const ctx = await mergeGate.loadPrContext(github, owner, repo, prNumber);
+ const minCreatedAt = new Date(Date.now() - 25 * 60 * 1000).toISOString();
+ const existing = mergeGate.findMergeGateVerdict(
+ ctx.comments, minCreatedAt, ctx.headPushedAt, { excludeAutomatedFallback: true }
+ );
+ if (existing) {
+ core.info(`Opus verdict already posted: ${existing}`);
+ return;
+ }
+
+ const check = await mergeGate.evaluatePipelineQuiescent(
+ github, owner, repo, prNumber, core, { forMergeStep: true }
+ );
+ const lines = [
+ 'MERGE_GATE_VERDICT: BLOCK',
+ '',
+ 'Automated fallback — Opus merge gate assessment did not complete.',
+ ];
+ if (check.ready) {
+ lines.push('Deterministic gates passed, but Opus MERGE_GATE_VERDICT is required before merge.');
+ } else {
+ lines.push('Blockers:', ...check.reasons.map((r) => `- ${r}`));
+ }
+ await github.rest.issues.createComment({
+ owner, repo, issue_number: prNumber,
+ body: lines.join('\n'),
+ });
+ core.info('Posted fallback BLOCK — Opus verdict required');
+
+ - name: Require Opus MERGE_GATE_VERDICT
+ uses: actions/github-script@v7
+ with:
+ github-token: ${{ steps.app-token.outputs.token }}
+ script: |
+ const mergeGate = require('/tmp/merge-gate.js');
+ const comments = await mergeGate.listAllComments(
+ github, context.repo.owner, context.repo.repo, ${{ matrix.pr_number }}
+ );
+ const ctx = await mergeGate.loadPrContext(
+ github, context.repo.owner, context.repo.repo, ${{ matrix.pr_number }}
+ );
+ const minCreatedAt = new Date(Date.now() - 25 * 60 * 1000).toISOString();
+ const verdict = mergeGate.findMergeGateVerdict(
+ comments, minCreatedAt, ctx.headPushedAt, { excludeAutomatedFallback: true }
+ );
+ if (!verdict) {
+ core.setFailed(
+ 'Opus merge gate assessment must post MERGE_GATE_VERDICT on this PR (automated fallback APPROVE is not accepted).'
+ );
+ return;
+ }
+ core.info(`Opus merge gate verdict: ${verdict}`);
+ core.setOutput('verdict', verdict);
+
+ - name: Remove merge gate active label
+ if: always()
+ uses: actions/github-script@v7
+ with:
+ github-token: ${{ steps.app-token.outputs.token }}
+ script: |
+ const prNumber = ${{ matrix.pr_number }};
+ try {
+ await github.rest.issues.removeLabel({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: prNumber,
+ name: 'claude-merge-gate-active',
+ });
+ core.info(`Removed claude-merge-gate-active from PR #${prNumber}`);
+ } catch (e) {
+ core.info(`Label claude-merge-gate-active already removed or missing on PR #${prNumber}`);
+ }
+
+ - name: Fail job if assessment failed
+ if: steps.assess.outcome == 'failure'
+ run: exit 1
+
+ merge-only:
+ needs: [scan-candidates, claude-assess]
+ if: |
+ always() &&
+ needs.scan-candidates.outputs.has_candidates == 'true' &&
+ needs.claude-assess.result == 'success'
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix: ${{ fromJson(needs.scan-candidates.outputs.matrix) }}
+ permissions:
+ contents: write
+ pull-requests: write
+ issues: write
+ actions: read
+ id-token: write
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Generate GitHub App token
+ id: app-token
+ uses: actions/create-github-app-token@v1
+ with:
+ app-id: ${{ secrets.CLAUDE_APP_ID }}
+ private-key: ${{ secrets.CLAUDE_APP_PRIVATE_KEY }}
+ owner: ${{ github.repository_owner }}
+
+ - name: Re-check gates and merge
+ uses: actions/github-script@v7
+ env:
+ MERGE_METHOD: ${{ github.event.inputs.merge_method || 'merge' }}
+ SCAN_HEAD_SHA: ${{ matrix.head_sha }}
+ with:
+ github-token: ${{ steps.app-token.outputs.token }}
+ script: |
+ const path = require('path');
+ const mergeGate = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/merge-gate.js'));
+ const owner = context.repo.owner;
+ const repo = context.repo.repo;
+ const prNumber = ${{ matrix.pr_number }};
+ const scanHeadSha = process.env.SCAN_HEAD_SHA;
+
+ const removeLabels = async () => {
+ try {
+ await github.rest.issues.removeLabel({
+ owner, repo, issue_number: prNumber, name: 'claude-merge-gate-active',
+ });
+ } catch (e) {
+ core.info('Label claude-merge-gate-active already removed or missing');
+ }
+ };
+
+ try {
+ const ctx = await mergeGate.loadPrContext(github, owner, repo, prNumber);
+ const comments = ctx.comments;
+ const verdictMin = new Date(Date.now() - 25 * 60 * 1000).toISOString();
+ const verdict = mergeGate.findMergeGateVerdict(
+ comments, verdictMin, ctx.headPushedAt, { excludeAutomatedFallback: true }
+ );
+ if (verdict !== 'APPROVE') {
+ core.info(`Skip merge: verdict=${verdict || 'NONE'}`);
+ await removeLabels();
+ return;
+ }
+
+ const check = await mergeGate.evaluatePipelineQuiescent(
+ github, owner, repo, prNumber, core, { forMergeStep: true }
+ );
+ if (!check.ready) {
+ core.info(`Skip merge: ${check.reasons.join(', ')}`);
+ await removeLabels();
+ return;
+ }
+
+ if (check.headSha !== scanHeadSha) {
+ core.info(`Skip merge: HEAD moved ${scanHeadSha.slice(0, 7)} → ${check.headSha.slice(0, 7)}`);
+ await removeLabels();
+ return;
+ }
+
+ const method = process.env.MERGE_METHOD || 'merge';
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+ let merged = false;
+ for (let attempt = 1; attempt <= 3; attempt++) {
+ try {
+ await github.rest.pulls.merge({
+ owner, repo, pull_number: prNumber, merge_method: method,
+ });
+ merged = true;
+ break;
+ } catch (e) {
+ const msg = (e.message || '').toLowerCase();
+ if (attempt < 3 && msg.includes('base branch was modified')) {
+ core.info(`Merge attempt ${attempt} failed (base moved), retrying in 8s...`);
+ await sleep(8000);
+ const recheck = await mergeGate.evaluatePipelineQuiescent(
+ github, owner, repo, prNumber, core, { forMergeStep: true }
+ );
+ if (!recheck.ready) {
+ core.info(`Skip merge retry: ${recheck.reasons.join(', ')}`);
+ await removeLabels();
+ return;
+ }
+ continue;
+ }
+ throw e;
+ }
+ }
+ if (!merged) {
+ core.info('Skip merge: exhausted retries');
+ await removeLabels();
+ return;
+ }
+
+ await github.rest.issues.addLabels({
+ owner, repo, issue_number: prNumber, labels: ['auto-merged-by-gate'],
+ });
+
+ await github.rest.issues.createComment({
+ owner, repo, issue_number: prNumber,
+ body: [
+ 'Merged by **Claude PR merge gate** (`claude-merge-gate.yml`).',
+ `Verdict: MERGE_GATE_VERDICT: APPROVE`,
+ `SHA: \`${check.headSha.slice(0, 7)}\``,
+ `Method: ${method}`,
+ ].join('\n'),
+ });
+
+ core.info(`Merged PR #${prNumber}`);
+ } finally {
+ await removeLabels();
+ }
diff --git a/.github/workflows/nightly-release-gate.yml b/.github/workflows/nightly-release-gate.yml
new file mode 100644
index 0000000..1ee0c34
--- /dev/null
+++ b/.github/workflows/nightly-release-gate.yml
@@ -0,0 +1,129 @@
+name: Nightly Release Gate
+
+# Preflight before PyPI Release (patch bump).
+# Primary: CI success on main (workflow_run).
+# Backstop: nightly cron at 00:00 UTC.
+
+on:
+ schedule:
+ - cron: '0 0 * * *'
+ workflow_dispatch:
+ inputs:
+ dry_run:
+ description: 'Run preflight only; do not dispatch pypi-release.yml'
+ required: false
+ default: false
+ type: boolean
+ workflow_run:
+ workflows:
+ - CI
+ types:
+ - completed
+ branches:
+ - main
+
+permissions:
+ contents: read
+ actions: write
+
+concurrency:
+ group: nightly-release-gate
+ cancel-in-progress: false
+
+jobs:
+ preflight:
+ if: |
+ github.event_name == 'workflow_dispatch' ||
+ github.event_name == 'schedule' ||
+ (
+ github.event_name == 'workflow_run' &&
+ github.event.workflow_run.conclusion == 'success' &&
+ github.event.workflow_run.head_branch == 'main' &&
+ github.event.workflow_run.event == 'push'
+ )
+ runs-on: ubuntu-latest
+ env:
+ GH_TOKEN: ${{ secrets.GH_TOKEN }}
+ steps:
+ - name: Resolve trigger context
+ id: ctx
+ run: |
+ if [ "${{ github.event_name }}" = "workflow_run" ]; then
+ echo "checkout_sha=${{ github.event.workflow_run.head_sha }}" >> "$GITHUB_OUTPUT"
+ echo "source=ci" >> "$GITHUB_OUTPUT"
+ echo "is_ci=true" >> "$GITHUB_OUTPUT"
+ else
+ echo "checkout_sha=main" >> "$GITHUB_OUTPUT"
+ echo "source=nightly" >> "$GITHUB_OUTPUT"
+ echo "is_ci=false" >> "$GITHUB_OUTPUT"
+ fi
+
+ - name: Checkout
+ uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
+ with:
+ ref: ${{ steps.ctx.outputs.checkout_sha }}
+ fetch-depth: 0
+
+ - name: Fetch main and tags
+ run: git fetch --tags origin main
+
+ - name: Preflight — path changes, CI SHA, dedupe
+ id: preflight
+ uses: actions/github-script@v7
+ env:
+ HEAD_SHA: ${{ steps.ctx.outputs.checkout_sha }}
+ IS_CI: ${{ steps.ctx.outputs.is_ci }}
+ with:
+ github-token: ${{ secrets.GH_TOKEN }}
+ script: |
+ const path = require('path');
+ const rg = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/release-gate.js'));
+ const owner = context.repo.owner;
+ const repo = context.repo.repo;
+ const headSha = process.env.HEAD_SHA;
+ const isCi = process.env.IS_CI === 'true';
+
+ const result = await rg.evaluateReleasePreflight(
+ github, owner, repo,
+ { headSha, isCiTrigger: isCi, bump: 'patch' },
+ core
+ );
+
+ core.setOutput('should_release', result.ready ? 'true' : 'false');
+ core.setOutput('reason', result.reasons.join('; '));
+ core.setOutput('head_sha', result.headSha || headSha);
+ core.setOutput('last_tag', result.lastTag || '');
+ core.setOutput('target_version', result.targetVersion || '');
+
+ core.summary.addRaw('## Release gate preflight\n');
+ core.summary.addRaw(`Ready: ${result.ready}\n`);
+ core.summary.addRaw(`Reason: ${result.reasons.join('; ')}\n`);
+ if (result.targetVersion) {
+ core.summary.addRaw(`Target: package==${result.targetVersion}\n`);
+ }
+ await core.summary.write();
+
+ if (!result.ready) {
+ core.info(`Skip release: ${result.reasons.join('; ')}`);
+ }
+
+ - name: Dry run summary
+ if: inputs.dry_run == true
+ run: |
+ echo "Dry run — preflight only, no pypi-release dispatch."
+ echo "should_release=${{ steps.preflight.outputs.should_release }}"
+ echo "reason=${{ steps.preflight.outputs.reason }}"
+
+ - name: Dispatch PyPI Release (patch)
+ if: steps.preflight.outputs.should_release == 'true' && inputs.dry_run != true
+ run: |
+ set -euo pipefail
+ gh workflow run pypi-release.yml \
+ --repo "${GITHUB_REPOSITORY}" \
+ --ref main \
+ -f bump=patch \
+ -f source=${{ steps.ctx.outputs.source }} \
+ -f trigger_sha=${{ steps.preflight.outputs.head_sha }} \
+ -f dry_run=false
+ echo "Dispatched pypi-release.yml (patch, source=${{ steps.ctx.outputs.source }})"
+ echo "Uses pypi-auto environment for ci/nightly patch releases."
diff --git a/.github/workflows/pipeline-status-sync.yml b/.github/workflows/pipeline-status-sync.yml
new file mode 100644
index 0000000..0a6e3bf
--- /dev/null
+++ b/.github/workflows/pipeline-status-sync.yml
@@ -0,0 +1,42 @@
+name: Pipeline Status Sync
+
+# Sync pipeline/* stage and blocker labels on open internal PRs.
+
+on:
+ schedule:
+ - cron: '*/10 * * * *'
+ workflow_dispatch:
+
+permissions:
+ pull-requests: read
+ issues: write
+ actions: write
+ id-token: write
+
+jobs:
+ sync:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ persist-credentials: false
+
+ - name: Generate GitHub App token
+ id: app-token
+ uses: actions/create-github-app-token@v1
+ with:
+ app-id: ${{ secrets.CLAUDE_APP_ID }}
+ private-key: ${{ secrets.CLAUDE_APP_PRIVATE_KEY }}
+ owner: ${{ github.repository_owner }}
+
+ - name: Sync pipeline labels on open PRs
+ uses: actions/github-script@v7
+ with:
+ github-token: ${{ steps.app-token.outputs.token }}
+ script: |
+ const path = require('path');
+ const ps = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pipeline-status.js'));
+ await ps.syncOpenPullRequests(
+ github, context.repo.owner, context.repo.repo,
+ { maxPrs: 30, dispatchMergeGate: true }, core
+ );
diff --git a/.github/workflows/pypi-release.yml b/.github/workflows/pypi-release.yml
new file mode 100644
index 0000000..f238f6e
--- /dev/null
+++ b/.github/workflows/pypi-release.yml
@@ -0,0 +1,135 @@
+name: PyPI Release
+
+on:
+ workflow_dispatch:
+ inputs:
+ bump:
+ description: 'Version bump type'
+ required: true
+ default: patch
+ type: choice
+ options: [patch, minor, major]
+ dry_run:
+ description: 'Preview only; do not publish'
+ required: false
+ default: false
+ type: boolean
+ version:
+ description: 'Override version (empty = auto bump)'
+ required: false
+ default: ''
+ type: string
+ source:
+ description: 'Trigger source (audit trail)'
+ required: false
+ default: manual
+ type: string
+ trigger_sha:
+ description: 'SHA that triggered this release'
+ required: false
+ default: ''
+ type: string
+
+permissions:
+ contents: write
+
+concurrency:
+ group: pypi-release
+ cancel-in-progress: false
+
+jobs:
+ release:
+ if: github.ref == 'refs/heads/main'
+ runs-on: ubuntu-latest
+ environment: >-
+ ${{
+ (inputs.source == 'ci' || inputs.source == 'nightly') &&
+ inputs.bump == 'patch' &&
+ 'pypi-auto' || 'pypi'
+ }}
+ env:
+ PACKAGE: praisonai-plugins
+ GH_TOKEN: ${{ secrets.GH_TOKEN }}
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ ref: main
+ fetch-depth: 0
+ token: ${{ secrets.GH_TOKEN }}
+
+ - uses: actions/setup-python@v5
+ with:
+ python-version: '3.11'
+
+ - uses: astral-sh/setup-uv@v4
+
+ - name: Configure git
+ run: |
+ git config user.name "MervinPraison"
+ git config user.email "454862+MervinPraison@users.noreply.github.com"
+ gh auth setup-git
+
+ - name: Compute release version
+ id: versions
+ env:
+ BUMP: ${{ inputs.bump }}
+ VERSION_OVERRIDE: ${{ inputs.version }}
+ run: |
+ python <<'PY'
+ import os, re, sys
+ from pathlib import Path
+ bump = os.environ["BUMP"]
+ override = os.environ.get("VERSION_OVERRIDE", "").strip()
+ content = Path("pyproject.toml").read_text()
+ match = re.search(r'^version\s*=\s*"([^"]+)"', content, re.MULTILINE)
+ if not match:
+ sys.exit("Could not read version from pyproject.toml")
+ current = match.group(1)
+ maj, min_, patch = map(int, current.split("."))
+ if override:
+ new_version = override
+ elif bump == "major":
+ new_version = f"{maj + 1}.0.0"
+ elif bump == "minor":
+ new_version = f"{maj}.{min_ + 1}.0"
+ else:
+ new_version = f"{maj}.{min_}.{patch + 1}"
+ with open(os.environ["GITHUB_OUTPUT"], "a") as fh:
+ fh.write(f"current_version={current}\nnew_version={new_version}\n")
+ PY
+
+ - name: Bump pyproject.toml
+ if: inputs.dry_run != true
+ env:
+ CURRENT: ${{ steps.versions.outputs.current_version }}
+ NEW_VERSION: ${{ steps.versions.outputs.new_version }}
+ run: |
+ python -c "
+ from pathlib import Path
+ import os
+ p = Path('pyproject.toml')
+ p.write_text(p.read_text().replace(f'version = \"{os.environ[\"CURRENT\"]}\"', f'version = \"{os.environ[\"NEW_VERSION\"]}\"', 1))
+ "
+
+ - name: Publish to PyPI
+ if: inputs.dry_run != true
+ env:
+ UV_PUBLISH_TOKEN: ${{ secrets.PYPI_TOKEN || secrets.PYPI_API_TOKEN }}
+ run: |
+ uv lock
+ uv build
+ uv publish
+
+ - name: Commit, tag, and release
+ if: inputs.dry_run != true
+ env:
+ VERSION: ${{ steps.versions.outputs.new_version }}
+ run: |
+ TAG="v${VERSION}"
+ git add pyproject.toml uv.lock
+ git commit -m "Release ${TAG}" || exit 0
+ git tag -f "${TAG}"
+ git pull --rebase origin main
+ git push origin main
+ git push --tags -f
+ gh release create "${TAG}" --title "praisonai-plugins ${TAG}" --notes "Release ${TAG}" --latest
diff --git a/.github/workflows/sync-secrets-from-praisonai.yml b/.github/workflows/sync-secrets-from-praisonai.yml
new file mode 100644
index 0000000..c4a0377
--- /dev/null
+++ b/.github/workflows/sync-secrets-from-praisonai.yml
@@ -0,0 +1,21 @@
+name: Sync Claude Secrets from PraisonAI
+
+on:
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ trigger-sync:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Trigger PraisonAI secret sync
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ gh workflow run sync-secrets-to-aiui.yml \
+ --repo MervinPraison/PraisonAI \
+ --ref main \
+ -f target_repo=MervinPraison/PraisonAI-Plugins
+ echo "Triggered secret sync → MervinPraison/PraisonAI-Plugins"
diff --git a/src/praisonai_plugins/integrations/slack_integration.py b/src/praisonai_plugins/integrations/slack_integration.py
index 3f061b0..e52737f 100644
--- a/src/praisonai_plugins/integrations/slack_integration.py
+++ b/src/praisonai_plugins/integrations/slack_integration.py
@@ -3,7 +3,7 @@
"""
from praisonaiagents.plugins.plugin import Plugin, PluginInfo
from praisonaiagents._logging import get_logger
-from typing import Dict, Any, Optional
+from typing import Dict, Any
logger = get_logger(__name__)
diff --git a/src/praisonai_plugins/observability/gateway_forensics.py b/src/praisonai_plugins/observability/gateway_forensics.py
index a745e7a..feb6ec1 100644
--- a/src/praisonai_plugins/observability/gateway_forensics.py
+++ b/src/praisonai_plugins/observability/gateway_forensics.py
@@ -25,7 +25,7 @@
import signal
import threading
import time
-from typing import Any, Dict, List, Optional
+from typing import Any, Dict, Optional
from praisonaiagents.plugins.plugin import Plugin, PluginInfo, PluginHook
from praisonaiagents._logging import get_logger
diff --git a/src/praisonai_plugins/policies/strict_policy.py b/src/praisonai_plugins/policies/strict_policy.py
index 5db5faa..411a5f3 100644
--- a/src/praisonai_plugins/policies/strict_policy.py
+++ b/src/praisonai_plugins/policies/strict_policy.py
@@ -2,7 +2,7 @@
Policy Plugin for PraisonAI Agents.
"""
from praisonaiagents.plugins.plugin import Plugin, PluginInfo
-from typing import Dict, Any, Optional
+from typing import Optional
class StrictTypingPolicyPlugin(Plugin):
"""