Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions .ai/agent.adf
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ ADF: 0.1
- groq.ts: askGroq() — fetch-based, no SDK dependency
- github.ts: GitHub REST API client — fetch-based, no SDK
- Read: getRepoTree, getFileContent, getRecentCommits, listIssues, listPullRequests, listOrgRepos
- Write: createIssue, createBranch, getFileSha, updateFile, createPullRequest, mergePullRequest
- Write: createIssue, createBranch, getFileSha, updateFile, createPullRequest
- CI: listWorkflowRuns (Actions API), getCombinedStatus (Status API)
- Git Data API: createBlob, getBaseTreeSha, createTree, createGitCommit, updateRef (multi-file commits)
- ghFetch: GitHubApiError class, retry on 500/502/503 (2x exponential backoff), rate limit detection (403/429)
Expand Down Expand Up @@ -93,7 +93,6 @@ ADF: 0.1
- create_improvement_issue: POST /issues + addAgendaItem mirror
- create_improvement_pr: single-file: createBranch → updateFile → createPullRequest + addAgendaItem(high)
- create_multi_file_pr: multi-file: createBranch → createBlob(each) → createTree → createGitCommit → updateRef → createPullRequest + addAgendaItem(high)
- merge_pull_request: PUT /pulls/{n}/merge (squash/merge/rebase) + auto-resolve matching agenda item

Classification: 'self_improvement' → claude executor (via DEFAULT_ROUTES in router.ts)

Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# Changelog

## 0.10.1 (2026-10-16)

### Removed
- **The `merge_pull_request` LLM tool.** It was in `GITHUB_TOOLS`, so every tool-using dispatch offered it, including the scheduled goal loop, whose goals read issues, PR bodies and comments anyone can write. Only prompt text told the model not to merge, so an injected "merge PR #N" could merge with no person involved. `mergePullRequest()` is removed from `github.ts` too, so nothing in core can merge a PR. Merge on GitHub.

### Fixed
- The dashboard's PR link, the daily digest email, the re-entry briefing, the reflection email and decision-doc timelines showed `pr_url` with the `/closed` (or older `/merged`) tracking suffix the PR heartbeats append, which 404s on GitHub. They now strip it (`lib/pr-url.ts`).

## 0.10.0 (2026-10-16)

### Breaking
Expand Down
2 changes: 1 addition & 1 deletion web/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@stackbilt/aegis-core",
"version": "0.10.0",
"version": "0.10.1",
"description": "Persistent AI agent framework for Cloudflare Workers. Multi-tier memory, autonomous goals, dreaming cycles, MCP native.",
"license": "Apache-2.0",
"publishConfig": {
Expand Down
32 changes: 3 additions & 29 deletions web/src/claude-tools/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ import {
updateRef,
listWorkflowRuns,
getCombinedStatus,
mergePullRequest,
resolveRepoName,
} from '../github.js';
import { addAgendaItem } from '../kernel/memory/index.js';
Expand Down Expand Up @@ -190,20 +189,9 @@ const TRIGGER_WORKER_DEPLOY_TOOL = {
},
};

const MERGE_PR_TOOL = {
name: 'merge_pull_request',
description: 'Merge an approved pull request. Uses squash by default for clean history. Auto-resolves the matching agenda item. Defaults to the AEGIS repo.',
input_schema: {
type: 'object' as const,
properties: {
pull_number: { type: 'number', description: 'PR number to merge' },
method: { type: 'string', enum: ['merge', 'squash', 'rebase'], description: 'Merge strategy (default: squash)' },
commit_message: { type: 'string', description: 'Custom squash/merge commit message (optional)' },
...REPO_PARAM,
},
required: ['pull_number'],
},
};
// No merge tool: a person merges every PR. GitHub text the model reads
// (issues, PR bodies, comments) is untrusted, so a merge tool would let
// anyone who can write there ask for a merge.

