diff --git a/.ai/agent.adf b/.ai/agent.adf index 1ae94c5..21d2278 100755 --- a/.ai/agent.adf +++ b/.ai/agent.adf @@ -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) @@ -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) diff --git a/CHANGELOG.md b/CHANGELOG.md index e3a83b9..bbc4813 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/web/package.json b/web/package.json index ff3c98f..85898d6 100755 --- a/web/package.json +++ b/web/package.json @@ -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": { diff --git a/web/src/claude-tools/github.ts b/web/src/claude-tools/github.ts index 81527e8..fbdf50f 100755 --- a/web/src/claude-tools/github.ts +++ b/web/src/claude-tools/github.ts @@ -20,7 +20,6 @@ import { updateRef, listWorkflowRuns, getCombinedStatus, - mergePullRequest, resolveRepoName, } from '../github.js'; import { addAgendaItem } from '../kernel/memory/index.js'; @@ -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, @@ -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 ───────────────────────────────────────────────── @@ -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( diff --git a/web/src/dashboard.ts b/web/src/dashboard.ts index 3f92f0d..04e581b 100644 --- a/web/src/dashboard.ts +++ b/web/src/dashboard.ts @@ -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 ────────────────────────────────────────────── @@ -588,7 +589,7 @@ export function dashboardPage(data: DashboardData): string { ${esc(t.category)} ${t.authority === 'proposed' ? '[PROPOSED]' : ''} ${esc(t.title)} - ${t.pr_url ? `PR` : ''} + ${t.pr_url ? `PR` : ''} ${exitLabel} `; }).join('') || '

No tasks

'} diff --git a/web/src/decision-docs.ts b/web/src/decision-docs.ts index ff334df..9fe4916 100644 --- a/web/src/decision-docs.ts +++ b/web/src/decision-docs.ts @@ -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 { @@ -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})` }); diff --git a/web/src/email.ts b/web/src/email.ts index ace634d..076a2fe 100755 --- a/web/src/email.ts +++ b/web/src/email.ts @@ -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. @@ -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, '&').replace(/"/g, '"').replace(//g, '>'); +} + export async function sendDailyDigest( apiKeys: { resendApiKey: string; resendApiKeyPersonal: string }, sections: DigestSections, @@ -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 ? ` PR →` : ''; + const pr = t.pr_url ? ` PR →` : ''; const errorSnippet = t.error ? `

${t.error.slice(0, 100)}

` : ''; return `
diff --git a/web/src/github.ts b/web/src/github.ts index 076c1c5..200282e 100755 --- a/web/src/github.ts +++ b/web/src/github.ts @@ -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 = { 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 }; -} diff --git a/web/src/kernel/argus-actions.ts b/web/src/kernel/argus-actions.ts index c65846b..2a701df 100644 --- a/web/src/kernel/argus-actions.ts +++ b/web/src/kernel/argus-actions.ts @@ -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 { diff --git a/web/src/kernel/briefing.ts b/web/src/kernel/briefing.ts index 1760dd7..73f7f3d 100755 --- a/web/src/kernel/briefing.ts +++ b/web/src/kernel/briefing.ts @@ -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 @@ -92,7 +93,7 @@ export async function generateBriefing(env: EdgeEnv): Promise { // 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 => diff --git a/web/src/kernel/scheduled/reflection.ts b/web/src/kernel/scheduled/reflection.ts index 3044c97..8781f10 100755 --- a/web/src/kernel/scheduled/reflection.ts +++ b/web/src/kernel/scheduled/reflection.ts @@ -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 @@ -360,7 +361,7 @@ export async function runOperatorLogCycle(env: EdgeEnv): Promise { 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)'; diff --git a/web/src/lib/pr-url.ts b/web/src/lib/pr-url.ts new file mode 100644 index 0000000..cfe6ad2 --- /dev/null +++ b/web/src/lib/pr-url.ts @@ -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, ''); +} diff --git a/web/src/version.ts b/web/src/version.ts index f9532fa..7a9727e 100755 --- a/web/src/version.ts +++ b/web/src/version.ts @@ -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'; diff --git a/web/tests/claude-tools.test.ts b/web/tests/claude-tools.test.ts index 2f72676..34c02ac 100755 --- a/web/tests/claude-tools.test.ts +++ b/web/tests/claude-tools.test.ts @@ -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), @@ -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}`, })); @@ -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'); @@ -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); @@ -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 () => { diff --git a/web/tests/github.test.ts b/web/tests/github.test.ts index 07b151f..5fe1bc2 100755 --- a/web/tests/github.test.ts +++ b/web/tests/github.test.ts @@ -18,7 +18,6 @@ import { createPullRequest, listWorkflowRuns, getCombinedStatus, - mergePullRequest, searchIssues, closeIssue, commentOnIssue, @@ -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'); diff --git a/web/tests/pr-url.test.ts b/web/tests/pr-url.test.ts new file mode 100644 index 0000000..714ffb2 --- /dev/null +++ b/web/tests/pr-url.test.ts @@ -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(); + }); +});