export const GITHUB_TOOLS = [
LIST_ORG_REPOS_TOOL,
Expand All @@ -217,7 +205,6 @@ export const GITHUB_TOOLS = [
CREATE_IMPROVEMENT_ISSUE_TOOL,
CREATE_IMPROVEMENT_PR_TOOL,
CREATE_MULTI_FILE_PR_TOOL,
MERGE_PR_TOOL,
];

// ─── Handler ─────────────────────────────────────────────────
Expand Down Expand Up @@ -316,19 +303,6 @@ export async function handleGithubTool(
return parts.join('\n');
}

if (name === 'merge_pull_request') {
const pullNumber = input.pull_number as number;
const method = (input.method as 'merge' | 'squash' | 'rebase' | undefined) ?? 'squash';
const commitMessage = input.commit_message as string | undefined;
const result = await mergePullRequest(githubToken, targetRepo, pullNumber, method, commitMessage);
if (!result.merged) return `Failed to merge PR #${pullNumber}: ${result.message}`;
// Auto-resolve matching agenda item
await db.prepare(
"UPDATE agent_agenda SET status = 'done', resolved_at = datetime('now') WHERE status = 'active' AND item LIKE ?"
).bind(`%PR #${pullNumber}%`).run();
return `Merged PR #${pullNumber} via ${method} (sha: ${result.sha.slice(0, 8)}). Agenda item auto-resolved.`;
}

if (name === 'create_improvement_issue') {
const i = input as { title: string; body: string; labels?: string[]; priority: 'low' | 'medium' | 'high' };
const { number, url } = await createIssue(
Expand Down
3 changes: 2 additions & 1 deletion web/src/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { getMemoryStats } from './kernel/memory-adapter.js';
import type { MemoryServiceBinding } from './types.js';
import type { ProceduralEntry } from './kernel/types.js';
import { VERSION } from './version.js';
import { prLink } from './lib/pr-url.js';

// ─── Data Model ──────────────────────────────────────────────

Expand Down Expand Up @@ -588,7 +589,7 @@ export function dashboardPage(data: DashboardData): string {
<span style="display:inline-block;background:${statusColors[t.status] ?? '#666'}15;color:var(--text-secondary);font-size:9px;padding:1px 4px;border-radius:2px">${esc(t.category)}</span>
${t.authority === 'proposed' ? '<span style="color:#a855f7;font-size:9px">[PROPOSED]</span>' : ''}
<span style="color:var(--text-primary);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(t.title)}</span>
${t.pr_url ? `<a href="${esc(t.pr_url)}" target="_blank" style="color:#3dd6c8;font-size:9px;text-decoration:none" title="View PR">PR</a>` : ''}
${t.pr_url ? `<a href="${esc(prLink(t.pr_url))}" target="_blank" style="color:#3dd6c8;font-size:9px;text-decoration:none" title="View PR">PR</a>` : ''}
<span style="color:var(--text-secondary);font-family:'JetBrains Mono',monospace;font-size:10px">${exitLabel}</span>
</div>`;
}).join('') || '<p style="font-size:12px;color:var(--text-secondary)">No tasks</p>'}
Expand Down
3 changes: 2 additions & 1 deletion web/src/decision-docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

import type { EdgeEnv } from './kernel/dispatch.js';
import type { MemoryFragmentResult } from './types.js';
import { prLink } from './lib/pr-url.js';
import { listIssues, listPullRequests, resolveRepoName } from './github.js';

interface TimelineEvent {
Expand Down Expand Up @@ -157,7 +158,7 @@ export async function generateDecisionDoc(
timeline.push({ date: g.created_at, source: 'goal', summary: `Goal: "${g.title}" (${g.status}, ${g.run_count} runs)` });
}
for (const t of tasks) {
timeline.push({ date: t.created_at, source: 'task', summary: `Task: "${t.title}" → ${t.status}${t.pr_url ? ` (${t.pr_url})` : ''}` });
timeline.push({ date: t.created_at, source: 'task', summary: `Task: "${t.title}" → ${t.status}${t.pr_url ? ` (${prLink(t.pr_url)})` : ''}` });
}
for (const i of issues) {
timeline.push({ date: i.created_at, source: 'issue', summary: `#${i.number}: ${i.title} (${i.state})` });
Expand Down
8 changes: 7 additions & 1 deletion web/src/email.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Resend email client — fetch-based, no SDK dependency

import { operatorConfig } from './operator/index.js';
import { prLink } from './lib/pr-url.js';

// ─── Email profile resolution ───────────────────────────────
// Maps profile names to { apiKey, from } using operator config + env keys.
Expand Down Expand Up @@ -266,6 +267,11 @@ export interface DigestSections {
bizopsInteractions: number | null;
}

/** Escape a value for a double-quoted HTML attribute. */
function escAttr(s: string): string {
return s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}

export async function sendDailyDigest(
apiKeys: { resendApiKey: string; resendApiKeyPersonal: string },
sections: DigestSections,
Expand Down Expand Up @@ -324,7 +330,7 @@ export async function sendDailyDigest(
let workShippedHtml = '';
if (sections.failedTasks.length > 0 || sections.completedTasks.length > 0) {
const taskRow = (t: DigestTask) => {
const pr = t.pr_url ? ` <a href="${t.pr_url}" style="color:#8b8bff;text-decoration:none;font-size:11px">PR →</a>` : '';
const pr = t.pr_url ? ` <a href="${escAttr(prLink(t.pr_url))}" style="color:#8b8bff;text-decoration:none;font-size:11px">PR →</a>` : '';
const errorSnippet = t.error ? `<p style="margin:2px 0 0;font-size:11px;color:#ff6b6b">${t.error.slice(0, 100)}</p>` : '';
return `
<div style="background:#111;border:1px solid #222;border-left:3px solid ${t.status === 'completed' ? '#2dd4bf' : '#ff6b6b'};border-radius:4px;padding:8px 12px;margin-bottom:6px">
Expand Down
18 changes: 0 additions & 18 deletions web/src/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -728,21 +728,3 @@ export async function removeLabelFromIssue(
}
}

// ─── PR Merge ────────────────────────────────────────────────

export async function mergePullRequest(
token: string,
repo: string,
pullNumber: number,
method: 'merge' | 'squash' | 'rebase' = 'squash',
commitMessage?: string,
): Promise<{ sha: string; merged: boolean; message: string }> {
const body: Record<string, unknown> = { merge_method: method };
if (commitMessage) body.commit_message = commitMessage;
const res = await ghFetch(token, `/repos/${repo}/pulls/${pullNumber}/merge`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
return (await res.json()) as { sha: string; merged: boolean; message: string };
}
2 changes: 1 addition & 1 deletion web/src/kernel/argus-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
//
// Authority model:
// READ_ONLY — label, comment, create agenda item (auto)
// MUTATION — close issue, merge PR (requires operator approval)
// MUTATION — close issue (requires operator approval). Nothing merges PRs.

import { commentOnIssue, addLabelsToIssue, resolveRepoName } from '../github.js';
import {
Expand Down
3 changes: 2 additions & 1 deletion web/src/kernel/briefing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
// No fluff, no preamble. Answer before the question is asked.

import type { EdgeEnv } from './dispatch.js';
import { prLink } from '../lib/pr-url.js';

export interface Briefing {
// What happened while you were away
Expand Down Expand Up @@ -92,7 +93,7 @@ export async function generateBriefing(env: EdgeEnv): Promise<Briefing> {

// Build briefing
const shipped = completedTasks.results.map(t =>
t.pr_url ? `${t.title} (${t.repo}) — PR: ${t.pr_url}` : `${t.title} (${t.repo})`
t.pr_url ? `${t.title} (${t.repo}) — PR: ${prLink(t.pr_url)}` : `${t.title} (${t.repo})`
);

const failed = failedTasks.results.map(t =>
Expand Down
3 changes: 2 additions & 1 deletion web/src/kernel/scheduled/reflection.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { type EdgeEnv } from '../dispatch.js';
import { prLink } from '../../lib/pr-url.js';
import { getAllMemoryForReflection } from '../memory-adapter.js';
import { askWorkersAiOrGroq } from './dreaming/llm.js';
// Standalone emails removed — all content routes through daily digest
Expand Down Expand Up @@ -360,7 +361,7 @@ export async function runOperatorLogCycle(env: EdgeEnv): Promise<void> {

const taskSummary = activity.tasksCompleted.length > 0
? activity.tasksCompleted.map(t => {
const pr = t.pr_url ? ` → PR: ${t.pr_url}` : '';
const pr = t.pr_url ? ` → PR: ${prLink(t.pr_url)}` : '';
return ` ✓ [${t.category}] ${t.title} (${t.repo})${pr}`;
}).join('\n')
: ' (none)';
Expand Down
13 changes: 13 additions & 0 deletions web/src/lib/pr-url.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// cc_tasks.pr_url doubles as tracking state: the pr-ready heartbeat appends
// '/closed' (0.10.0+), and pr-automerge appended '/merged' (before 0.10.0),
// once they stop tracking a PR. Those URLs 404 on GitHub, so pages and
// messages that show or link a stored pr_url strip the suffix. The task API
// (GET /api/cc-tasks) returns the stored value unchanged.

const TRACKING_SUFFIX = /\/(closed|merged)$/;

export function prLink(prUrl: string): string;
export function prLink(prUrl: string | null): string | null;
export function prLink(prUrl: string | null): string | null {
return prUrl === null ? null : prUrl.replace(TRACKING_SUFFIX, '');
}
2 changes: 1 addition & 1 deletion web/src/version.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
// Semantic version - bump on each deploy
// Used in /health endpoint and changelog tracking
export const VERSION = '0.10.0';
export const VERSION = '0.10.1';
31 changes: 7 additions & 24 deletions web/tests/claude-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,6 @@ const mockCreateGitCommit = vi.fn();
const mockUpdateRef = vi.fn();
const mockListWorkflowRuns = vi.fn();
const mockGetCombinedStatus = vi.fn();
const mockMergePullRequest = vi.fn();
vi.mock('../src/github.js', () => ({
getRepoTree: (...args: unknown[]) => mockGetRepoTree(...args),
getFileContent: (...args: unknown[]) => mockGetFileContent(...args),
Expand All @@ -116,7 +115,6 @@ vi.mock('../src/github.js', () => ({
updateRef: (...args: unknown[]) => mockUpdateRef(...args),
listWorkflowRuns: (...args: unknown[]) => mockListWorkflowRuns(...args),
getCombinedStatus: (...args: unknown[]) => mockGetCombinedStatus(...args),
mergePullRequest: (...args: unknown[]) => mockMergePullRequest(...args),
resolveRepoName: (repo: string) => repo.includes('/') ? repo : `ExampleOrg/${repo}`,
}));

Expand Down Expand Up @@ -159,7 +157,7 @@ vi.mock('../src/mcp-client.js', () => ({
// ─── Import after mocks ─────────────────────────────────────

const { buildContext, handleInProcessTool, resolveMcpTool, GITHUB_TOOLS } = await import('../src/claude-tools/index.js');
const { handleGithubTool } = await import('../src/claude-tools/github.js');
const { handleGithubTool, GITHUB_TOOLS: GITHUB_TOOL_DEFS } = await import('../src/claude-tools/github.js');
const { handleGoalTool, GOAL_TOOLS } = await import('../src/claude-tools/goals.js');
const { handleContentTool, ROUNDTABLE_TOOLS, DISPATCH_TOOLS } = await import('../src/claude-tools/content.js');
const { handleWebTool, WEB_TOOLS } = await import('../src/claude-tools/web.js');
Expand Down Expand Up @@ -247,7 +245,7 @@ describe('tool schema exports', () => {
const web = await import('../src/claude-tools/web.js');
const email = await import('../src/claude-tools/email.js');

expect(github.GITHUB_TOOLS).toHaveLength(12);
expect(github.GITHUB_TOOLS).toHaveLength(11);
expect(goals.GOAL_TOOLS).toHaveLength(5);
expect(content.ROUNDTABLE_TOOLS).toHaveLength(5);
expect(content.DISPATCH_TOOLS).toHaveLength(3);
Expand Down Expand Up @@ -764,26 +762,11 @@ describe('handleGithubTool', () => {
expect(result).toContain('too many');
});

it('merge_pull_request merges and resolves agenda', async () => {
mockMergePullRequest.mockResolvedValue({ merged: true, sha: 'merge-sha-123', message: 'ok' });
const mergeDb = baseMockDb();
const result = await handleGithubTool(
mergeDb as unknown as D1Database, 'merge_pull_request',
{ pull_number: 42 }, token, repo,
);
expect(result).toContain('Merged PR #42');
expect(result).toContain('squash');
// Check agenda auto-resolve query
const agendaQuery = mergeDb._queries.find(q => q.sql.includes('UPDATE agent_agenda'));
expect(agendaQuery).toBeDefined();
expect(agendaQuery!.bindings[0]).toContain('PR #42');
});

it('merge_pull_request reports failure', async () => {
mockMergePullRequest.mockResolvedValue({ merged: false, sha: '', message: 'not ready' });
const result = await handleGithubTool(db, 'merge_pull_request', { pull_number: 99 }, token, repo);
expect(result).toContain('Failed');
expect(result).toContain('not ready');
it('offers no merge tool, and a merge_pull_request call is not handled', async () => {
expect(GITHUB_TOOL_DEFS.map((t) => t.name)).not.toContain('merge_pull_request');
expect(GITHUB_TOOL_DEFS.some((t) => /merge/i.test(t.name))).toBe(false);
const result = await handleGithubTool(db, 'merge_pull_request', { pull_number: 42 }, token, repo);
expect(result).toBeNull();
});

it('trigger_worker_deploy creates agenda item', async () => {
Expand Down
11 changes: 0 additions & 11 deletions web/tests/github.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ import {
createPullRequest,
listWorkflowRuns,
getCombinedStatus,
mergePullRequest,
searchIssues,
closeIssue,
commentOnIssue,
Expand Down Expand Up @@ -335,16 +334,6 @@ describe('GitHub API functions', () => {
});
});

describe('mergePullRequest', () => {
it('returns merge result', async () => {
mockFetch({ sha: 'merge_sha', merged: true, message: 'Pull Request successfully merged' });

const result = await mergePullRequest(token, 'ExampleOrg/aegis', 1);
expect(result.merged).toBe(true);
expect(result.sha).toBe('merge_sha');
});
});

describe('ghFetch retry logic', () => {
it('throws GitHubApiError on 404', async () => {
mockFetchError(404, 'Not Found');
Expand Down
14 changes: 14 additions & 0 deletions web/tests/pr-url.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { describe, expect, it } from 'vitest';
import { prLink } from '../src/lib/pr-url.js';

describe('prLink', () => {
it('strips the tracking suffixes the PR heartbeats append', () => {
expect(prLink('https://github.com/ExampleOrg/aegis/pull/817/closed')).toBe('https://github.com/ExampleOrg/aegis/pull/817');
expect(prLink('https://github.com/ExampleOrg/aegis/pull/817/merged')).toBe('https://github.com/ExampleOrg/aegis/pull/817');
});

it('leaves a plain PR URL and null alone', () => {
expect(prLink('https://github.com/ExampleOrg/aegis/pull/817')).toBe('https://github.com/ExampleOrg/aegis/pull/817');
expect(prLink(null)).toBeNull();
});
});
Loading